| 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 |