From 427aa6ee8ef17706d56b7bdf0483305e87750d70 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 20 Oct 2026 06:07:18 +0300 Subject: [PATCH 01/10] fix(web): collapsed composer shows quoted text instead of the citation link On narrow viewports an unfocused composer collapses to a one-line row that rendered the raw prompt, so assistant quotes, context chips and @-mentioned files appeared as their serialized markdown links. The row now reads the prompt through the editor's own segment parser and shows what each chip shows: quoted text, context labels and file names. Fixes #22076 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/ChatComposer.tsx | 8 +++- .../web/src/lib/composerPromptPreview.test.ts | 40 +++++++++++++++++++ apps/web/src/lib/composerPromptPreview.ts | 30 ++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/composerPromptPreview.test.ts create mode 100644 apps/web/src/lib/composerPromptPreview.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f87ddab1371ff..0450b50454c95 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -198,6 +198,7 @@ import { formatAttachmentUploadProgress, } from "../../lib/attachmentUploadState"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; +import { composerPromptPreviewText } from "../../lib/composerPromptPreview"; import { getTerminalFocusOwner } from "../../lib/terminalFocus"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../../keybindings"; @@ -2810,6 +2811,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pendingUserInputs.length > 0 || (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null); const showCollapsedMobilePromptRow = isComposerCollapsedMobile && !isComposerApprovalState; + const collapsedMobilePromptPreview = showCollapsedMobilePromptRow + ? composerPromptPreviewText(prompt) + : ""; const showComposerAttachAction = fileStagingLimit !== null && (!activePendingProgress || @@ -6601,13 +6605,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) data-chat-composer-transition-prompt="true" className={cn( "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-sm focus:outline-none", - prompt.trim() ? "text-foreground" : "text-placeholder", + collapsedMobilePromptPreview ? "text-foreground" : "text-placeholder", )} onPointerDown={(event) => event.preventDefault()} onClick={expandMobileComposerStable} aria-label="Expand composer" > - {prompt.trim() || + {collapsedMobilePromptPreview || (showProviderUnavailable ? "Enable a provider in Settings" : "Ask anything...")} {collapsedComposerImagePreviews} diff --git a/apps/web/src/lib/composerPromptPreview.test.ts b/apps/web/src/lib/composerPromptPreview.test.ts new file mode 100644 index 0000000000000..008059ca90e58 --- /dev/null +++ b/apps/web/src/lib/composerPromptPreview.test.ts @@ -0,0 +1,40 @@ +import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import { describe, expect, it } from "vite-plus/test"; + +import { formatInlineContextReference } from "./composerContextReferences"; +import { composerPromptPreviewText } from "./composerPromptPreview"; + +describe("composerPromptPreviewText", () => { + it("shows quoted text and context labels instead of their serialized links", () => { + const quotedText = "Cache the parsed config per worktree."; + const quote = serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("environment:test"), + threadId: ThreadId.make("thread:test"), + messageId: MessageId.make("message:assistant"), + text: quotedText, + start: 0, + end: quotedText.length, + prefix: "", + suffix: "", + }); + const terminal = formatInlineContextReference({ + kind: "terminal", + contextId: "terminal_1", + label: "Terminal 1", + }); + + expect(composerPromptPreviewText(` ${quote} Why per worktree? ${terminal} `)).toBe( + `${quotedText} Why per worktree? Terminal 1`, + ); + }); + + it("shows file mentions by their file name, as their chips do", () => { + expect( + composerPromptPreviewText( + 'Compare [package.json](package.json) with @"apps/web/src/main.tsx" ', + ), + ).toBe("Compare package.json with main.tsx"); + }); +}); diff --git a/apps/web/src/lib/composerPromptPreview.ts b/apps/web/src/lib/composerPromptPreview.ts new file mode 100644 index 0000000000000..d7a43ff34f351 --- /dev/null +++ b/apps/web/src/lib/composerPromptPreview.ts @@ -0,0 +1,30 @@ +import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; + +import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; +import { basenameOfPath } from "~/pierre-icons"; + +/** + * The draft as the editor's chips read, for surfaces that show it as plain text. + * Built from the editor's own segment parser so a new chip type can't leak its + * serialized link here. Skills stay `$name`: their chip label needs the provider's + * skill list, and the source already reads as text. + */ +export function composerPromptPreviewText(prompt: string): string { + return splitPromptIntoComposerSegments(prompt) + .map((segment) => { + switch (segment.type) { + case "text": + return segment.text; + case "mention": + return basenameOfPath(segment.path); + case "citation": + return assistantCitationsToPlainText(segment.source); + case "context-reference": + return segment.label; + case "skill": + return segment.source; + } + }) + .join("") + .trim(); +} From a8d320934c737e4237ab8b6faf019859d50b7fc1 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 20 Oct 2026 08:08:16 +0300 Subject: [PATCH 02/10] test(web): cover quote comments and multiple quotes in the collapsed composer preview Also reword the preview comment: it renders readable text, not the chips' labels. Co-Authored-By: Claude Opus 5.5 --- .../web/src/lib/composerPromptPreview.test.ts | 30 +++++++++++++++++++ apps/web/src/lib/composerPromptPreview.ts | 8 ++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/composerPromptPreview.test.ts b/apps/web/src/lib/composerPromptPreview.test.ts index 008059ca90e58..c5bc5590d6aa4 100644 --- a/apps/web/src/lib/composerPromptPreview.test.ts +++ b/apps/web/src/lib/composerPromptPreview.test.ts @@ -5,6 +5,21 @@ import { describe, expect, it } from "vite-plus/test"; import { formatInlineContextReference } from "./composerContextReferences"; import { composerPromptPreviewText } from "./composerPromptPreview"; +function quote(text: string, comment?: string): string { + return serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("environment:test"), + threadId: ThreadId.make("thread:test"), + messageId: MessageId.make("message:assistant"), + text, + start: 0, + end: text.length, + prefix: "", + suffix: "", + ...(comment === undefined ? {} : { comment }), + }); +} + describe("composerPromptPreviewText", () => { it("shows quoted text and context labels instead of their serialized links", () => { const quotedText = "Cache the parsed config per worktree."; @@ -37,4 +52,19 @@ describe("composerPromptPreviewText", () => { ), ).toBe("Compare package.json with main.tsx"); }); + + it("shows a quote's comment after its quoted text", () => { + expect(composerPromptPreviewText(quote("Retry on 503.", "Also on 429?"))).toBe( + "Retry on 503.\nComment: Also on 429?", + ); + }); + + it("shows every quote on a line in order with the typed text", () => { + const preview = composerPromptPreviewText( + `${quote("First claim.")} vs ${quote("Second claim.")} which holds?`, + ); + + expect(preview).toBe("First claim. vs Second claim. which holds?"); + expect(preview).not.toContain("t3-citation://"); + }); }); diff --git a/apps/web/src/lib/composerPromptPreview.ts b/apps/web/src/lib/composerPromptPreview.ts index d7a43ff34f351..8274aa56cc717 100644 --- a/apps/web/src/lib/composerPromptPreview.ts +++ b/apps/web/src/lib/composerPromptPreview.ts @@ -4,10 +4,10 @@ import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; import { basenameOfPath } from "~/pierre-icons"; /** - * The draft as the editor's chips read, for surfaces that show it as plain text. - * Built from the editor's own segment parser so a new chip type can't leak its - * serialized link here. Skills stay `$name`: their chip label needs the provider's - * skill list, and the source already reads as text. + * The draft as readable text instead of serialized links, for surfaces that show + * it as plain text. Built from the editor's own segment parser so a new chip type + * can't leak raw markup here. Skills stay `$name`: their chip label needs the + * provider's skill list, and the source already reads as text. */ export function composerPromptPreviewText(prompt: string): string { return splitPromptIntoComposerSegments(prompt) From b4e6db6634ecf2059d37cf7a44dca9381cd19675 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 20 Oct 2026 14:59:49 +0300 Subject: [PATCH 03/10] fix(web): collapsed composer shows chips instead of raw links The collapsed mobile composer row now draws quotes, file mentions, context references and provider skills as the same chips the editor shows, rendered inert so the row stays one tap target that expands the composer. Replaces the plain-text preview helper. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/ChatComposer.tsx | 20 ++-- ...psedComposerPromptPreview.browser.test.tsx | 89 +++++++++++++++ .../chat/CollapsedComposerPromptPreview.tsx | 108 ++++++++++++++++++ .../src/components/chat/SkillInlineText.tsx | 2 +- .../web/src/lib/composerPromptPreview.test.ts | 70 ------------ apps/web/src/lib/composerPromptPreview.ts | 30 ----- 6 files changed, 211 insertions(+), 108 deletions(-) create mode 100644 apps/web/src/components/chat/CollapsedComposerPromptPreview.browser.test.tsx create mode 100644 apps/web/src/components/chat/CollapsedComposerPromptPreview.tsx delete mode 100644 apps/web/src/lib/composerPromptPreview.test.ts delete mode 100644 apps/web/src/lib/composerPromptPreview.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 0450b50454c95..172c81d75fdc6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -158,6 +158,7 @@ import { import type { ThreadSyncPhase } from "../../threadSync"; import { ComposerBanner } from "./ComposerBanner"; import { ComposerSurface } from "./ComposerSurface"; +import { CollapsedComposerPromptPreview } from "./CollapsedComposerPromptPreview"; import { ComposerBannerStack, type ComposerBannerStackContent, @@ -198,7 +199,6 @@ import { formatAttachmentUploadProgress, } from "../../lib/attachmentUploadState"; import { isCommandPaletteOpen } from "../../commandPaletteBus"; -import { composerPromptPreviewText } from "../../lib/composerPromptPreview"; import { getTerminalFocusOwner } from "../../lib/terminalFocus"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../../keybindings"; @@ -2811,9 +2811,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pendingUserInputs.length > 0 || (!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null); const showCollapsedMobilePromptRow = isComposerCollapsedMobile && !isComposerApprovalState; - const collapsedMobilePromptPreview = showCollapsedMobilePromptRow - ? composerPromptPreviewText(prompt) - : ""; const showComposerAttachAction = fileStagingLimit !== null && (!activePendingProgress || @@ -6605,14 +6602,23 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) data-chat-composer-transition-prompt="true" className={cn( "min-w-0 flex-1 truncate bg-transparent p-0 text-left text-sm focus:outline-none", - collapsedMobilePromptPreview ? "text-foreground" : "text-placeholder", + prompt.trim() ? "text-foreground" : "text-placeholder", )} onPointerDown={(event) => event.preventDefault()} onClick={expandMobileComposerStable} aria-label="Expand composer" > - {collapsedMobilePromptPreview || - (showProviderUnavailable ? "Enable a provider in Settings" : "Ask anything...")} + {prompt.trim() ? ( + + ) : showProviderUnavailable ? ( + "Enable a provider in Settings" + ) : ( + "Ask anything..." + )} {collapsedComposerImagePreviews}