From 6163c328e7e4e45773729876b184bc1827899a5c Mon Sep 17 00:00:00 2001 From: darox Date: Sun, 2 Aug 2026 15:25:44 +0200 Subject: [PATCH 1/2] 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. From 59a7fd702a554a8dd060d24559b149e2dd3eaa46 Mon Sep 17 00:00:00 2001 From: darox Date: Mon, 3 Aug 2026 17:22:30 +0200 Subject: [PATCH 2/2] feat(web): edit and number chat annotations --- apps/web/src/chatSelectionAnnotation.test.ts | 115 +++ apps/web/src/chatSelectionAnnotation.ts | 129 +-- apps/web/src/components/ChatMarkdown.tsx | 758 +++++++++++++++++- .../web/src/components/ChatView.logic.test.ts | 14 + apps/web/src/components/ChatView.logic.ts | 8 +- apps/web/src/components/ChatView.tsx | 54 +- .../chat/AssistantMessageIndicators.tsx | 64 ++ apps/web/src/components/chat/ChatComposer.tsx | 18 +- .../chat/ChatSelectionAnnotationEditor.tsx | 123 +++ .../chat/ChatTextSelectionPopover.tsx | 116 ++- .../components/chat/MessagesTimeline.test.tsx | 253 +++++- .../src/components/chat/MessagesTimeline.tsx | 58 +- apps/web/src/composerDraftStore.test.ts | 6 +- apps/web/src/composerDraftStore.ts | 17 +- docs/user/message-context.md | 3 + packages/shared/package.json | 4 + .../shared/src/chatSelectionAnnotation.ts | 204 +++++ 17 files changed, 1742 insertions(+), 202 deletions(-) create mode 100644 apps/web/src/components/chat/AssistantMessageIndicators.tsx create mode 100644 apps/web/src/components/chat/ChatSelectionAnnotationEditor.tsx create mode 100644 packages/shared/src/chatSelectionAnnotation.ts diff --git a/apps/web/src/chatSelectionAnnotation.test.ts b/apps/web/src/chatSelectionAnnotation.test.ts index 5c21bbb75b9..49e03e7fd0e 100644 --- a/apps/web/src/chatSelectionAnnotation.test.ts +++ b/apps/web/src/chatSelectionAnnotation.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from "vite-plus/test"; import { appendChatSelectionAnnotationsToPrompt, + collectChatSelectionAnnotationsByMessageId, + countChatSelectionAnnotationsForMessage, + deriveChatSelectionIndicators, formatChatSelectionAnnotation, parseChatSelectionMessageSegments, stripAppendedChatSelectionAnnotations, @@ -39,6 +42,16 @@ describe("chat selection annotations", () => { ); expect(segments.filter((segment) => segment.kind === "selection")).toHaveLength(2); + + expect(countChatSelectionAnnotationsForMessage([annotation, second], "selection-message")).toBe( + 0, + ); + expect( + countChatSelectionAnnotationsForMessage( + [{ ...annotation, messageId: "selection-message" }, second], + "selection-message", + ), + ).toBe(1); }); it("keeps user-authored chat selection markup as message text", () => { @@ -58,6 +71,47 @@ describe("chat selection annotations", () => { ]); }); + it("does not treat the old public marker as an appended annotation", () => { + 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("recognizes sent annotations without a local registry", () => { + const sentMarkup = [ + '', + "", + "this was sent on another device", + "", + "", + "keep the annotation", + "", + "", + ].join("\n"); + + expect(parseChatSelectionMessageSegments(sentMarkup)).toEqual([ + { + kind: "selection", + annotation: { + id: "not-generated", + selectedText: "this was sent on another device", + comment: "keep the annotation", + }, + }, + ]); + }); + it("does not let a user-authored opener swallow a following annotation", () => { const prompt = appendChatSelectionAnnotationsToPrompt("Explain this literal ", [ annotation, @@ -78,6 +132,67 @@ describe("chat selection annotations", () => { expect(formatChatSelectionAnnotation(annotation)).toContain(""); }); + it("persists the source message id in the sent prompt", () => { + const sourceAnnotation = { + ...annotation, + messageId: "assistant-1", + sourceStart: 42, + sourceEnd: 63, + }; + const prompt = appendChatSelectionAnnotationsToPrompt("Explain this", [sourceAnnotation]); + const segments = parseChatSelectionMessageSegments(prompt); + + expect(segments[1]).toEqual({ kind: "selection", annotation: sourceAnnotation }); + expect(prompt).toContain('message_id="assistant-1"'); + expect(prompt).toContain('source_start="42" source_end="63"'); + }); + + it("groups pending annotations by source message", () => { + const pending = { ...annotation, messageId: "assistant-1" }; + const byMessageId = collectChatSelectionAnnotationsByMessageId([pending]); + + expect(byMessageId.get("assistant-1")).toEqual([pending]); + expect(deriveChatSelectionIndicators([pending])).toEqual([ + { + id: pending.id, + kind: "text-comment", + number: 1, + annotation: pending, + }, + ]); + }); + + it("creates one numbered indicator for every annotation in source order", () => { + const second = { ...annotation, id: "selection-2", comment: "" }; + + expect(deriveChatSelectionIndicators([annotation, second])).toEqual([ + { + id: annotation.id, + kind: "text-comment", + number: 1, + annotation, + }, + { + id: second.id, + kind: "text-selection", + number: 2, + annotation: second, + }, + ]); + }); + + it("uses global indicator numbers when annotations are grouped by message", () => { + const second = { ...annotation, id: "selection-2", comment: "Second" }; + const numbers = new Map([ + [annotation.id, 1], + [second.id, 3], + ]); + + expect( + deriveChatSelectionIndicators([annotation, second], numbers).map(({ number }) => number), + ).toEqual([1, 3]); + }); + it("preserves prompt whitespace when appending annotations", () => { const prompt = " Keep this "; diff --git a/apps/web/src/chatSelectionAnnotation.ts b/apps/web/src/chatSelectionAnnotation.ts index bf154f9ff94..21332491f47 100644 --- a/apps/web/src/chatSelectionAnnotation.ts +++ b/apps/web/src/chatSelectionAnnotation.ts @@ -1,128 +1 @@ -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(""); -} +export * from "@t3tools/shared/chatSelectionAnnotation"; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e708315055b..29939aba1e1 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -40,7 +40,17 @@ 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 { + ChatTextSelectionPopover, + type ChatTextSelectionPopoverProps, +} from "./chat/ChatTextSelectionPopover"; +import { ChatSelectionAnnotationEditor } from "./chat/ChatSelectionAnnotationEditor"; +import { AssistantMessageIndicators } from "./chat/AssistantMessageIndicators"; +import { + deriveChatSelectionIndicators, + type ChatSelectionAnnotation, + type ChatSelectionIndicator, +} from "../chatSelectionAnnotation"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { @@ -104,11 +114,451 @@ 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; + /** Enables the answer-selection actions used by assistant messages. */ + onTextSelection?: + | ((input: { + selectedText: string; + comment: string; + sourceStart?: number; + sourceEnd?: number; + }) => void) + | undefined; + annotations?: ReadonlyArray; + indicatorNumberByAnnotationId?: ReadonlyMap; + editableAnnotationIds?: ReadonlySet; + onUpdateAnnotation?: ((annotationId: string, comment: string) => void) | undefined; + onRemoveAnnotation?: ((annotationId: string) => void) | undefined; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; +const EMPTY_CHAT_SELECTION_ANNOTATIONS: ReadonlyArray = []; + +interface ChatMarkdownHighlightRect { + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +} + +function mergeChatMarkdownHighlightRects( + rects: ReadonlyArray, +): ReadonlyArray { + const sortedRects = [...rects].sort((first, second) => { + return first.top - second.top || first.left - second.left; + }); + const mergedRects: ChatMarkdownHighlightRect[] = []; + + for (const rect of sortedRects) { + const previous = mergedRects.at(-1); + if (!previous) { + mergedRects.push(rect); + continue; + } + + const verticalOverlap = + Math.min(previous.top + previous.height, rect.top + rect.height) - + Math.max(previous.top, rect.top); + const minimumHeight = Math.min(previous.height, rect.height); + const horizontalGap = rect.left - (previous.left + previous.width); + if (verticalOverlap < minimumHeight * 0.5 || Math.abs(horizontalGap) > 3) { + mergedRects.push(rect); + continue; + } + + const right = Math.max(previous.left + previous.width, rect.left + rect.width); + const bottom = Math.max(previous.top + previous.height, rect.top + rect.height); + mergedRects[mergedRects.length - 1] = { + left: Math.min(previous.left, rect.left), + top: Math.min(previous.top, rect.top), + width: right - Math.min(previous.left, rect.left), + height: bottom - Math.min(previous.top, rect.top), + }; + } + + return mergedRects; +} + +interface ChatMarkdownIndicatorPlacement { + readonly indicator: ChatSelectionIndicator; + readonly top: number; +} + +interface ChatMarkdownTextNodeEntry { + readonly node: Text; + readonly start: number; + readonly end: number; +} + +interface ChatMarkdownTextIndex { + readonly text: string; + readonly nodes: ReadonlyArray; +} + +function readChatMarkdownTextRects( + root: HTMLElement, + selectionRect: { top: number; left: number; width: number; height: number }, +): ReadonlyArray<{ top: number; left: number; width: number; height: number }> { + const rects: Array<{ + top: number; + left: number; + width: number; + height: number; + }> = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let current = walker.nextNode(); + while (current instanceof Text) { + const parentRect = current.parentElement?.getBoundingClientRect(); + const isNearSelection = + parentRect && + parentRect.bottom >= selectionRect.top - 80 && + parentRect.top <= selectionRect.top + selectionRect.height + 80; + if (current.data.trim().length > 0 && isNearSelection) { + const range = document.createRange(); + range.selectNodeContents(current); + for (const rect of range.getClientRects()) { + if (rect.width > 0 && rect.height > 0) { + rects.push({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }); + } + } + } + current = walker.nextNode(); + } + return rects; +} + +const CHAT_MARKDOWN_BLOCK_TAGS = new Set([ + "blockquote", + "dd", + "div", + "dl", + "dt", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "li", + "ol", + "p", + "pre", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "tr", + "ul", +]); + +function nearestChatMarkdownBlock(node: Text, root: HTMLElement): Element { + let current = node.parentElement; + while (current && current !== root) { + if (CHAT_MARKDOWN_BLOCK_TAGS.has(current.tagName.toLowerCase())) { + return current; + } + current = current.parentElement; + } + return root; +} + +function buildChatMarkdownTextIndex(root: HTMLElement): ChatMarkdownTextIndex { + const nodes: ChatMarkdownTextNodeEntry[] = []; + let text = ""; + let previousBlock: Element | null = null; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let current = walker.nextNode(); + + while (current instanceof Text) { + if (current.data.length > 0) { + const block = nearestChatMarkdownBlock(current, root); + if (previousBlock && block !== previousBlock) { + text += "\n"; + } + const start = text.length; + text += current.data; + nodes.push({ node: current, start, end: text.length }); + previousBlock = block; + } + current = walker.nextNode(); + } + + return { text, nodes }; +} + +function findChatMarkdownBoundaryOffset( + index: ChatMarkdownTextIndex, + container: Node, + offset: number, +): number | null { + if (container instanceof Text) { + const entry = index.nodes.find((candidate) => candidate.node === container); + return entry ? entry.start + Math.max(0, Math.min(offset, container.data.length)) : null; + } + if (!(container instanceof Element)) return null; + + const descendants = (candidate: Node) => + index.nodes.filter( + (entry) => + candidate === entry.node || + (candidate instanceof Element && candidate.contains(entry.node)), + ); + const child = container.childNodes[offset]; + if (child) { + return descendants(child)[0]?.start ?? null; + } + const entries = descendants(container); + return entries.at(-1)?.end ?? null; +} + +function readChatMarkdownSelectionSourceOffsets( + root: HTMLElement, + range: Range, + selectedText: string, +): { sourceStart: number; sourceEnd: number } | undefined { + const index = buildChatMarkdownTextIndex(root); + const rawStart = findChatMarkdownBoundaryOffset(index, range.startContainer, range.startOffset); + const rawEnd = findChatMarkdownBoundaryOffset(index, range.endContainer, range.endOffset); + if (rawStart === null || rawEnd === null || rawEnd <= rawStart) return undefined; + + const sourceStart = Math.min(rawStart, rawEnd); + const sourceEnd = Math.max(rawStart, rawEnd); + const rawSelectedText = index.text.slice(sourceStart, sourceEnd); + const leadingWhitespace = rawSelectedText.length - rawSelectedText.trimStart().length; + const trailingWhitespace = rawSelectedText.length - rawSelectedText.trimEnd().length; + const trimmedStart = sourceStart + leadingWhitespace; + const trimmedEnd = Math.max(trimmedStart, sourceEnd - trailingWhitespace); + const sourceSelection = index.text.slice(trimmedStart, trimmedEnd); + if ( + sourceSelection.trim() !== selectedText.trim() && + normalizeChatMarkdownSearchText(sourceSelection) !== + normalizeChatMarkdownSearchText(selectedText) + ) { + return undefined; + } + return { sourceStart: trimmedStart, sourceEnd: trimmedEnd }; +} + +function normalizeChatMarkdownSearchText(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +interface ChatMarkdownTextMatch { + readonly start: number; + readonly end: number; +} + +function selectClosestChatMarkdownTextMatch( + matches: ReadonlyArray, + preferredStart?: number, +): ChatMarkdownTextMatch | null { + const first = matches[0]; + if (!first) return null; + if (preferredStart === undefined) return first; + + return matches.reduce((closest, match) => + Math.abs(match.start - preferredStart) < Math.abs(closest.start - preferredStart) + ? match + : closest, + ); +} + +function findChatMarkdownTextMatch( + source: string, + target: string, + preferredStart?: number, +): ChatMarkdownTextMatch | null { + const trimmedTarget = target.trim(); + if (trimmedTarget.length === 0) return null; + + const exactMatches: ChatMarkdownTextMatch[] = []; + let exactSearchStart = 0; + while (exactSearchStart < source.length) { + const exactStart = source.indexOf(trimmedTarget, exactSearchStart); + if (exactStart < 0) break; + exactMatches.push({ start: exactStart, end: exactStart + trimmedTarget.length }); + exactSearchStart = exactStart + 1; + } + if (exactMatches.length > 0) { + return selectClosestChatMarkdownTextMatch(exactMatches, preferredStart); + } + + const normalizedCharacters: Array<{ value: string; start: number; end: number }> = []; + let sourceIndex = 0; + while (sourceIndex < source.length) { + if (/\s/.test(source[sourceIndex]!)) { + const whitespaceStart = sourceIndex; + while (sourceIndex < source.length && /\s/.test(source[sourceIndex]!)) { + sourceIndex += 1; + } + if (normalizedCharacters.length > 0) { + normalizedCharacters.push({ value: " ", start: whitespaceStart, end: sourceIndex }); + } + continue; + } + normalizedCharacters.push({ + value: source[sourceIndex]!, + start: sourceIndex, + end: sourceIndex + 1, + }); + sourceIndex += 1; + } + while (normalizedCharacters.at(-1)?.value === " ") normalizedCharacters.pop(); + + const normalizedSource = normalizedCharacters.map(({ value }) => value).join(""); + const normalizedTarget = normalizeChatMarkdownSearchText(trimmedTarget); + if (normalizedTarget.length === 0) return null; + + const normalizedMatches: ChatMarkdownTextMatch[] = []; + let normalizedSearchStart = 0; + while (normalizedSearchStart < normalizedSource.length) { + const normalizedStart = normalizedSource.indexOf(normalizedTarget, normalizedSearchStart); + if (normalizedStart < 0) break; + const normalizedEnd = normalizedStart + normalizedTarget.length; + const firstCharacter = normalizedCharacters[normalizedStart]; + const lastCharacter = normalizedCharacters[normalizedEnd - 1]; + if (firstCharacter && lastCharacter) { + normalizedMatches.push({ start: firstCharacter.start, end: lastCharacter.end }); + } + normalizedSearchStart = normalizedStart + 1; + } + + return selectClosestChatMarkdownTextMatch(normalizedMatches, preferredStart); +} + +function resolveChatMarkdownTextRange( + root: HTMLElement, + target: string, + sourceStart?: number, + sourceEnd?: number, + index: ChatMarkdownTextIndex = buildChatMarkdownTextIndex(root), +): Range | null { + const validSourceStart = sourceStart; + const validSourceEnd = sourceEnd; + const hasSourceOffsets = + typeof validSourceStart === "number" && + typeof validSourceEnd === "number" && + Number.isSafeInteger(validSourceStart) && + Number.isSafeInteger(validSourceEnd) && + validSourceStart >= 0 && + validSourceEnd > validSourceStart && + validSourceEnd <= index.text.length; + const offsetMatch = hasSourceOffsets + ? index.text.slice(validSourceStart, validSourceEnd).trim() === target.trim() || + normalizeChatMarkdownSearchText(index.text.slice(validSourceStart, validSourceEnd)) === + normalizeChatMarkdownSearchText(target) + ? { start: validSourceStart, end: validSourceEnd } + : null + : null; + const preferredStart = + typeof validSourceStart === "number" && Number.isSafeInteger(validSourceStart) + ? validSourceStart + : typeof validSourceEnd === "number" && Number.isSafeInteger(validSourceEnd) + ? Math.max(0, validSourceEnd - target.trim().length) + : undefined; + const match = offsetMatch ?? findChatMarkdownTextMatch(index.text, target, preferredStart); + if (!match) return null; + const matchStart = match.start; + const matchEnd = match.end; + if (matchStart === undefined || matchEnd === undefined) return null; + + const startNode = index.nodes.find( + (entry) => matchStart >= entry.start && matchStart < entry.end, + ); + const endNode = index.nodes.find((entry) => matchEnd > entry.start && matchEnd <= entry.end); + if (!startNode || !endNode) return null; + + const range = document.createRange(); + range.setStart(startNode.node, matchStart - startNode.start); + range.setEnd(endNode.node, matchEnd - endNode.start); + return range; +} + +function resolveChatMarkdownHighlightRects( + root: HTMLElement, + annotation: ChatSelectionAnnotation, + index: ChatMarkdownTextIndex, +): ReadonlyArray { + const range = resolveChatMarkdownTextRange( + root, + annotation.selectedText, + annotation.sourceStart, + annotation.sourceEnd, + index, + ); + if (!range) return []; + + const rootRect = root.getBoundingClientRect(); + return mergeChatMarkdownHighlightRects( + Array.from(range.getClientRects()) + .filter((rect) => rect.width > 0 && rect.height > 0) + .map((rect) => ({ + left: rect.left - rootRect.left + root.scrollLeft, + top: rect.top - rootRect.top + root.scrollTop, + width: rect.width, + height: rect.height, + })), + ); +} + +function resolveChatMarkdownIndicatorPlacements( + root: HTMLElement, + indicators: ReadonlyArray, + index: ChatMarkdownTextIndex, +): ReadonlyArray { + const rootRect = root.getBoundingClientRect(); + const indicatorHalfSize = 10; + const minTop = root.scrollTop + indicatorHalfSize; + const maxTop = Math.max(minTop, root.scrollTop + root.clientHeight - indicatorHalfSize); + const placements: ChatMarkdownIndicatorPlacement[] = []; + for (const indicator of indicators) { + const range = resolveChatMarkdownTextRange( + root, + indicator.annotation.selectedText, + indicator.annotation.sourceStart, + indicator.annotation.sourceEnd, + index, + ); + const rects = range ? Array.from(range.getClientRects()) : []; + const firstRect = rects[0]; + if (!firstRect) continue; + const sourceTop = Math.min( + maxTop, + Math.max(minTop, firstRect.top - rootRect.top + root.scrollTop + firstRect.height / 2), + ); + const occupiedTops = placements.map((placement) => placement.top); + const maxOffset = Math.ceil(Math.max(sourceTop - minTop, maxTop - sourceTop) / 28); + let top = sourceTop; + let placed = false; + for (let offset = 0; offset <= maxOffset; offset += 1) { + const candidates = + offset === 0 ? [sourceTop] : [sourceTop + offset * 28, sourceTop - offset * 28]; + const availableCandidate = candidates.find( + (candidate) => + candidate >= minTop && + candidate <= maxTop && + occupiedTops.every((occupiedTop) => Math.abs(occupiedTop - candidate) >= 28), + ); + if (availableCandidate === undefined) continue; + top = availableCandidate; + placed = true; + break; + } + if (!placed) { + // Keep every annotation reachable even when there is not enough vertical + // room for a fully separated stack of markers. + top = sourceTop; + } + placements.push({ indicator, top }); + } + return placements; +} const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; @@ -290,9 +740,11 @@ function extractCodeBlock( const onlyChild = childNodes[0]; if ( - !isValidElement<{ className?: string; children?: ReactNode; node?: { tagName?: string } }>( - onlyChild, - ) + !isValidElement<{ + className?: string; + children?: ReactNode; + node?: { tagName?: string }; + }>(onlyChild) ) { return null; } @@ -1137,7 +1589,11 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }, (error) => { reportMarkdownActionFailure( - { operation: "copy-file-path", target: targetPath, copyTarget: title }, + { + operation: "copy-file-path", + target: targetPath, + copyTarget: title, + }, error, ); toastManager.add( @@ -1166,7 +1622,12 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ [ { id: "open", label: "Open in editor" }, ...(onOpenInBrowser - ? ([{ id: "open-in-browser", label: "Open in integrated browser" }] as const) + ? ([ + { + id: "open-in-browser", + label: "Open in integrated browser", + }, + ] as const) : []), { id: "copy-relative", label: "Copy relative path" }, { id: "copy-full", label: "Copy full path" }, @@ -1265,15 +1726,49 @@ function ChatMarkdown({ className, lineBreaks = false, onTextSelection, + annotations = EMPTY_CHAT_SELECTION_ANNOTATIONS, + indicatorNumberByAnnotationId, + editableAnnotationIds, + onUpdateAnnotation, + onRemoveAnnotation, }: 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 [selectionPopover, setSelectionPopover] = useState< + | (Pick & { + highlightRects: ReadonlyArray; + sourceStart?: number; + sourceEnd?: number; + }) + | null + >(null); + const [highlightRects, setHighlightRects] = useState>( + [], + ); + const [indicatorPlacements, setIndicatorPlacements] = useState< + ReadonlyArray + >([]); + const [activeAnnotationId, setActiveAnnotationId] = useState(null); + const [editorAnchorRect, setEditorAnchorRect] = useState< + ChatTextSelectionPopoverProps["rect"] | null + >(null); + const indicators = useMemo( + () => deriveChatSelectionIndicators(annotations, indicatorNumberByAnnotationId), + [annotations, indicatorNumberByAnnotationId], + ); + const activeAnnotation = + annotations.find((annotation) => annotation.id === activeAnnotationId) ?? null; + const activeAnnotationSelectionId = activeAnnotation?.id; + const activeAnnotationSelectedText = activeAnnotation?.selectedText; + const activeAnnotationSourceStart = activeAnnotation?.sourceStart; + const activeAnnotationSourceEnd = activeAnnotation?.sourceEnd; const [selectionPopoverHasDraft, setSelectionPopoverHasDraft] = useState(false); + const selectionPopoverHasDraftRef = useRef(false); + const handleSelectionPopoverCommentStateChange = useCallback((hasDraft: boolean) => { + selectionPopoverHasDraftRef.current = hasDraft; + setSelectionPopoverHasDraft(hasDraft); + }, []); const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, @@ -1456,7 +1951,10 @@ function ChatMarkdown({ event.currentTarget.closest("li")?.dataset.taskMarkerOffset, ); if (!Number.isSafeInteger(markerOffset)) return; - onTaskListChange({ markerOffset, checked: event.currentTarget.checked }); + onTaskListChange({ + markerOffset, + checked: event.currentTarget.checked, + }); }} /> ); @@ -1612,26 +2110,53 @@ function ChatMarkdown({ const root = markdownRootRef.current; const selection = window.getSelection(); if (!root || !selection || selection.isCollapsed || selection.rangeCount === 0) { - if (!selectionPopoverHasDraft) setSelectionPopover(null); + if (!selectionPopoverHasDraftRef.current) setSelectionPopover(null); return; } const range = selection.getRangeAt(0); if (!root.contains(range.commonAncestorContainer)) { - if (!selectionPopoverHasDraft) setSelectionPopover(null); + if (!selectionPopoverHasDraftRef.current) 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); + if (!selectionPopoverHasDraftRef.current) setSelectionPopover(null); return; } - if (selectionPopoverHasDraft) return; + if (selectionPopoverHasDraftRef.current) return; + const selectionRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; + const rootRect = root.getBoundingClientRect(); + const selectionHighlightRects = mergeChatMarkdownHighlightRects( + Array.from(range.getClientRects()) + .filter((selectionRect) => selectionRect.width > 0 && selectionRect.height > 0) + .map((selectionRect) => ({ + left: selectionRect.left - rootRect.left + root.scrollLeft, + top: selectionRect.top - rootRect.top + root.scrollTop, + width: selectionRect.width, + height: selectionRect.height, + })), + ); + const sourceOffsets = readChatMarkdownSelectionSourceOffsets(root, range, selectedText); setSelectionPopover({ text: selectedText, - rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }, + rect: selectionRect, + avoidRects: readChatMarkdownTextRects(root, selectionRect), + highlightRects: selectionHighlightRects, + ...(sourceOffsets + ? { + sourceStart: sourceOffsets.sourceStart, + sourceEnd: sourceOffsets.sourceEnd, + } + : {}), }); - }, [onTextSelection, selectionPopoverHasDraft]); + }, [onTextSelection]); + const selectionActionsEnabled = onTextSelection !== undefined; const scheduleReadTextSelection = useCallback(() => { if (selectionReadFrameRef.current !== null) { window.cancelAnimationFrame(selectionReadFrameRef.current); @@ -1689,8 +2214,12 @@ function ChatMarkdown({ const closeOnOutsidePointerDown = (event: PointerEvent) => { const target = event.target; if (target instanceof Element && target.closest("[data-chat-selection-popover]")) return; + if (target instanceof Element && target.closest("[data-chat-selection-indicator]")) { + setSelectionPopover(null); + return; + } if (target instanceof Node && markdownRootRef.current?.contains(target)) { - if (selectionPopoverHasDraft) return; + if (selectionPopoverHasDraftRef.current) return; setSelectionPopover(null); return; } @@ -1698,12 +2227,12 @@ function ChatMarkdown({ }; document.addEventListener("pointerdown", closeOnOutsidePointerDown, true); return () => document.removeEventListener("pointerdown", closeOnOutsidePointerDown, true); - }, [selectionPopover, selectionPopoverHasDraft]); + }, [selectionPopover]); useEffect(() => { if (!selectionPopover) return; const closeOnViewportChange = () => { - if (!selectionPopoverHasDraft) setSelectionPopover(null); + if (!selectionPopoverHasDraftRef.current) setSelectionPopover(null); }; window.addEventListener("scroll", closeOnViewportChange, true); window.addEventListener("resize", closeOnViewportChange); @@ -1711,17 +2240,110 @@ function ChatMarkdown({ window.removeEventListener("scroll", closeOnViewportChange, true); window.removeEventListener("resize", closeOnViewportChange); }; - }, [selectionPopover, selectionPopoverHasDraft]); + }, [selectionPopover]); + + useEffect(() => { + if (activeAnnotationId && !activeAnnotation) { + setActiveAnnotationId(null); + setEditorAnchorRect(null); + } + }, [activeAnnotation, activeAnnotationId]); + + useEffect(() => { + if (!activeAnnotationSelectedText?.trim()) return; + const root = markdownRootRef.current; + if (!root) return; + const range = resolveChatMarkdownTextRange( + root, + activeAnnotationSelectedText, + activeAnnotationSourceStart, + activeAnnotationSourceEnd, + ); + range?.startContainer.parentElement?.scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + }, [ + activeAnnotationSelectedText, + activeAnnotationSelectionId, + activeAnnotationSourceEnd, + activeAnnotationSourceStart, + ]); + + useEffect(() => { + const root = markdownRootRef.current; + if (!root || indicators.length === 0) { + setHighlightRects((current) => (current.length === 0 ? current : [])); + setIndicatorPlacements((current) => (current.length === 0 ? current : [])); + return; + } + + let frame: number | null = null; + const update = () => { + frame = null; + const textIndex = buildChatMarkdownTextIndex(root); + const nextHighlightRects = activeAnnotation + ? resolveChatMarkdownHighlightRects(root, activeAnnotation, textIndex) + : []; + const nextIndicatorPlacements = resolveChatMarkdownIndicatorPlacements( + root, + indicators, + textIndex, + ); + setHighlightRects((current) => + current.length === nextHighlightRects.length && + current.every((rect, index) => { + const next = nextHighlightRects[index]; + return ( + next !== undefined && + rect.left === next.left && + rect.top === next.top && + rect.width === next.width && + rect.height === next.height + ); + }) + ? current + : nextHighlightRects, + ); + setIndicatorPlacements((current) => + current.length === nextIndicatorPlacements.length && + current.every((placement, index) => { + const next = nextIndicatorPlacements[index]; + return ( + next !== undefined && + placement.indicator.id === next.indicator.id && + placement.top === next.top + ); + }) + ? current + : nextIndicatorPlacements, + ); + }; + const scheduleUpdate = () => { + if (frame === null) frame = window.requestAnimationFrame(update); + }; + scheduleUpdate(); + const observer = new ResizeObserver(scheduleUpdate); + observer.observe(root); + window.addEventListener("resize", scheduleUpdate); + return () => { + if (frame !== null) window.cancelAnimationFrame(frame); + observer.disconnect(); + window.removeEventListener("resize", scheduleUpdate); + }; + }, [activeAnnotation, indicators, text]); return (
0 && "pr-8", className, )} onCopy={handleCopy} - onPointerDown={onTextSelection ? handleSelectionPointerDown : undefined} + onPointerDown={selectionActionsEnabled ? handleSelectionPointerDown : undefined} > {text} + {highlightRects.length > 0 ? ( + + ) : null} + {selectionPopoverHasDraft && selectionPopover?.highlightRects.length ? ( + + ) : null} + { + setActiveAnnotationId(indicator.id); + setEditorAnchorRect( + editableAnnotationIds?.has(indicator.id) + ? { + top: anchorRect.top, + left: anchorRect.left, + width: anchorRect.width, + height: anchorRect.height, + } + : null, + ); + }} + /> {selectionPopover ? ( { - onTextSelection?.({ selectedText: selectionPopover.text, comment }); + onTextSelection?.({ + selectedText: selectionPopover.text, + comment, + ...(selectionPopover.sourceStart !== undefined + ? { sourceStart: selectionPopover.sourceStart } + : {}), + ...(selectionPopover.sourceEnd !== undefined + ? { sourceEnd: selectionPopover.sourceEnd } + : {}), + }); setSelectionPopover(null); - setSelectionPopoverHasDraft(false); + handleSelectionPopoverCommentStateChange(false); }} - onCommentStateChange={setSelectionPopoverHasDraft} + onCommentStateChange={handleSelectionPopoverCommentStateChange} onClose={() => { setSelectionPopover(null); - setSelectionPopoverHasDraft(false); + handleSelectionPopoverCommentStateChange(false); + }} + /> + ) : null} + {activeAnnotation && editorAnchorRect && onUpdateAnnotation && onRemoveAnnotation ? ( + { + setEditorAnchorRect(null); + setActiveAnnotationId(null); + }} + onDelete={() => { + onRemoveAnnotation(activeAnnotation.id); + setEditorAnchorRect(null); + setActiveAnnotationId(null); + }} + onSave={(comment) => { + onUpdateAnnotation(activeAnnotation.id, comment); + setEditorAnchorRect(null); + setActiveAnnotationId(null); }} /> ) : null} diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index e9800118bfd..ff5f9f504fe 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -38,18 +38,32 @@ describe("shouldRestoreClearedPlanFollowUpDraft", () => { shouldRestoreClearedPlanFollowUpDraft({ currentPrompt: "", currentChatSelectionAnnotationCount: 0, + currentComposerMutationVersion: 0, + submittedComposerMutationVersion: 0, }), ).toBe(true); expect( shouldRestoreClearedPlanFollowUpDraft({ currentPrompt: "new draft", currentChatSelectionAnnotationCount: 0, + currentComposerMutationVersion: 1, + submittedComposerMutationVersion: 0, }), ).toBe(false); expect( shouldRestoreClearedPlanFollowUpDraft({ currentPrompt: "", currentChatSelectionAnnotationCount: 1, + currentComposerMutationVersion: 1, + submittedComposerMutationVersion: 0, + }), + ).toBe(false); + expect( + shouldRestoreClearedPlanFollowUpDraft({ + currentPrompt: "", + currentChatSelectionAnnotationCount: 0, + currentComposerMutationVersion: 1, + submittedComposerMutationVersion: 0, }), ).toBe(false); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 8b42c3cb269..a6ede12fc79 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -31,8 +31,14 @@ export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema. export function shouldRestoreClearedPlanFollowUpDraft(input: { readonly currentPrompt: string; readonly currentChatSelectionAnnotationCount: number; + readonly currentComposerMutationVersion: number; + readonly submittedComposerMutationVersion: number; }): boolean { - return input.currentPrompt.length === 0 && input.currentChatSelectionAnnotationCount === 0; + return ( + input.currentPrompt.length === 0 && + input.currentChatSelectionAnnotationCount === 0 && + input.currentComposerMutationVersion === input.submittedComposerMutationVersion + ); } export function startNewThreadForProject( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5dab6021a42..a08c48c98a4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -314,6 +314,7 @@ import { useAssetUrls } from "../assets/assetUrls"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; +const EMPTY_CHAT_SELECTION_ANNOTATIONS: ReadonlyArray = []; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; @@ -1247,6 +1248,11 @@ function ChatViewContent(props: ChatViewProps) { const composerInteractionMode = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.interactionMode ?? null, ); + const composerChatSelectionAnnotations = useComposerDraftStore( + (store) => + store.getComposerDraft(composerDraftTarget)?.chatSelectionAnnotations ?? + EMPTY_CHAT_SELECTION_ANNOTATIONS, + ); const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); @@ -1265,6 +1271,9 @@ function ChatViewContent(props: ChatViewProps) { const addComposerDraftChatSelectionAnnotation = useComposerDraftStore( (store) => store.addChatSelectionAnnotation, ); + const removeComposerDraftChatSelectionAnnotation = useComposerDraftStore( + (store) => store.removeChatSelectionAnnotation, + ); const setComposerDraftChatSelectionAnnotations = useComposerDraftStore( (store) => store.setChatSelectionAnnotations, ); @@ -1283,6 +1292,7 @@ function ChatViewContent(props: ChatViewProps) { (store) => store.setLogicalProjectDraftThreadId, ); const promptRef = useRef(""); + const composerMutationVersionRef = useRef(0); const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const composerElementContextsRef = useRef([]); @@ -2632,6 +2642,9 @@ function ChatViewContent(props: ChatViewProps) { focusComposer(); }); }, [focusComposer]); + const markComposerMutation = useCallback(() => { + composerMutationVersionRef.current += 1; + }, []); const addTerminalContextToDraft = useCallback( (selection: TerminalContextSelection) => { composerRef.current?.addTerminalContext(selection); @@ -2640,13 +2653,45 @@ function ChatViewContent(props: ChatViewProps) { ); const addChatSelectionAnnotation = useCallback( (input: Omit) => { + markComposerMutation(); addComposerDraftChatSelectionAnnotation(composerDraftTarget, { id: randomUUID(), ...input, }); scheduleComposerFocus(); }, - [addComposerDraftChatSelectionAnnotation, composerDraftTarget, scheduleComposerFocus], + [ + addComposerDraftChatSelectionAnnotation, + composerDraftTarget, + markComposerMutation, + scheduleComposerFocus, + ], + ); + const updateChatSelectionAnnotation = useCallback( + (annotationId: string, comment: string) => { + const annotation = composerChatSelectionAnnotations.find( + (candidate) => candidate.id === annotationId, + ); + if (!annotation) return; + markComposerMutation(); + addComposerDraftChatSelectionAnnotation(composerDraftTarget, { + ...annotation, + comment, + }); + }, + [ + addComposerDraftChatSelectionAnnotation, + composerChatSelectionAnnotations, + composerDraftTarget, + markComposerMutation, + ], + ); + const removeChatSelectionAnnotation = useCallback( + (annotationId: string) => { + markComposerMutation(); + removeComposerDraftChatSelectionAnnotation(composerDraftTarget, annotationId); + }, + [composerDraftTarget, markComposerMutation, removeComposerDraftChatSelectionAnnotation], ); const setTerminalOpen = useCallback( (open: boolean) => { @@ -5200,6 +5245,7 @@ function ChatViewContent(props: ChatViewProps) { if (!trimmed) { return; } + const submittedComposerMutationVersion = composerMutationVersionRef.current; const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx?.providerAvailable) { @@ -5331,6 +5377,8 @@ function ChatViewContent(props: ChatViewProps) { shouldRestoreClearedPlanFollowUpDraft({ currentPrompt: promptRef.current, currentChatSelectionAnnotationCount: currentDraft?.chatSelectionAnnotations.length ?? 0, + currentComposerMutationVersion: composerMutationVersionRef.current, + submittedComposerMutationVersion, }) ) { promptRef.current = draftTextToRestore; @@ -5912,6 +5960,9 @@ function ChatViewContent(props: ChatViewProps) { hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} onAddChatSelectionAnnotation={addChatSelectionAnnotation} + onUpdateChatSelectionAnnotation={updateChatSelectionAnnotation} + onRemoveChatSelectionAnnotation={removeChatSelectionAnnotation} + chatSelectionAnnotations={composerChatSelectionAnnotations} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} @@ -6040,6 +6091,7 @@ function ChatViewContent(props: ChatViewProps) { terminalOpen={Boolean(terminalUiState.terminalOpen)} gitCwd={gitCwd} promptRef={promptRef} + onPromptMutation={markComposerMutation} composerImagesRef={composerImagesRef} composerTerminalContextsRef={composerTerminalContextsRef} composerElementContextsRef={composerElementContextsRef} diff --git a/apps/web/src/components/chat/AssistantMessageIndicators.tsx b/apps/web/src/components/chat/AssistantMessageIndicators.tsx new file mode 100644 index 00000000000..4fbc0845cf5 --- /dev/null +++ b/apps/web/src/components/chat/AssistantMessageIndicators.tsx @@ -0,0 +1,64 @@ +import type { ChatSelectionIndicator, ChatSelectionIndicatorKind } from "~/chatSelectionAnnotation"; +import { cn } from "~/lib/utils"; + +interface IndicatorPresentation { + readonly label: string; + readonly className: string; +} + +const INDICATOR_PRESENTATION: Record = { + "text-selection": { + label: "selected text", + className: "border-blue-400/45 bg-blue-500/90", + }, + "text-comment": { + label: "text comment", + className: "border-blue-400/45 bg-blue-500/90", + }, +}; + +export function AssistantMessageIndicators({ + placements, + activeIndicatorId, + onSelect, +}: { + placements: ReadonlyArray<{ + indicator: ChatSelectionIndicator; + top: number; + }>; + activeIndicatorId?: string | null; + onSelect: (indicator: ChatSelectionIndicator, anchorRect: DOMRect) => void; +}) { + if (placements.length === 0) return null; + + return ( +
+ {placements.map(({ indicator, top }) => { + const presentation = INDICATOR_PRESENTATION[indicator.kind]; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 218e8d894d9..fb10d49197a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -589,6 +589,7 @@ export interface ChatComposerProps { composerRef: React.RefObject; // Callbacks + onPromptMutation?: () => void; onSend: (e?: { preventDefault: () => void }) => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -677,6 +678,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContextsRef, composerElementContextsRef, onSend, + onPromptMutation, onInterrupt, onImplementPlanInNewThread, onRespondToApproval, @@ -1212,6 +1214,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) scheduleComposerFocus(); return; } + onPromptMutation?.(); promptRef.current = nextPrompt; setComposerDraftPrompt(composerDraftTarget, nextPrompt); const nextCursor = collapseExpandedComposerCursor(nextPrompt, nextPrompt.length); @@ -1219,7 +1222,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerTrigger(detectComposerTrigger(nextPrompt, nextPrompt.length)); scheduleComposerFocus(); }, - [composerDraftTarget, promptRef, scheduleComposerFocus, setComposerDraftPrompt], + [ + composerDraftTarget, + onPromptMutation, + promptRef, + scheduleComposerFocus, + setComposerDraftPrompt, + ], ); const providerTraitsMenuContent = renderProviderTraitsMenuContent({ @@ -1308,6 +1317,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); if (contextIndex < 0) return; const removal = removeInlineTerminalContextPlaceholder(promptRef.current, contextIndex); + onPromptMutation?.(); promptRef.current = removal.prompt; setPrompt(removal.prompt); removeComposerDraftTerminalContext(composerDraftTarget, contextId); @@ -1319,6 +1329,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerDraftTarget, composerTerminalContexts, promptRef, + onPromptMutation, removeComposerDraftTerminalContext, setPrompt, ], @@ -1555,6 +1566,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) terminalContextIds: string[], ) => { if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { + if (nextPrompt !== promptRef.current) onPromptMutation?.(); setComposerCursor(nextCursor); setComposerTrigger( cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor), @@ -1568,6 +1580,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); return; } + if (nextPrompt !== promptRef.current) onPromptMutation?.(); promptRef.current = nextPrompt; setPrompt(nextPrompt); if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { @@ -1584,6 +1597,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [ activePendingProgress?.activeQuestion, pendingUserInputs.length, + onPromptMutation, onChangeActivePendingUserInputCustomAnswer, promptRef, setPrompt, @@ -1615,6 +1629,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); const nextCursor = collapseExpandedComposerCursor(next.text, next.cursor); const nextExpandedCursor = expandCollapsedComposerCursor(next.text, nextCursor); + if (next.text !== promptRef.current) onPromptMutation?.(); promptRef.current = next.text; const activePendingQuestion = activePendingProgress?.activeQuestion; if (activePendingQuestion && activePendingUserInput) { @@ -1641,6 +1656,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingProgress?.activeQuestion, activePendingUserInput, onChangeActivePendingUserInputCustomAnswer, + onPromptMutation, promptRef, setPrompt, ], diff --git a/apps/web/src/components/chat/ChatSelectionAnnotationEditor.tsx b/apps/web/src/components/chat/ChatSelectionAnnotationEditor.tsx new file mode 100644 index 00000000000..5677ad6d812 --- /dev/null +++ b/apps/web/src/components/chat/ChatSelectionAnnotationEditor.tsx @@ -0,0 +1,123 @@ +import { Trash2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import type { ChatSelectionAnnotation } from "~/chatSelectionAnnotation"; +import { Button } from "../ui/button"; +import { Textarea } from "../ui/textarea"; + +interface ChatSelectionAnnotationEditorProps { + annotation: ChatSelectionAnnotation; + anchorRect: { top: number; left: number; width: number; height: number }; + onCancel: () => void; + onDelete: () => void; + onSave: (comment: string) => void; +} + +function editorPosition(anchorRect: ChatSelectionAnnotationEditorProps["anchorRect"]) { + const width = Math.min(320, window.innerWidth - 32); + const rightSideLeft = anchorRect.left + anchorRect.width + 12; + const left = + rightSideLeft + width <= window.innerWidth - 16 + ? rightSideLeft + : Math.max(16, anchorRect.left - width - 12); + + return { + left, + top: Math.max(16, Math.min(window.innerHeight - 176, anchorRect.top - 24)), + width, + }; +} + +export function ChatSelectionAnnotationEditor({ + annotation, + anchorRect, + onCancel, + onDelete, + onSave, +}: ChatSelectionAnnotationEditorProps) { + const [comment, setComment] = useState(annotation.comment); + const textareaRef = useRef(null); + const ignoreViewportChangeRef = useRef(true); + + useEffect(() => { + ignoreViewportChangeRef.current = true; + setComment(annotation.comment); + let releaseIgnoreFrame: number | null = null; + const focusFrame = window.requestAnimationFrame(() => { + textareaRef.current?.focus(); + releaseIgnoreFrame = window.requestAnimationFrame(() => { + ignoreViewportChangeRef.current = false; + }); + }); + return () => { + window.cancelAnimationFrame(focusFrame); + if (releaseIgnoreFrame !== null) window.cancelAnimationFrame(releaseIgnoreFrame); + }; + }, [annotation.comment, annotation.id]); + + useEffect(() => { + const closeForViewportChange = () => { + if (ignoreViewportChangeRef.current || document.activeElement === textareaRef.current) { + return; + } + onCancel(); + }; + window.addEventListener("resize", closeForViewportChange); + window.addEventListener("scroll", closeForViewportChange, true); + return () => { + window.removeEventListener("resize", closeForViewportChange); + window.removeEventListener("scroll", closeForViewportChange, true); + }; + }, [onCancel]); + + return createPortal( +
event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + onSave(comment.trim()); + }} + > +