diff --git a/apps/web/src/chatSelectionAnnotation.test.ts b/apps/web/src/chatSelectionAnnotation.test.ts new file mode 100644 index 00000000000..7600c31f76c --- /dev/null +++ b/apps/web/src/chatSelectionAnnotation.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + appendChatSelectionAnnotationsToPrompt, + collectChatSelectionAnnotationsByMessageId, + countChatSelectionAnnotationsForMessage, + deriveChatSelectionIndicators, + formatChatSelectionAnnotation, + parseChatSelectionMessageSegments, + 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); + + expect(countChatSelectionAnnotationsForMessage([annotation, second], "selection-message")).toBe( + 0, + ); + expect( + countChatSelectionAnnotationsForMessage( + [{ ...annotation, messageId: "selection-message" }, second], + "selection-message", + ), + ).toBe(1); + }); + + it("does not emit a block for an empty annotation list", () => { + expect(appendChatSelectionAnnotationsToPrompt("Keep this", [])).toBe("Keep this"); + 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, + }, + ]); + }); +}); diff --git a/apps/web/src/chatSelectionAnnotation.ts b/apps/web/src/chatSelectionAnnotation.ts new file mode 100644 index 00000000000..21332491f47 --- /dev/null +++ b/apps/web/src/chatSelectionAnnotation.ts @@ -0,0 +1 @@ +export * from "@t3tools/shared/chatSelectionAnnotation"; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 985e943cb39..22255fd2a3d 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,17 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; +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 { @@ -102,9 +114,342 @@ interface ChatMarkdownProps { className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; + /** Enables the answer-selection actions used by assistant messages. */ + onTextSelection?: + | ((input: { + selectedText: string; + comment: string; + sourceStart?: number; + sourceEnd?: number; + }) => void) + | undefined; + annotations?: ReadonlyArray; + 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; +} + +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(); +} + +function findChatMarkdownTextMatch( + source: string, + target: string, +): { start: number; end: number } | null { + const trimmedTarget = target.trim(); + if (trimmedTarget.length === 0) return null; + + const exactStart = source.indexOf(trimmedTarget); + if (exactStart >= 0) { + return { start: exactStart, end: exactStart + trimmedTarget.length }; + } + + const normalizedSource = normalizeChatMarkdownSearchText(source); + const normalizedTarget = normalizeChatMarkdownSearchText(trimmedTarget); + const normalizedStart = normalizedSource.indexOf(normalizedTarget); + if (normalizedStart < 0) return null; + + let sourceIndex = 0; + let normalizedIndex = 0; + let matchStart = -1; + let matchEnd = -1; + while ( + sourceIndex < source.length && + normalizedIndex < normalizedStart + normalizedTarget.length + ) { + const character = source[sourceIndex]!; + if (/\s/.test(character)) { + if (normalizedIndex > 0 && normalizedSource[normalizedIndex - 1] !== " ") { + if (normalizedIndex === normalizedStart) matchStart = sourceIndex; + normalizedIndex += 1; + if (normalizedIndex === normalizedStart + normalizedTarget.length) { + matchEnd = sourceIndex + 1; + } + } + } else { + if (normalizedIndex === normalizedStart) matchStart = sourceIndex; + normalizedIndex += 1; + if (normalizedIndex === normalizedStart + normalizedTarget.length) { + matchEnd = sourceIndex + 1; + } + } + sourceIndex += 1; + } + + return matchStart >= 0 && matchEnd > matchStart ? { start: matchStart, end: matchEnd } : null; +} + +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 match = offsetMatch ?? findChatMarkdownTextMatch(index.text, target); + 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 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 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; + let top = firstRect.top - rootRect.top + root.scrollTop + firstRect.height / 2; + while (placements.some((placement) => Math.abs(placement.top - top) < 28)) { + top += 28; + } + placements.push({ indicator, top }); + } + return placements; +} const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; @@ -286,9 +631,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; } @@ -1133,7 +1480,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( @@ -1162,7 +1513,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" }, @@ -1260,7 +1616,34 @@ function ChatMarkdown({ skills = EMPTY_MARKDOWN_SKILLS, className, lineBreaks = false, + onTextSelection, + annotations = EMPTY_CHAT_SELECTION_ANNOTATIONS, + editableAnnotationIds, + onUpdateAnnotation, + onRemoveAnnotation, }: ChatMarkdownProps) { + const markdownRootRef = useRef(null); + const selectionPointerControllerRef = useRef(null); + const [selectionPopover, setSelectionPopover] = useState< + | (Pick & { + 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), [annotations]); + const activeAnnotation = + annotations.find((annotation) => annotation.id === activeAnnotationId) ?? null; const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, @@ -1443,7 +1826,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, + }); }} /> ); @@ -1594,13 +1980,191 @@ 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) { + setSelectionPopover(null); + return; + } + const range = selection.getRangeAt(0); + if (!root.contains(range.commonAncestorContainer)) { + setSelectionPopover(null); + return; + } + const selectedText = selection.toString().trim(); + const rect = range.getBoundingClientRect(); + if (selectedText.length === 0 || (rect.width === 0 && rect.height === 0)) { + setSelectionPopover(null); + return; + } + const selectionRect = { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; + const sourceOffsets = readChatMarkdownSelectionSourceOffsets(root, range, selectedText); + setSelectionPopover({ + text: selectedText, + rect: selectionRect, + avoidRects: readChatMarkdownTextRects(root, selectionRect), + ...(sourceOffsets + ? { + sourceStart: sourceOffsets.sourceStart, + sourceEnd: sourceOffsets.sourceEnd, + } + : {}), + }); + }, [onTextSelection]); + const selectionActionsEnabled = onTextSelection !== undefined; + const scheduleReadTextSelection = useCallback(() => { + window.requestAnimationFrame(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(), []); + + useEffect(() => { + if (!selectionPopover) return; + const closeOnOutsidePointerDown = (event: PointerEvent) => { + const target = event.target; + if (target instanceof Node && markdownRootRef.current?.contains(target)) return; + if (target instanceof Element && target.closest("[data-chat-selection-popover]")) return; + setSelectionPopover(null); + }; + document.addEventListener("pointerdown", closeOnOutsidePointerDown, true); + return () => document.removeEventListener("pointerdown", closeOnOutsidePointerDown, true); + }, [selectionPopover]); + + useEffect(() => { + setSelectionPopover(null); + }, [text]); + + useEffect(() => { + if (activeAnnotationId && !activeAnnotation) { + setActiveAnnotationId(null); + setEditorAnchorRect(null); + } + }, [activeAnnotation, activeAnnotationId]); + + useEffect(() => { + if (!activeAnnotation?.selectedText.trim()) return; + const root = markdownRootRef.current; + if (!root) return; + const range = resolveChatMarkdownTextRange( + root, + activeAnnotation.selectedText, + activeAnnotation.sourceStart, + activeAnnotation.sourceEnd, + ); + range?.startContainer.parentElement?.scrollIntoView({ + block: "nearest", + inline: "nearest", + }); + }, [activeAnnotation, text]); + + 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={selectionActionsEnabled ? handleSelectionPointerDown : undefined} + onKeyUp={selectionActionsEnabled ? scheduleReadTextSelection : undefined} > {text} + {highlightRects.length > 0 ? ( + + ) : 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, + ...(selectionPopover.sourceStart !== undefined + ? { sourceStart: selectionPopover.sourceStart } + : {}), + ...(selectionPopover.sourceEnd !== undefined + ? { sourceEnd: selectionPopover.sourceEnd } + : {}), + }); + setSelectionPopover(null); + }} + onClose={() => setSelectionPopover(null)} + /> + ) : null} + {activeAnnotation && editorAnchorRect && onUpdateAnnotation && onRemoveAnnotation ? ( + setEditorAnchorRect(null)} + onDelete={() => { + onRemoveAnnotation(activeAnnotation.id); + setEditorAnchorRect(null); + setActiveAnnotationId(null); + }} + onSave={(comment) => { + onUpdateAnnotation(activeAnnotation.id, comment); + setEditorAnchorRect(null); + }} + /> + ) : 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..278dbe3d33c 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, @@ -309,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"] = []; @@ -1242,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, ); @@ -1257,6 +1268,15 @@ function ChatViewContent(props: ChatViewProps) { (store) => store.setPreviewAnnotations, ); const setComposerDraftReviewComments = useComposerDraftStore((store) => store.setReviewComments); + const addComposerDraftChatSelectionAnnotation = useComposerDraftStore( + (store) => store.addChatSelectionAnnotation, + ); + const removeComposerDraftChatSelectionAnnotation = useComposerDraftStore( + (store) => store.removeChatSelectionAnnotation, + ); + const setComposerDraftChatSelectionAnnotations = useComposerDraftStore( + (store) => store.setChatSelectionAnnotations, + ); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( @@ -2627,6 +2647,39 @@ function ChatViewContent(props: ChatViewProps) { }, [composerRef], ); + const addChatSelectionAnnotation = useCallback( + (input: Omit) => { + addComposerDraftChatSelectionAnnotation(composerDraftTarget, { + id: randomUUID(), + ...input, + }); + scheduleComposerFocus(); + }, + [addComposerDraftChatSelectionAnnotation, composerDraftTarget, scheduleComposerFocus], + ); + const updateChatSelectionAnnotation = useCallback( + (annotationId: string, comment: string) => { + const annotation = composerChatSelectionAnnotations.find( + (candidate) => candidate.id === annotationId, + ); + if (!annotation) return; + addComposerDraftChatSelectionAnnotation(composerDraftTarget, { + ...annotation, + comment, + }); + }, + [ + addComposerDraftChatSelectionAnnotation, + composerChatSelectionAnnotations, + composerDraftTarget, + ], + ); + const removeChatSelectionAnnotation = useCallback( + (annotationId: string) => { + removeComposerDraftChatSelectionAnnotation(composerDraftTarget, annotationId); + }, + [composerDraftTarget, removeComposerDraftChatSelectionAnnotation], + ); const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadRef) return; @@ -4075,7 +4128,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 +4633,7 @@ function ChatViewContent(props: ChatViewProps) { elementContexts: composerElementContexts, previewAnnotations: composerPreviewAnnotations, reviewComments: composerReviewComments, + chatSelectionAnnotations: composerChatSelectionAnnotations, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, @@ -4598,19 +4653,21 @@ 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, }); - promptRef.current = ""; - clearComposerDraftContent(composerDraftTarget); - composerRef.current?.resetCursorState(); + const composerChatSelectionAnnotationsSnapshot: ChatSelectionAnnotation[] = [ + ...composerChatSelectionAnnotations, + ]; await onSubmitPlanFollowUp({ text: followUp.text, interactionMode: followUp.interactionMode, + chatSelectionAnnotations: composerChatSelectionAnnotationsSnapshot, }); return; } @@ -4619,7 +4676,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 +4752,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 +4860,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 +4977,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 +5000,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 +5208,11 @@ function ChatViewContent(props: ChatViewProps) { async ({ text, interactionMode: nextInteractionMode, + chatSelectionAnnotations = [], }: { text: string; interactionMode: "default" | "plan"; + chatSelectionAnnotations?: ReadonlyArray; }) => { if ( !activeThread || @@ -5145,7 +5224,7 @@ function ChatViewContent(props: ChatViewProps) { return; } - const trimmed = text.trim(); + const trimmed = appendChatSelectionAnnotationsToPrompt(text, chatSelectionAnnotations).trim(); if (!trimmed) { return; } @@ -5172,10 +5251,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 +5354,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 = text; + setComposerDraftPrompt(composerDraftTarget, text); + setComposerDraftChatSelectionAnnotations( + composerDraftTarget, + retryChatSelectionAnnotations, + ); + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(text, text.length), + prompt: text, + detectTrigger: true, + }); + } if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); setThreadError( @@ -5283,6 +5387,9 @@ function ChatViewContent(props: ChatViewProps) { activeThread, activeProposedPlan, beginLocalDispatch, + clearComposerDraftContent, + composerDraftTarget, + composerRef, isConnecting, isSendBusy, isServerThread, @@ -5290,12 +5397,13 @@ function ChatViewContent(props: ChatViewProps) { persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, + setComposerDraftChatSelectionAnnotations, setComposerDraftInteractionMode, + setComposerDraftPrompt, setThreadError, startThreadTurn, autoOpenPlanSidebar, environmentId, - composerRef, ], ); @@ -5831,6 +5939,10 @@ function ChatViewContent(props: ChatViewProps) { onManualNavigation={cancelTimelineLiveFollowForUserNavigation} 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 */} diff --git a/apps/web/src/components/chat/AssistantMessageIndicators.tsx b/apps/web/src/components/chat/AssistantMessageIndicators.tsx new file mode 100644 index 00000000000..0c8f65a3861 --- /dev/null +++ b/apps/web/src/components/chat/AssistantMessageIndicators.tsx @@ -0,0 +1,62 @@ +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 e92ecd497e3..c7a9c0d4917 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,7 @@ 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 nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); @@ -728,6 +732,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 +1031,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, ], @@ -2622,6 +2631,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) elementContexts: composerElementContextsRef.current, previewAnnotations: composerPreviewAnnotations, reviewComments: composerReviewComments, + chatSelectionAnnotations: composerChatSelectionAnnotations, selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, @@ -2643,6 +2653,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerElementContextsRef, composerPreviewAnnotations, composerReviewComments, + composerChatSelectionAnnotations, isConnecting, isComposerApprovalState, pendingUserInputs.length, @@ -2822,6 +2833,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {showCollapsedMobilePromptRow ? (
+ {composerChatSelectionAnnotations.length > 0 ? ( + + removeComposerDraftChatSelectionAnnotation(composerDraftTarget, annotationId) + } + compact + className="max-w-[38%] shrink-0" + /> + ) : null}