eleven / messenger Public

web: a dismiss X (and Escape) for the labelled composer ask (#1375) (#1381)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 97d318a2f5ed66a9893d01e2f90ac33f3a407662 parent d020a82
5 files changed, +92 −3
  • changelog/2026-07-28-composer-dismiss-x.md +4 −0
  • test/apps.test.mjs +13 −0
  • web/static/apps.js +10 −0
  • web/static/apps/chat.js +38 −3
  • web/static/style.css +27 −0
diff --git a/changelog/2026-07-28-composer-dismiss-x.md b/changelog/2026-07-28-composer-dismiss-x.md
new file mode 100644
index 0000000..81a11ed
--- /dev/null
+++ b/changelog/2026-07-28-composer-dismiss-x.md
@@ -0,0 +1,4 @@
+๐Ÿค– When Eleven asks you something right in the message box โ€” "Your name:",
+"Space name:" โ€” you can now wave it off: a small โœ• on the right returns the
+box to a regular input (Escape works too). Your next message is just a
+message, and the question waits patiently for another time.
diff --git a/test/apps.test.mjs b/test/apps.test.mjs
index 3105528..5ee1a15 100644
--- a/test/apps.test.mjs
+++ b/test/apps.test.mjs
@@ -44,6 +44,7 @@ import {
NOTICE_COMPACT_AFTER_MS,
awaitAnswer,
cancelAnswer,
+ setAnswerCancelledHook,
deliverAnswer,
groupAppsApp,
groupAppsFor,
@@ -1595,6 +1596,18 @@ await test("conversational answers: one waiter per thread, consumed on delivery"
assert.equal(deliverAnswer("t2", "x", () => {}), false, "cancelled waiter never fires");
});
+await test("conversational answers: every cancel fires the cancelled hook (#1375)", () => {
+ // chat.js registers this to clear the labelled composer โ€” the dismiss X, a
+ // chip's cancelInput, and the folded re-derive all ride the same path.
+ const cleared = [];
+ setAnswerCancelledHook((threadId) => cleared.push(threadId));
+ awaitAnswer("t9", () => {});
+ cancelAnswer("t9");
+ cancelAnswer("t-idle"); // no waiter parked โ€” the label still clears
+ assert.deepEqual(cleared, ["t9", "t-idle"]);
+ setAnswerCancelledHook(null);
+});
+
await test("conversational answers: attachments ride the payload (the photo step)", () => {
const got = [];
awaitAnswer("t3", (text, payload) => got.push([text, ((payload && payload.attachments) || []).length]));
diff --git a/web/static/apps.js b/web/static/apps.js
index eb0c442..fc326e0 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -1321,8 +1321,18 @@ const answerWaiters = new Map(); // threadId โ†’ fn(text, respond)
export function awaitAnswer(threadId, fn) {
answerWaiters.set(threadId, fn);
}
+// The label ("Your name:") is presentation for the waiter, but they live in
+// different modules โ€” chat.js registers this hook so EVERY cancelled ask
+// clears its labelled composer too (#1375): the dismiss X, a chip's
+// cancelInput, the folded re-derive. Before the hook, a cancelInput chip
+// stranded the pill until the next send.
+let answerCancelledHook = null;
+export function setAnswerCancelledHook(fn) {
+ answerCancelledHook = fn;
+}
export function cancelAnswer(threadId) {
answerWaiters.delete(threadId);
+ if (answerCancelledHook) answerCancelledHook(threadId);
}
// Whether a script sits parked on a question here โ€” the setup flow's folded
// hook uses it to tell "parked, needs a re-derive" from "mid-run or ended".
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index d6d2f3b..59df70f 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -22,7 +22,7 @@
//
// This is the DEFAULT chat experience for group/DM threads; a device can
// switch back per-device (โ‹ฎ menu โ†’ "Use original chat" โ€” see chat.js).
-import { defineApp, namespace, keyedList, runCommand, runAppMention, messageActionsFor, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor, appChatReadOnly, setCommandPanelHost, notifyCommandPanel, motionOK } from "../apps.js";
+import { defineApp, namespace, keyedList, runCommand, runAppMention, messageActionsFor, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, cancelAnswer, setAnswerCancelledHook, parseMeCommand, defaultCommandFor, appChatReadOnly, setCommandPanelHost, notifyCommandPanel, motionOK } from "../apps.js";
import { replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js";
import { openLightbox } from "../lightbox.js";
import { parseMessage, renderTree } from "../markdown.js";
@@ -147,6 +147,8 @@ function clearComposerHint(threadId) {
composerLabels.set(threadId, "");
applyComposerHint(threadId);
}
+// A cancelled ask never strands its pill (#1375) โ€” whoever cancelled it.
+setAnswerCancelledHook((threadId) => clearComposerHint(threadId));
// Feed-follow with intent: feedGlideToBottom eases towards the (still
// growing) bottom, and while it's in flight feedNearBottom stays true โ€” an
@@ -2369,6 +2371,27 @@ function buildSkeleton(el, ctx) {
fieldLabel.className = "composer-label";
fieldLabel.setAttribute("aria-hidden", "true");
fieldLabel.hidden = true;
+ // The labelled ask's dismiss X (#1375): back to a regular input without
+ // answering. It borrows the send button's slot while that's free (typing
+ // swaps it for Send; Escape still dismisses). Dismissing disarms the
+ // pending answer too, so a later plain message is an ordinary message, not
+ // a swallowed reply to a question the user waved off โ€” the parked step
+ // re-arms on the next resume, exactly like a reload.
+ const labelDismiss = document.createElement("button");
+ labelDismiss.type = "button";
+ labelDismiss.className = "composer-label-x";
+ labelDismiss.setAttribute("aria-label", "Dismiss question");
+ labelDismiss.hidden = true;
+ labelDismiss.innerHTML =
+ '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"></line><line x1="18" y1="6" x2="6" y2="18"></line></svg>';
+ const dismissComposerAsk = () => {
+ cancelAnswer(ctx.threadId); // the hook clears the label + hint with it
+ composer.focus();
+ };
+ labelDismiss.addEventListener("click", dismissComposerAsk);
+ const refreshLabelDismiss = () => {
+ labelDismiss.hidden = !(composerLabels.get(ctx.threadId) || "") || !send.hidden;
+ };
// When plain text runs the app's default command (typing into To-do adds a
// to-do โ€” see defaultCommandFor), the placeholder must SAY so: the implicit
// routing is only honest if the composer advertises it before the first send.
@@ -2387,7 +2410,17 @@ function buildSkeleton(el, ctx) {
placeholder: composerHints.get(ctx.threadId) || basePlaceholder(),
formatAsYouType: formatAsYouTypePref(),
onInput: () => onComposerInput(),
- onKeydown: (e) => onComposerKeydown(e),
+ onKeydown: (e) => {
+ // Escape dismisses an active labelled ask (#1375) โ€” the keyboard twin
+ // of the X. Only while the pill shows; otherwise Escape keeps its
+ // usual meanings (close typeahead, cancel editโ€ฆ) downstream.
+ if (e.key === "Escape" && (composerLabels.get(ctx.threadId) || "")) {
+ e.preventDefault();
+ dismissComposerAsk();
+ return;
+ }
+ onComposerKeydown(e);
+ },
// Caret moves (click / arrows) can enter or leave a token too โ€” re-check.
onKeyup: (e) => {
if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) updateTypeahead();
@@ -2415,6 +2448,7 @@ function buildSkeleton(el, ctx) {
fieldLabel.textContent = label;
fieldLabel.hidden = !label;
composer.setAriaLabel(label ? label.replace(/[:๏ผš]\s*$/, "") : "Message");
+ refreshLabelDismiss();
if (!label) {
composer.setIndent(0);
return;
@@ -2436,7 +2470,7 @@ function buildSkeleton(el, ctx) {
send.hidden = true;
send.innerHTML =
'<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="6 11 12 5 18 11"></polyline></svg>';
- field.append(fieldLabel, composer.el, send);
+ field.append(fieldLabel, composer.el, labelDismiss, send);
compose.append(attach, fileInput, field);
// A default-command thread takes text only โ€” with no one to read a file, an
// attachment is a dead end, so the affordance goes (stageFiles gates paste and
@@ -2645,6 +2679,7 @@ function buildSkeleton(el, ctx) {
const refreshSend = () => {
send.hidden = composer.isEmpty() && pending.length === 0;
send.disabled = pending.some((p) => !p.att) || !connected;
+ refreshLabelDismiss(); // the X borrows Send's slot โ€” they never overlap
};
// Enter during an upload queues the send instead of dropping it: the button
// swaps its arrow for a spinner (.send-waiting) and the message goes out the
diff --git a/web/static/style.css b/web/static/style.css
index 4606e4a..c1745df 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1605,6 +1605,33 @@ select:disabled { opacity: 0.5; cursor: default; }
@media (prefers-reduced-motion: reduce) {
.composer-label { transition: none; }
}
+/* The labelled ask's dismiss X (#1375): sits in the send button's slot while
+ that's free โ€” same geometry, quiet ink instead of accent fill. Focusable,
+ โ‰ฅ24px target (34px box, and the same rightward tap-target extension as
+ Send would be overkill: the X is a secondary action). */
+.composer-label-x {
+ position: absolute;
+ right: 4px;
+ bottom: 3px;
+ width: 34px;
+ height: 34px;
+ padding: 0;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 0;
+ background: none;
+ color: inherit;
+ opacity: 0.55;
+ cursor: pointer;
+}
+.composer-label-x:hover,
+.composer-label-x:focus-visible {
+ opacity: 1;
+ background: var(--input-bg);
+}
+.composer-label-x[hidden] { display: none; }
.composer .composer-input {
flex: 1;
resize: none;