From 6163c328e7e4e45773729876b184bc1827899a5c Mon Sep 17 00:00:00 2001 From: darox Date: Sun, 2 Aug 2026 15:25:44 +0200 Subject: [PATCH] feat(web): attach selected response text --- apps/web/src/chatSelectionAnnotation.test.ts | 94 ++++++++++ apps/web/src/chatSelectionAnnotation.ts | 128 +++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 136 ++++++++++++++ .../web/src/components/ChatView.logic.test.ts | 24 +++ apps/web/src/components/ChatView.logic.ts | 7 + apps/web/src/components/ChatView.tsx | 103 +++++++++-- apps/web/src/components/chat/ChatComposer.tsx | 44 ++++- .../chat/ChatTextSelectionPopover.tsx | 127 +++++++++++++ ...omposerPendingChatSelectionAnnotations.tsx | 116 ++++++++++++ .../components/chat/MessagesTimeline.test.tsx | 72 ++++++++ .../src/components/chat/MessagesTimeline.tsx | 168 ++++++++++++------ apps/web/src/composerDraftStore.test.ts | 101 +++++++++++ apps/web/src/composerDraftStore.ts | 108 ++++++++++- apps/web/src/proposedPlan.test.ts | 16 ++ apps/web/src/proposedPlan.ts | 11 +- docs/user/message-context.md | 9 + 16 files changed, 1184 insertions(+), 80 deletions(-) create mode 100644 apps/web/src/chatSelectionAnnotation.test.ts create mode 100644 apps/web/src/chatSelectionAnnotation.ts create mode 100644 apps/web/src/components/chat/ChatTextSelectionPopover.tsx create mode 100644 apps/web/src/components/chat/ComposerPendingChatSelectionAnnotations.tsx create mode 100644 docs/user/message-context.md diff --git a/apps/web/src/chatSelectionAnnotation.test.ts b/apps/web/src/chatSelectionAnnotation.test.ts new file mode 100644 index 00000000000..5c21bbb75b9 --- /dev/null +++ b/apps/web/src/chatSelectionAnnotation.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + appendChatSelectionAnnotationsToPrompt, + formatChatSelectionAnnotation, + parseChatSelectionMessageSegments, + stripAppendedChatSelectionAnnotations, + type ChatSelectionAnnotation, +} from "./chatSelectionAnnotation"; + +const annotation: ChatSelectionAnnotation = { + id: "selection-1", + selectedText: "Restart adapter", + comment: "Why is this necessary?", +}; + +describe("chat selection annotations", () => { + it("round-trips selected text and comments without treating markup as tags", () => { + const prompt = appendChatSelectionAnnotationsToPrompt("Explain this", [annotation]); + const segments = parseChatSelectionMessageSegments(prompt); + + expect(segments).toHaveLength(2); + expect(segments[0]).toEqual({ + kind: "text", + id: "chat-selection-text:0", + text: "Explain this\n\n", + }); + expect(segments[1]).toEqual({ kind: "selection", annotation }); + }); + + it("supports multiple annotations", () => { + const second = { + ...annotation, + id: "selection-2", + comment: "Compare this", + }; + const segments = parseChatSelectionMessageSegments( + appendChatSelectionAnnotationsToPrompt("", [annotation, second]), + ); + + expect(segments.filter((segment) => segment.kind === "selection")).toHaveLength(2); + }); + + it("keeps user-authored chat selection markup as message text", () => { + const userAuthoredMarkup = [ + '', + "", + "this is just an example", + "", + "", + "please explain this format", + "", + "", + ].join("\n"); + + expect(parseChatSelectionMessageSegments(userAuthoredMarkup)).toEqual([ + { kind: "text", id: "chat-selection-text:0", text: userAuthoredMarkup }, + ]); + }); + + it("does not let a user-authored opener swallow a following annotation", () => { + const prompt = appendChatSelectionAnnotationsToPrompt("Explain this literal ", [ + annotation, + ]); + + expect(parseChatSelectionMessageSegments(prompt)).toEqual([ + { + kind: "text", + id: "chat-selection-text:0", + text: "Explain this literal \n\n", + }, + { kind: "selection", annotation }, + ]); + }); + + it("does not emit a block for an empty annotation list", () => { + expect(appendChatSelectionAnnotationsToPrompt("Keep this", [])).toBe("Keep this"); + expect(formatChatSelectionAnnotation(annotation)).toContain(""); + }); + + it("preserves prompt whitespace when appending annotations", () => { + const prompt = " Keep this "; + + expect(appendChatSelectionAnnotationsToPrompt(prompt, [annotation])).toBe( + `${prompt}\n\n${formatChatSelectionAnnotation(annotation)}`, + ); + }); + + it("strips app-appended annotations while preserving message text", () => { + const prompt = appendChatSelectionAnnotationsToPrompt("Explain this", [annotation]); + + expect(stripAppendedChatSelectionAnnotations(prompt)).toBe("Explain this\n\n"); + }); +}); diff --git a/apps/web/src/chatSelectionAnnotation.ts b/apps/web/src/chatSelectionAnnotation.ts new file mode 100644 index 00000000000..bf154f9ff94 --- /dev/null +++ b/apps/web/src/chatSelectionAnnotation.ts @@ -0,0 +1,128 @@ +import * as Schema from "effect/Schema"; + +export const ChatSelectionAnnotationSchema = Schema.Struct({ + id: Schema.String, + selectedText: Schema.String, + comment: Schema.String, +}); + +export type ChatSelectionAnnotation = typeof ChatSelectionAnnotationSchema.Type; + +export type ChatSelectionMessageSegment = + | { readonly kind: "text"; readonly id: string; readonly text: string } + | { readonly kind: "selection"; readonly annotation: ChatSelectionAnnotation }; + +const CHAT_SELECTION_BLOCK_PATTERN = + /\n]*)>\s*\s*([\s\S]*?)\s*<\/selected_text>\s*\s*([\s\S]*?)\s*<\/user_comment>\s*<\/chat_selection>/g; +const CHAT_SELECTION_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g; +const CHAT_SELECTION_APP_MARKER_NAME = "data-t3code-appended"; +const CHAT_SELECTION_APP_MARKER_VALUE = "true"; +const CHAT_SELECTION_APP_MARKER = `${CHAT_SELECTION_APP_MARKER_NAME}="${CHAT_SELECTION_APP_MARKER_VALUE}"`; + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function unescapeXml(value: string): string { + return value + .replace(/"/g, '"') + .replace(/>/g, ">") + .replace(/</g, "<") + .replace(/&/g, "&"); +} + +function readId(rawAttributes: string, fallback: string): string { + for (const match of rawAttributes.matchAll(CHAT_SELECTION_ATTRIBUTE_PATTERN)) { + if (match[1] === "id" && match[2]) return unescapeXml(match[2]); + } + return fallback; +} + +function isAppendedChatSelection(rawAttributes: string): boolean { + for (const match of rawAttributes.matchAll(CHAT_SELECTION_ATTRIBUTE_PATTERN)) { + if ( + match[1] === CHAT_SELECTION_APP_MARKER_NAME && + match[2] === CHAT_SELECTION_APP_MARKER_VALUE + ) { + return true; + } + } + return false; +} + +export function formatChatSelectionAnnotation(annotation: ChatSelectionAnnotation): string { + return [ + ``, + "", + escapeXml(annotation.selectedText.trim()), + "", + "", + escapeXml(annotation.comment.trim()), + "", + "", + ].join("\n"); +} + +export function appendChatSelectionAnnotationsToPrompt( + prompt: string, + annotations: ReadonlyArray, +): string { + if (annotations.length === 0) return prompt; + const blocks = annotations.map(formatChatSelectionAnnotation).join("\n\n"); + const trimmedPrompt = prompt.trim(); + return trimmedPrompt.length > 0 ? `${prompt}\n\n${blocks}` : blocks; +} + +export function parseChatSelectionMessageSegments( + value: string, +): ReadonlyArray { + const segments: ChatSelectionMessageSegment[] = []; + let cursor = 0; + let parsedIndex = 0; + + for (const match of value.matchAll(CHAT_SELECTION_BLOCK_PATTERN)) { + const matchIndex = match.index ?? 0; + const beforeText = value.slice(cursor, matchIndex); + if (beforeText.length > 0) { + segments.push({ kind: "text", id: `chat-selection-text:${cursor}`, text: beforeText }); + } + + if (!isAppendedChatSelection(match[1] ?? "")) { + segments.push({ kind: "text", id: `chat-selection-text:${matchIndex}`, text: match[0] }); + cursor = matchIndex + match[0].length; + continue; + } + + const selectedText = unescapeXml(match[2] ?? "").trim(); + if (selectedText.length === 0) { + segments.push({ kind: "text", id: `chat-selection-invalid:${matchIndex}`, text: match[0] }); + } else { + segments.push({ + kind: "selection", + annotation: { + id: readId(match[1] ?? "", `chat-selection:${parsedIndex}`), + selectedText, + comment: unescapeXml(match[3] ?? "").trim(), + }, + }); + parsedIndex += 1; + } + cursor = matchIndex + match[0].length; + } + + const rest = value.slice(cursor); + if (rest.length > 0) { + segments.push({ kind: "text", id: `chat-selection-text:${cursor}`, text: rest }); + } + return segments; +} + +export function stripAppendedChatSelectionAnnotations(value: string): string { + return parseChatSelectionMessageSegments(value) + .flatMap((segment) => (segment.kind === "text" ? [segment.text] : [])) + .join(""); +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 985e943cb39..e708315055b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -21,6 +21,7 @@ import React, { Suspense, type ClipboardEvent as ReactClipboardEvent, type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, isValidElement, use, useCallback, @@ -39,6 +40,7 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; +import { ChatTextSelectionPopover } from "./chat/ChatTextSelectionPopover"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { @@ -102,6 +104,8 @@ interface ChatMarkdownProps { className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; + /** Enables attaching selected response text to the next chat message. */ + onTextSelection?: ((input: { selectedText: string; comment: string }) => void) | undefined; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; @@ -1260,7 +1264,16 @@ function ChatMarkdown({ skills = EMPTY_MARKDOWN_SKILLS, className, lineBreaks = false, + onTextSelection, }: ChatMarkdownProps) { + const markdownRootRef = useRef(null); + const selectionPointerControllerRef = useRef(null); + const selectionReadFrameRef = useRef(null); + const [selectionPopover, setSelectionPopover] = useState<{ + text: string; + rect: { top: number; left: number; width: number; height: number }; + } | null>(null); + const [selectionPopoverHasDraft, setSelectionPopoverHasDraft] = useState(false); const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, @@ -1594,13 +1607,121 @@ function ChatMarkdown({ threadRef, ]); + const readTextSelection = useCallback(() => { + if (!onTextSelection) return; + const root = markdownRootRef.current; + const selection = window.getSelection(); + if (!root || !selection || selection.isCollapsed || selection.rangeCount === 0) { + if (!selectionPopoverHasDraft) setSelectionPopover(null); + return; + } + const range = selection.getRangeAt(0); + if (!root.contains(range.commonAncestorContainer)) { + if (!selectionPopoverHasDraft) setSelectionPopover(null); + return; + } + const selectedText = selection.toString().trim(); + const rect = range.getBoundingClientRect(); + if (selectedText.length === 0 || (rect.width === 0 && rect.height === 0)) { + if (!selectionPopoverHasDraft) setSelectionPopover(null); + return; + } + if (selectionPopoverHasDraft) return; + setSelectionPopover({ + text: selectedText, + rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }, + }); + }, [onTextSelection, selectionPopoverHasDraft]); + const scheduleReadTextSelection = useCallback(() => { + if (selectionReadFrameRef.current !== null) { + window.cancelAnimationFrame(selectionReadFrameRef.current); + } + selectionReadFrameRef.current = window.requestAnimationFrame(() => { + selectionReadFrameRef.current = null; + readTextSelection(); + }); + }, [readTextSelection]); + const handleSelectionPointerDown = useCallback( + (event: ReactPointerEvent) => { + selectionPointerControllerRef.current?.abort(); + const controller = new AbortController(); + const pointerId = event.pointerId; + selectionPointerControllerRef.current = controller; + const options = { signal: controller.signal }; + window.addEventListener( + "pointerup", + (pointerEvent) => { + if (pointerEvent.pointerId !== pointerId) return; + controller.abort(); + scheduleReadTextSelection(); + }, + options, + ); + window.addEventListener( + "pointercancel", + (pointerEvent) => { + if (pointerEvent.pointerId === pointerId) controller.abort(); + }, + options, + ); + }, + [scheduleReadTextSelection], + ); + + useEffect( + () => () => { + selectionPointerControllerRef.current?.abort(); + if (selectionReadFrameRef.current !== null) { + window.cancelAnimationFrame(selectionReadFrameRef.current); + } + }, + [], + ); + + useEffect(() => { + if (!onTextSelection) return; + document.addEventListener("selectionchange", scheduleReadTextSelection); + return () => document.removeEventListener("selectionchange", scheduleReadTextSelection); + }, [onTextSelection, scheduleReadTextSelection]); + + useEffect(() => { + if (!selectionPopover) return; + const closeOnOutsidePointerDown = (event: PointerEvent) => { + const target = event.target; + if (target instanceof Element && target.closest("[data-chat-selection-popover]")) return; + if (target instanceof Node && markdownRootRef.current?.contains(target)) { + if (selectionPopoverHasDraft) return; + setSelectionPopover(null); + return; + } + setSelectionPopover(null); + }; + document.addEventListener("pointerdown", closeOnOutsidePointerDown, true); + return () => document.removeEventListener("pointerdown", closeOnOutsidePointerDown, true); + }, [selectionPopover, selectionPopoverHasDraft]); + + useEffect(() => { + if (!selectionPopover) return; + const closeOnViewportChange = () => { + if (!selectionPopoverHasDraft) setSelectionPopover(null); + }; + window.addEventListener("scroll", closeOnViewportChange, true); + window.addEventListener("resize", closeOnViewportChange); + return () => { + window.removeEventListener("scroll", closeOnViewportChange, true); + window.removeEventListener("resize", closeOnViewportChange); + }; + }, [selectionPopover, selectionPopoverHasDraft]); + return (
{text} + {selectionPopover ? ( + { + onTextSelection?.({ selectedText: selectionPopover.text, comment }); + setSelectionPopover(null); + setSelectionPopoverHasDraft(false); + }} + onCommentStateChange={setSelectionPopoverHasDraft} + onClose={() => { + setSelectionPopover(null); + setSelectionPopoverHasDraft(false); + }} + /> + ) : null}
); } diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 39285438d1a..e9800118bfd 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -28,9 +28,33 @@ import { resolveSendEnvMode, startNewThreadForProject, shouldShowBranchMismatchBanner, + shouldRestoreClearedPlanFollowUpDraft, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; +describe("shouldRestoreClearedPlanFollowUpDraft", () => { + it("restores only while the cleared prompt and annotations remain untouched", () => { + expect( + shouldRestoreClearedPlanFollowUpDraft({ + currentPrompt: "", + currentChatSelectionAnnotationCount: 0, + }), + ).toBe(true); + expect( + shouldRestoreClearedPlanFollowUpDraft({ + currentPrompt: "new draft", + currentChatSelectionAnnotationCount: 0, + }), + ).toBe(false); + expect( + shouldRestoreClearedPlanFollowUpDraft({ + currentPrompt: "", + currentChatSelectionAnnotationCount: 1, + }), + ).toBe(false); + }); +}); + const environmentId = EnvironmentId.make("environment-local"); const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 04b35fd4551..8b42c3cb269 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -28,6 +28,13 @@ export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); +export function shouldRestoreClearedPlanFollowUpDraft(input: { + readonly currentPrompt: string; + readonly currentChatSelectionAnnotationCount: number; +}): boolean { + return input.currentPrompt.length === 0 && input.currentChatSelectionAnnotationCount === 0; +} + export function startNewThreadForProject( projectRef: ScopedProjectRef | null, handleNewThread: (projectRef: ScopedProjectRef) => Promise, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2b9eda1a787..5dab6021a42 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -152,7 +152,7 @@ import { TriangleAlertIcon, WifiOffIcon, } from "lucide-react"; -import { cn, randomHex } from "~/lib/utils"; +import { cn, randomHex, randomUUID } from "~/lib/utils"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; @@ -196,6 +196,10 @@ import { } from "../lib/elementContext"; import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; +import { + appendChatSelectionAnnotationsToPrompt, + type ChatSelectionAnnotation, +} from "../chatSelectionAnnotation"; import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; @@ -274,6 +278,7 @@ import { resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, + shouldRestoreClearedPlanFollowUpDraft, shouldWriteThreadErrorToCurrentServerThread, startNewThreadForProject, waitForStartedServerThread, @@ -1257,6 +1262,12 @@ function ChatViewContent(props: ChatViewProps) { (store) => store.setPreviewAnnotations, ); const setComposerDraftReviewComments = useComposerDraftStore((store) => store.setReviewComments); + const addComposerDraftChatSelectionAnnotation = useComposerDraftStore( + (store) => store.addChatSelectionAnnotation, + ); + const setComposerDraftChatSelectionAnnotations = useComposerDraftStore( + (store) => store.setChatSelectionAnnotations, + ); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( @@ -2627,6 +2638,16 @@ function ChatViewContent(props: ChatViewProps) { }, [composerRef], ); + const addChatSelectionAnnotation = useCallback( + (input: Omit) => { + addComposerDraftChatSelectionAnnotation(composerDraftTarget, { + id: randomUUID(), + ...input, + }); + scheduleComposerFocus(); + }, + [addComposerDraftChatSelectionAnnotation, composerDraftTarget, scheduleComposerFocus], + ); const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadRef) return; @@ -4075,7 +4096,8 @@ function ChatViewContent(props: ChatViewProps) { draft.terminalContexts.length > 0 || draft.elementContexts.length > 0 || draft.previewAnnotations.length > 0 || - draft.reviewComments.length > 0), + draft.reviewComments.length > 0 || + draft.chatSelectionAnnotations.length > 0), ); }); const activeBranchMismatchKey = branchMismatchKey( @@ -4579,6 +4601,7 @@ function ChatViewContent(props: ChatViewProps) { elementContexts: composerElementContexts, previewAnnotations: composerPreviewAnnotations, reviewComments: composerReviewComments, + chatSelectionAnnotations: composerChatSelectionAnnotations, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, @@ -4598,19 +4621,23 @@ function ChatViewContent(props: ChatViewProps) { elementContextCount: composerElementContexts.length + composerPreviewAnnotations.length + - composerReviewComments.length, + composerReviewComments.length + + composerChatSelectionAnnotations.length, }); if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, + hasChatSelectionAnnotations: composerChatSelectionAnnotations.length > 0, }); - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); + const composerChatSelectionAnnotationsSnapshot: ChatSelectionAnnotation[] = [ + ...composerChatSelectionAnnotations, + ]; await onSubmitPlanFollowUp({ text: followUp.text, interactionMode: followUp.interactionMode, + draftText: followUp.draftText, + chatSelectionAnnotations: composerChatSelectionAnnotationsSnapshot, }); return; } @@ -4619,7 +4646,8 @@ function ChatViewContent(props: ChatViewProps) { sendableComposerTerminalContexts.length === 0 && composerElementContexts.length === 0 && composerPreviewAnnotations.length === 0 && - composerReviewComments.length === 0 + composerReviewComments.length === 0 && + composerChatSelectionAnnotations.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { @@ -4694,8 +4722,18 @@ function ChatViewContent(props: ChatViewProps) { const composerElementContextsSnapshot = [...composerElementContexts]; const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; + const composerChatSelectionAnnotationsSnapshot: ChatSelectionAnnotation[] = [ + ...composerChatSelectionAnnotations, + ]; + const messageTextWithSelectionAnnotations = appendChatSelectionAnnotationsToPrompt( + promptForSend, + composerChatSelectionAnnotationsSnapshot, + ); const messageTextWithContexts = appendElementContextsToPrompt( - appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), + appendTerminalContextsToPrompt( + messageTextWithSelectionAnnotations, + composerTerminalContextsSnapshot, + ), composerElementContextsSnapshot, ); const messageTextWithPreviewAnnotations = composerPreviewAnnotationsSnapshot.reduce( @@ -4792,6 +4830,9 @@ function ChatViewContent(props: ChatViewProps) { titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); } else if (composerElementContextsSnapshot.length > 0) { titleSeed = formatElementContextLabel(composerElementContextsSnapshot[0]!); + } else if (composerChatSelectionAnnotationsSnapshot.length > 0) { + const firstAnnotation = composerChatSelectionAnnotationsSnapshot[0]!; + titleSeed = firstAnnotation.comment.trim() || firstAnnotation.selectedText; } else { titleSeed = "New thread"; } @@ -4906,7 +4947,9 @@ function ChatViewContent(props: ChatViewProps) { (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations .length ?? 0) === 0 && (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments - .length ?? 0) === 0 + .length ?? 0) === 0 && + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget) + ?.chatSelectionAnnotations.length ?? 0) === 0 ) { setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); @@ -4927,6 +4970,10 @@ function ChatViewContent(props: ChatViewProps) { setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); + setComposerDraftChatSelectionAnnotations( + composerDraftTarget, + composerChatSelectionAnnotationsSnapshot, + ); composerRef.current?.resetCursorState({ cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), prompt: promptForSend, @@ -5131,9 +5178,13 @@ function ChatViewContent(props: ChatViewProps) { async ({ text, interactionMode: nextInteractionMode, + draftText: draftTextToRestore, + chatSelectionAnnotations = [], }: { text: string; interactionMode: "default" | "plan"; + draftText: string; + chatSelectionAnnotations?: ReadonlyArray; }) => { if ( !activeThread || @@ -5145,7 +5196,7 @@ function ChatViewContent(props: ChatViewProps) { return; } - const trimmed = text.trim(); + const trimmed = appendChatSelectionAnnotationsToPrompt(text, chatSelectionAnnotations).trim(); if (!trimmed) { return; } @@ -5172,10 +5223,16 @@ function ChatViewContent(props: ChatViewProps) { effort: ctxSelectedPromptEffort, text: trimmed, }); + const retryChatSelectionAnnotations = chatSelectionAnnotations.map((annotation) => ({ + ...annotation, + })); sendInFlightRef.current = true; beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); // Position this sent row once LegendList has measured the anchored tail. isAtEndRef.current = true; @@ -5269,6 +5326,25 @@ function ChatViewContent(props: ChatViewProps) { setOptimisticUserMessages((existing) => existing.filter((message) => message.id !== messageIdForSend), ); + const currentDraft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); + if ( + shouldRestoreClearedPlanFollowUpDraft({ + currentPrompt: promptRef.current, + currentChatSelectionAnnotationCount: currentDraft?.chatSelectionAnnotations.length ?? 0, + }) + ) { + promptRef.current = draftTextToRestore; + setComposerDraftPrompt(composerDraftTarget, draftTextToRestore); + setComposerDraftChatSelectionAnnotations( + composerDraftTarget, + retryChatSelectionAnnotations, + ); + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(draftTextToRestore, draftTextToRestore.length), + prompt: draftTextToRestore, + detectTrigger: true, + }); + } if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); setThreadError( @@ -5283,6 +5359,9 @@ function ChatViewContent(props: ChatViewProps) { activeThread, activeProposedPlan, beginLocalDispatch, + clearComposerDraftContent, + composerDraftTarget, + composerRef, isConnecting, isSendBusy, isServerThread, @@ -5290,12 +5369,13 @@ function ChatViewContent(props: ChatViewProps) { persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, + setComposerDraftChatSelectionAnnotations, setComposerDraftInteractionMode, + setComposerDraftPrompt, setThreadError, startThreadTurn, autoOpenPlanSidebar, environmentId, - composerRef, ], ); @@ -5831,6 +5911,7 @@ function ChatViewContent(props: ChatViewProps) { onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} + onAddChatSelectionAnnotation={addChatSelectionAnnotation} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e92ecd497e3..218e8d894d9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -79,6 +79,7 @@ import { useComposerPathSearch } from "../../lib/composerPathSearchState"; import { type ElementContextDraft } from "../../lib/elementContext"; import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; +import { ComposerPendingChatSelectionAnnotations } from "./ComposerPendingChatSelectionAnnotations"; import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards"; import { shouldUseCompactComposerPrimaryActions, @@ -106,6 +107,7 @@ import { ContextWindowMeter } from "./ContextWindowMeter"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; +import type { ChatSelectionAnnotation } from "~/chatSelectionAnnotation"; import { Separator } from "../ui/separator"; function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: ReactNode }) { @@ -492,6 +494,7 @@ export interface ChatComposerHandle { elementContexts: ElementContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; reviewComments: ReviewCommentContext[]; + chatSelectionAnnotations: ChatSelectionAnnotation[]; selectedPromptEffort: string | null; selectedModelOptionsForDispatch: unknown; selectedModelSelection: ModelSelection; @@ -704,6 +707,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerElementContexts = composerDraft.elementContexts; const composerPreviewAnnotations = composerDraft.previewAnnotations; const composerReviewComments = composerDraft.reviewComments; + const composerChatSelectionAnnotations = composerDraft.chatSelectionAnnotations; + const hasComposerPromptText = + prompt.trim().length > 0 || composerChatSelectionAnnotations.length > 0; const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); @@ -728,6 +734,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const removeComposerDraftReviewComment = useComposerDraftStore( (store) => store.removeReviewComment, ); + const removeComposerDraftChatSelectionAnnotation = useComposerDraftStore( + (store) => store.removeChatSelectionAnnotation, + ); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); @@ -1024,13 +1033,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) elementContextCount: composerElementContexts.length + composerPreviewAnnotations.length + - composerReviewComments.length, + composerReviewComments.length + + composerChatSelectionAnnotations.length, }), [ composerElementContexts.length, composerImages.length, composerPreviewAnnotations.length, composerReviewComments.length, + composerChatSelectionAnnotations.length, composerTerminalContexts, prompt, ], @@ -1166,7 +1177,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return "running"; } if (showPlanFollowUpPrompt) { - return prompt.trim().length > 0 ? "plan:refine" : "plan:implement"; + return hasComposerPromptText ? "plan:refine" : "plan:implement"; } return `idle:${composerSendState.hasSendableContent}:${isSendBusy}:${isConnecting}:${isPreparingWorktree}`; }, [ @@ -1176,8 +1187,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting, isPreparingWorktree, isSendBusy, + hasComposerPromptText, phase, - prompt, showPlanFollowUpPrompt, ]); @@ -2622,6 +2633,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) elementContexts: composerElementContextsRef.current, previewAnnotations: composerPreviewAnnotations, reviewComments: composerReviewComments, + chatSelectionAnnotations: composerChatSelectionAnnotations, selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, @@ -2643,6 +2655,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerElementContextsRef, composerPreviewAnnotations, composerReviewComments, + composerChatSelectionAnnotations, isConnecting, isComposerApprovalState, pendingUserInputs.length, @@ -2822,6 +2835,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {showCollapsedMobilePromptRow ? (
+ {composerChatSelectionAnnotations.length > 0 ? ( + + removeComposerDraftChatSelectionAnnotation(composerDraftTarget, annotationId) + } + compact + className="max-w-[38%] shrink-0" + /> + ) : null} + + ) : ( +
+ +
+ )} +
, + document.body, + ); +} diff --git a/apps/web/src/components/chat/ComposerPendingChatSelectionAnnotations.tsx b/apps/web/src/components/chat/ComposerPendingChatSelectionAnnotations.tsx new file mode 100644 index 00000000000..b578656e4ef --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingChatSelectionAnnotations.tsx @@ -0,0 +1,116 @@ +import { TextQuote, X } from "lucide-react"; + +import type { ChatSelectionAnnotation } from "~/chatSelectionAnnotation"; +import { + COMPOSER_INLINE_CHIP_CLASS_NAME, + COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME, + COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, + COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, +} from "../composerInlineChip"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { cn } from "~/lib/utils"; + +interface ComposerPendingChatSelectionAnnotationsProps { + annotations: ReadonlyArray; + onRemove: (annotationId: string) => void; + className?: string; + compact?: boolean; +} + +export function ComposerPendingChatSelectionAnnotations({ + annotations, + onRemove, + className, + compact = false, +}: ComposerPendingChatSelectionAnnotationsProps) { + if (annotations.length === 0) return null; + const label = compact + ? `${annotations.length} annotation${annotations.length === 1 ? "" : "s"}` + : `${annotations.length} annotation${annotations.length === 1 ? "" : "s"}`; + + return ( +
+ +
+ + } + > + + + {compact ? `${annotations.length} selected` : label} + + + {annotations.length === 1 ? ( + + ) : null} +
+ +
+ {annotations.map((annotation, index) => ( +
+
+ + {index + 1}. + +
+
+
Selected text
+

+ {annotation.selectedText} +

+
+ {annotation.comment.trim() ? ( +
+
Comment
+

+ {annotation.comment} +

+
+ ) : null} +
+
+ +
+ ))} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 83ca7d3e952..c9d36b11e33 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -3,6 +3,7 @@ import { createRef, type ReactNode, type Ref } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; import type { LegendListRef } from "@legendapp/list/react"; +import { appendChatSelectionAnnotationsToPrompt } from "../../chatSelectionAnnotation"; vi.mock("@legendapp/list/react", async () => { const legendListTestId = "legend-list"; @@ -196,6 +197,7 @@ function buildProps() { contentInsetEndAdjustment: 0, onIsAtEndChange: () => {}, onManualNavigation: () => {}, + onAddChatSelectionAnnotation: () => {}, }; } @@ -630,6 +632,76 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain('data-testid="file-diff"'); }); + it("summarizes attached response text without exposing its prompt markup", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("1 annotation"); + expect(markup).toContain('data-chat-selection-annotation-summary="true"'); + expect(markup).toContain("lucide-text-quote"); + expect(markup).toContain("Please explain."); + expect(markup).not.toContain("Retry the request."); + expect(markup).not.toContain("Why is this safe?"); + expect(markup).not.toContain("<chat_selection"); + }); + + it("preserves user-authored chat selection markup in the message bubble", () => { + const userAuthoredMarkup = [ + "I am discussing this format:", + '', + "", + "this is just an example", + "", + "", + "please explain this format", + "", + "", + ].join("\n"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).not.toContain('data-chat-selection-annotation-summary="true"'); + expect(markup).toContain("this is just an example"); + expect(markup).toContain("please explain this format"); + }); + + it("does not render an empty user bubble for an annotation-only message", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('data-chat-selection-annotation-summary="true"'); + expect(markup).not.toContain('data-user-message-bubble="true"'); + expect(markup).not.toContain('aria-label="Copy link"'); + }); + it("renders a failure marker for failed tool lifecycle entries", () => { const markup = renderToStaticMarkup( void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; + onAddChatSelectionAnnotation: (annotation: Omit) => void; } interface TimelineRowActivityState { @@ -184,6 +191,7 @@ interface MessagesTimelineProps { onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; + onAddChatSelectionAnnotation: (annotation: Omit) => void; } // --------------------------------------------------------------------------- @@ -219,6 +227,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onManualNavigation, hideEmptyPlaceholder = false, topFadeEnabled = false, + onAddChatSelectionAnnotation, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); @@ -430,6 +439,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, + onAddChatSelectionAnnotation, }), [ timestampFormat, @@ -444,6 +454,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, + onAddChatSelectionAnnotation, ], ); const activityState = useMemo( @@ -884,70 +895,108 @@ function UserTimelineRow({ row }: { row: Extract + segment.kind === "selection" ? [segment.annotation] : [], + ); + const userPromptText = + chatSelectionAnnotations.length > 0 + ? chatSelectionSegments + .flatMap((segment) => (segment.kind === "text" ? [segment.text] : [])) + .join("") + .trim() + : elementContextState.promptText; + const userCopyText = + chatSelectionAnnotations.length > 0 + ? stripAppendedChatSelectionAnnotations(displayedUserMessage.copyText) + : displayedUserMessage.copyText; const previewImages = userImages.filter((image) => image.name.startsWith("preview-annotation-")); const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); const canRevertAgentWork = typeof row.revertTurnCount === "number"; + const hasVisibleUserBubble = + regularImages.length > 0 || + previewAnnotations.length > 0 || + elementContexts.length > 0 || + userPromptText.trim().length > 0 || + terminalContexts.length > 0; return (
-
- {regularImages.length > 0 && ( -
- {regularImages.map((image: NonNullable[number]) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} -
- ))} -
- )} - {previewAnnotations.map((annotation, index) => ( - 0 ? ( +
+ + + {chatSelectionAnnotations.length} annotation + {chatSelectionAnnotations.length === 1 ? "" : "s"} + +
+ ) : null} + {hasVisibleUserBubble ? ( +
+ {regularImages.length > 0 && ( +
+ {regularImages.map((image: NonNullable[number]) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} +
+ ))} +
+ )} + {previewAnnotations.map((annotation, index) => ( + + ))} + {elementContexts.length > 0 ? ( +
+ {elementContexts.map((context) => ( + + ))} +
+ ) : null} + - ))} - {elementContexts.length > 0 ? ( -
- {elementContexts.map((context) => ( - - ))} -
- ) : null} - -
+
+ ) : null}
@@ -960,8 +1009,8 @@ function UserTimelineRow({ row }: { row: Extract
{canRevertAgentWork && } - {displayedUserMessage.copyText && ( - + {userCopyText.trim().length > 0 && ( + )}
@@ -1028,6 +1077,7 @@ function AssistantTimelineRow({ row }: { row: Extract { }); }); +describe("composerDraftStore persisted preview annotations", () => { + const threadId = ThreadId.make("thread-preview-annotation"); + + it("preserves preview-only drafts while normalizing legacy storage", () => { + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + merge: ( + persistedState: unknown, + currentState: ReturnType, + ) => ReturnType; + }; + }; + const previewAnnotation = { + id: "preview-1", + pageUrl: "https://example.com", + pageTitle: "Example", + comment: "Move this button.", + elements: [], + regions: [], + strokes: [], + styleChanges: [], + screenshot: null, + createdAt: "2026-03-13T12:00:00.000Z", + }; + + const mergedState = persistApi.getOptions().merge( + { + draftsByThreadId: { + [threadId]: { + prompt: "", + attachments: [], + previewAnnotations: [previewAnnotation], + }, + }, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectKey: {}, + }, + useComposerDraftStore.getInitialState(), + ); + + expect(mergedState.draftsByThreadKey[threadId]?.previewAnnotations).toEqual([ + previewAnnotation, + ]); + }); +}); + describe("composerDraftStore terminal contexts", () => { const threadId = ThreadId.make("thread-dedupe"); const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); @@ -679,6 +725,61 @@ describe("composerDraftStore review comments", () => { }); }); +describe("composerDraftStore chat selection annotations", () => { + const threadId = ThreadId.make("thread-chat-selection"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const annotation = { + id: "selection-1", + selectedText: "Restart the adapter, then retry OAuth.", + comment: "Why is this needed?", + } as const; + + beforeEach(() => { + resetComposerDraftStore(); + }); + + it("adds and removes annotations in source order", () => { + const store = useComposerDraftStore.getState(); + store.addChatSelectionAnnotation(threadRef, annotation); + store.addChatSelectionAnnotation(threadRef, { + ...annotation, + id: "selection-2", + selectedText: "Retry OAuth.", + }); + + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.chatSelectionAnnotations).toEqual([ + annotation, + { ...annotation, id: "selection-2", selectedText: "Retry OAuth." }, + ]); + + store.removeChatSelectionAnnotation(threadRef, annotation.id); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)?.chatSelectionAnnotations).toHaveLength(1); + store.removeChatSelectionAnnotation(threadRef, "selection-2"); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); + }); + + it("persists chat selection annotations and clears them with composer content", () => { + const store = useComposerDraftStore.getState(); + store.addChatSelectionAnnotation(threadRef, annotation); + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + partialize: (state: ReturnType) => unknown; + }; + }; + const persisted = persistApi.getOptions().partialize(useComposerDraftStore.getState()) as { + draftsByThreadKey?: Record; + }; + + expect( + persisted.draftsByThreadKey?.[threadKeyFor(threadId, TEST_ENVIRONMENT_ID)] + ?.chatSelectionAnnotations, + ).toEqual([annotation]); + + store.clearComposerContent(threadRef); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); + }); +}); + describe("composerDraftStore project draft thread mapping", () => { const projectId = ProjectId.make("project-a"); const otherProjectId = ProjectId.make("project-b"); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 95dde6187c8..704f26ebeba 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -45,6 +45,10 @@ import { elementContextDedupKey, newElementContextId, } from "./lib/elementContext"; +import { + ChatSelectionAnnotationSchema, + type ChatSelectionAnnotation, +} from "./chatSelectionAnnotation"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { useShallow } from "zustand/react/shallow"; @@ -54,10 +58,12 @@ import { UnifiedSettings } from "@t3tools/contracts/settings"; import { ReviewCommentContextSchema, type ReviewCommentContext } from "./reviewCommentContext"; const isRuntimeMode = Schema.is(RuntimeMode); const isProviderDriverKind = Schema.is(ProviderDriverKind); +const isPreviewAnnotationPayload = Schema.is(PreviewAnnotationPayloadSchema); const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); +const isChatSelectionAnnotation = Schema.is(ChatSelectionAnnotationSchema); export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -const COMPOSER_DRAFT_STORAGE_VERSION = 8; +const COMPOSER_DRAFT_STORAGE_VERSION = 9; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; @@ -132,6 +138,7 @@ const PersistedComposerThreadDraftState = Schema.Struct({ elementContexts: Schema.optionalKey(Schema.Array(PersistedElementContextDraft)), previewAnnotations: Schema.optionalKey(Schema.Array(PreviewAnnotationPayloadSchema)), reviewComments: Schema.optionalKey(Schema.Array(ReviewCommentContextSchema)), + chatSelectionAnnotations: Schema.optionalKey(Schema.Array(ChatSelectionAnnotationSchema)), // Keyed by `ProviderInstanceId` (open branded slug) so custom provider // instances (e.g. `codex_personal`) round-trip alongside the built-in // `codex` / `claudeAgent` / ... entries. Every prior `ProviderDriverKind` @@ -262,6 +269,7 @@ export interface ComposerThreadDraftState { elementContexts: ElementContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; reviewComments: ReviewCommentContext[]; + chatSelectionAnnotations: ChatSelectionAnnotation[]; /** * Per-instance model selection. Keyed by `ProviderInstanceId` (open * branded slug) so a default `codex` instance and a user-authored @@ -489,6 +497,15 @@ interface ComposerDraftStoreState { comments: ReadonlyArray, ) => void; removeReviewComment: (threadRef: ComposerThreadTarget, commentId: string) => void; + addChatSelectionAnnotation: ( + threadRef: ComposerThreadTarget, + annotation: ChatSelectionAnnotation, + ) => void; + setChatSelectionAnnotations: ( + threadRef: ComposerThreadTarget, + annotations: ReadonlyArray, + ) => void; + removeChatSelectionAnnotation: (threadRef: ComposerThreadTarget, annotationId: string) => void; clearPersistedAttachments: (threadRef: ComposerThreadTarget) => void; syncPersistedAttachments: ( threadRef: ComposerThreadTarget, @@ -497,9 +514,10 @@ interface ComposerDraftStoreState { clearComposerContent: (threadRef: ComposerThreadTarget) => void; /** * Clears only the prompt text and image attachments, preserving terminal / - * element contexts, preview annotations, and review comments. Used by the - * prompt stash, which can only round-trip text + images: clearing the - * session-bound contexts would destroy state nothing can restore. + * element contexts, preview annotations, review comments, and chat selection + * annotations. Used by the prompt stash, which can only round-trip text + + * images: clearing the session-bound contexts would destroy state nothing can + * restore. */ clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; } @@ -574,12 +592,14 @@ const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; const EMPTY_ELEMENT_CONTEXTS: ElementContextDraft[] = []; const EMPTY_PREVIEW_ANNOTATIONS: PreviewAnnotationPayload[] = []; const EMPTY_REVIEW_COMMENTS: ReviewCommentContext[] = []; +const EMPTY_CHAT_SELECTION_ANNOTATIONS: ChatSelectionAnnotation[] = []; Object.freeze(EMPTY_IMAGES); Object.freeze(EMPTY_IDS); Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); Object.freeze(EMPTY_ELEMENT_CONTEXTS); Object.freeze(EMPTY_PREVIEW_ANNOTATIONS); Object.freeze(EMPTY_REVIEW_COMMENTS); +Object.freeze(EMPTY_CHAT_SELECTION_ANNOTATIONS); const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial> = Object.freeze({}); const EMPTY_COMPOSER_DRAFT_MODEL_STATE = Object.freeze({ @@ -596,6 +616,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ elementContexts: EMPTY_ELEMENT_CONTEXTS, previewAnnotations: EMPTY_PREVIEW_ANNOTATIONS, reviewComments: EMPTY_REVIEW_COMMENTS, + chatSelectionAnnotations: EMPTY_CHAT_SELECTION_ANNOTATIONS, modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, activeProvider: null, runtimeMode: null, @@ -618,6 +639,7 @@ export function createEmptyThreadDraft(): ComposerThreadDraftState { elementContexts: [], previewAnnotations: [], reviewComments: [], + chatSelectionAnnotations: [], modelSelectionByProvider: {}, activeProvider: null, runtimeMode: null, @@ -691,6 +713,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { draft.elementContexts.length === 0 && draft.previewAnnotations.length === 0 && draft.reviewComments.length === 0 && + draft.chatSelectionAnnotations.length === 0 && Object.keys(draft.modelSelectionByProvider).length === 0 && draft.activeProvider === null && draft.runtimeMode === null && @@ -1670,9 +1693,15 @@ function normalizePersistedDraftsByThreadId( return normalized ? [normalized] : []; }) : []; + const previewAnnotations = Array.isArray(draftCandidate.previewAnnotations) + ? draftCandidate.previewAnnotations.filter(isPreviewAnnotationPayload) + : []; const reviewComments = Array.isArray(draftCandidate.reviewComments) ? draftCandidate.reviewComments.filter(isReviewCommentContext) : []; + const chatSelectionAnnotations = Array.isArray(draftCandidate.chatSelectionAnnotations) + ? draftCandidate.chatSelectionAnnotations.filter(isChatSelectionAnnotation) + : []; const runtimeMode = isRuntimeMode(draftCandidate.runtimeMode) ? draftCandidate.runtimeMode : null; @@ -1737,7 +1766,9 @@ function normalizePersistedDraftsByThreadId( attachments.length === 0 && terminalContexts.length === 0 && elementContexts.length === 0 && + previewAnnotations.length === 0 && reviewComments.length === 0 && + chatSelectionAnnotations.length === 0 && !hasModelData && !runtimeMode && !interactionMode @@ -1761,7 +1792,9 @@ function normalizePersistedDraftsByThreadId( attachments, ...(terminalContexts.length > 0 ? { terminalContexts } : {}), ...(elementContexts.length > 0 ? { elementContexts } : {}), + ...(previewAnnotations.length > 0 ? { previewAnnotations } : {}), ...(reviewComments.length > 0 ? { reviewComments } : {}), + ...(chatSelectionAnnotations.length > 0 ? { chatSelectionAnnotations } : {}), ...(hasModelData ? { modelSelectionByProvider: compactModelSelectionByProvider(modelSelectionByProvider), @@ -1847,6 +1880,7 @@ function partializeComposerDraftStoreState( draft.elementContexts.length === 0 && draft.previewAnnotations.length === 0 && draft.reviewComments.length === 0 && + draft.chatSelectionAnnotations.length === 0 && !hasModelData && draft.runtimeMode === null && draft.interactionMode === null @@ -1898,6 +1932,13 @@ function partializeComposerDraftStoreState( reviewComments: draft.reviewComments.map((comment) => ({ ...comment })), } : {}), + ...(draft.chatSelectionAnnotations.length > 0 + ? { + chatSelectionAnnotations: draft.chatSelectionAnnotations.map((annotation) => ({ + ...annotation, + })), + } + : {}), ...(hasModelData ? { modelSelectionByProvider: compactModelSelectionByProvider( @@ -2141,6 +2182,8 @@ function toHydratedThreadDraft( previewAnnotations: persistedDraft.previewAnnotations?.map((annotation) => ({ ...annotation })) ?? [], reviewComments: persistedDraft.reviewComments?.map((comment) => ({ ...comment })) ?? [], + chatSelectionAnnotations: + persistedDraft.chatSelectionAnnotations?.map((annotation) => ({ ...annotation })) ?? [], modelSelectionByProvider, activeProvider, runtimeMode: persistedDraft.runtimeMode ?? null, @@ -3262,6 +3305,62 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, + addChatSelectionAnnotation: (threadRef, annotation) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey || !isChatSelectionAnnotation(annotation)) return; + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + if (existing.chatSelectionAnnotations.some((entry) => entry.id === annotation.id)) { + return state; + } + return { + draftsByThreadKey: { + ...state.draftsByThreadKey, + [threadKey]: { + ...existing, + chatSelectionAnnotations: [ + ...existing.chatSelectionAnnotations, + { ...annotation }, + ], + }, + }, + }; + }); + }, + setChatSelectionAnnotations: (threadRef, annotations) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey) return; + const chatSelectionAnnotations = annotations + .filter(isChatSelectionAnnotation) + .map((annotation) => ({ ...annotation })); + set((state) => { + const existing = state.draftsByThreadKey[threadKey] ?? createEmptyThreadDraft(); + const nextDraft = { ...existing, chatSelectionAnnotations }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) delete nextDraftsByThreadKey[threadKey]; + else nextDraftsByThreadKey[threadKey] = nextDraft; + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, + removeChatSelectionAnnotation: (threadRef, annotationId) => { + const threadKey = resolveComposerDraftKey(get(), threadRef); + if (!threadKey || !annotationId) return; + set((state) => { + const current = state.draftsByThreadKey[threadKey]; + if (!current) return state; + const chatSelectionAnnotations = current.chatSelectionAnnotations.filter( + (entry) => entry.id !== annotationId, + ); + if (chatSelectionAnnotations.length === current.chatSelectionAnnotations.length) { + return state; + } + const nextDraft = { ...current, chatSelectionAnnotations }; + const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; + if (shouldRemoveDraft(nextDraft)) delete nextDraftsByThreadKey[threadKey]; + else nextDraftsByThreadKey[threadKey] = nextDraft; + return { draftsByThreadKey: nextDraftsByThreadKey }; + }); + }, clearPersistedAttachments: (threadRef) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { @@ -3337,6 +3436,7 @@ const composerDraftStore = create()( elementContexts: [], previewAnnotations: [], reviewComments: [], + chatSelectionAnnotations: [], }; const nextDraftsByThreadKey = { ...state.draftsByThreadKey }; if (shouldRemoveDraft(nextDraft)) { diff --git a/apps/web/src/proposedPlan.test.ts b/apps/web/src/proposedPlan.test.ts index 99732b5761c..ebaa2ef1e28 100644 --- a/apps/web/src/proposedPlan.test.ts +++ b/apps/web/src/proposedPlan.test.ts @@ -73,6 +73,21 @@ describe("resolvePlanFollowUpSubmission", () => { ).toEqual({ text: "PLEASE IMPLEMENT THIS PLAN:\n## Ship it\n\n- step 1", interactionMode: "default", + draftText: "", + }); + }); + + it("keeps annotation-only follow-ups in plan mode", () => { + expect( + resolvePlanFollowUpSubmission({ + draftText: " ", + planMarkdown: "## Ship it\n\n- step 1\n", + hasChatSelectionAnnotations: true, + }), + ).toEqual({ + text: "", + interactionMode: "plan", + draftText: "", }); }); @@ -85,6 +100,7 @@ describe("resolvePlanFollowUpSubmission", () => { ).toEqual({ text: "Refine step 2 first", interactionMode: "plan", + draftText: "Refine step 2 first", }); }); }); diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts index 48186392e8a..f3811793622 100644 --- a/apps/web/src/proposedPlan.ts +++ b/apps/web/src/proposedPlan.ts @@ -74,21 +74,28 @@ export function buildPlanImplementationPrompt(planMarkdown: string): string { return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`; } -export function resolvePlanFollowUpSubmission(input: { draftText: string; planMarkdown: string }): { +export function resolvePlanFollowUpSubmission(input: { + draftText: string; + planMarkdown: string; + hasChatSelectionAnnotations?: boolean; +}): { text: string; interactionMode: "default" | "plan"; + draftText: string; } { const trimmedDraftText = input.draftText.trim(); - if (trimmedDraftText.length > 0) { + if (trimmedDraftText.length > 0 || input.hasChatSelectionAnnotations) { return { text: trimmedDraftText, interactionMode: "plan", + draftText: trimmedDraftText, }; } return { text: buildPlanImplementationPrompt(input.planMarkdown), interactionMode: "default", + draftText: trimmedDraftText, }; } diff --git a/docs/user/message-context.md b/docs/user/message-context.md new file mode 100644 index 00000000000..ee8d4f203e8 --- /dev/null +++ b/docs/user/message-context.md @@ -0,0 +1,9 @@ +# Message context + +You can attach part of an assistant response to your next message without copying it manually. + +In the web and desktop apps, select text in the response, choose **Add to chat**, then add an +optional comment. + +Attached selections appear in one compact annotation chip in the composer. Open the chip to review +or remove selections before sending.