diff --git a/apps/web/src/chatSelectionAnnotation.test.ts b/apps/web/src/chatSelectionAnnotation.test.ts new file mode 100644 index 00000000000..49e03e7fd0e --- /dev/null +++ b/apps/web/src/chatSelectionAnnotation.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + appendChatSelectionAnnotationsToPrompt, + collectChatSelectionAnnotationsByMessageId, + countChatSelectionAnnotationsForMessage, + deriveChatSelectionIndicators, + 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); + + 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", () => { + 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 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, + ]); + + 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("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 "; + + 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..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..29939aba1e1 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,451 @@ 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; + 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; @@ -286,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; } @@ -1133,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( @@ -1162,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" }, @@ -1260,7 +1725,50 @@ function ChatMarkdown({ skills = EMPTY_MARKDOWN_SKILLS, 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< + | (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, @@ -1443,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, + }); }} /> ); @@ -1594,13 +2105,245 @@ 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 (!selectionPopoverHasDraftRef.current) setSelectionPopover(null); + return; + } + const range = selection.getRangeAt(0); + if (!root.contains(range.commonAncestorContainer)) { + 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 (!selectionPopoverHasDraftRef.current) setSelectionPopover(null); + 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: selectionRect, + avoidRects: readChatMarkdownTextRects(root, selectionRect), + highlightRects: selectionHighlightRects, + ...(sourceOffsets + ? { + sourceStart: sourceOffsets.sourceStart, + sourceEnd: sourceOffsets.sourceEnd, + } + : {}), + }); + }, [onTextSelection]); + const selectionActionsEnabled = onTextSelection !== undefined; + 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 Element && target.closest("[data-chat-selection-indicator]")) { + setSelectionPopover(null); + return; + } + if (target instanceof Node && markdownRootRef.current?.contains(target)) { + if (selectionPopoverHasDraftRef.current) return; + setSelectionPopover(null); + return; + } + setSelectionPopover(null); + }; + document.addEventListener("pointerdown", closeOnOutsidePointerDown, true); + return () => document.removeEventListener("pointerdown", closeOnOutsidePointerDown, true); + }, [selectionPopover]); + + useEffect(() => { + if (!selectionPopover) return; + const closeOnViewportChange = () => { + if (!selectionPopoverHasDraftRef.current) setSelectionPopover(null); + }; + window.addEventListener("scroll", closeOnViewportChange, true); + window.addEventListener("resize", closeOnViewportChange); + return () => { + window.removeEventListener("scroll", closeOnViewportChange, true); + window.removeEventListener("resize", closeOnViewportChange); + }; + }, [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={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, + ...(selectionPopover.sourceStart !== undefined + ? { sourceStart: selectionPopover.sourceStart } + : {}), + ...(selectionPopover.sourceEnd !== undefined + ? { sourceEnd: selectionPopover.sourceEnd } + : {}), + }); + setSelectionPopover(null); + handleSelectionPopoverCommentStateChange(false); + }} + onCommentStateChange={handleSelectionPopoverCommentStateChange} + onClose={() => { + setSelectionPopover(null); + 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 39285438d1a..ff5f9f504fe 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -28,9 +28,47 @@ 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, + 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); + }); +}); + 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..a6ede12fc79 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -28,6 +28,19 @@ 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; + readonly currentComposerMutationVersion: number; + readonly submittedComposerMutationVersion: number; +}): boolean { + return ( + input.currentPrompt.length === 0 && + input.currentChatSelectionAnnotationCount === 0 && + input.currentComposerMutationVersion === input.submittedComposerMutationVersion + ); +} + 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..a08c48c98a4 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( @@ -1272,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([]); @@ -2621,12 +2642,57 @@ function ChatViewContent(props: ChatViewProps) { focusComposer(); }); }, [focusComposer]); + const markComposerMutation = useCallback(() => { + composerMutationVersionRef.current += 1; + }, []); const addTerminalContextToDraft = useCallback( (selection: TerminalContextSelection) => { composerRef.current?.addTerminalContext(selection); }, [composerRef], ); + const addChatSelectionAnnotation = useCallback( + (input: Omit) => { + markComposerMutation(); + addComposerDraftChatSelectionAnnotation(composerDraftTarget, { + id: randomUUID(), + ...input, + }); + 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) => { if (!activeThreadRef) return; @@ -4075,7 +4141,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 +4646,7 @@ function ChatViewContent(props: ChatViewProps) { elementContexts: composerElementContexts, previewAnnotations: composerPreviewAnnotations, reviewComments: composerReviewComments, + chatSelectionAnnotations: composerChatSelectionAnnotations, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, @@ -4598,19 +4666,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 +4691,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 +4767,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 +4875,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 +4992,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 +5015,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 +5223,13 @@ function ChatViewContent(props: ChatViewProps) { async ({ text, interactionMode: nextInteractionMode, + draftText: draftTextToRestore, + chatSelectionAnnotations = [], }: { text: string; interactionMode: "default" | "plan"; + draftText: string; + chatSelectionAnnotations?: ReadonlyArray; }) => { if ( !activeThread || @@ -5145,10 +5241,11 @@ function ChatViewContent(props: ChatViewProps) { return; } - const trimmed = text.trim(); + const trimmed = appendChatSelectionAnnotationsToPrompt(text, chatSelectionAnnotations).trim(); if (!trimmed) { return; } + const submittedComposerMutationVersion = composerMutationVersionRef.current; const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx?.providerAvailable) { @@ -5172,10 +5269,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 +5372,27 @@ 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, + currentComposerMutationVersion: composerMutationVersionRef.current, + submittedComposerMutationVersion, + }) + ) { + 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 +5407,9 @@ function ChatViewContent(props: ChatViewProps) { activeThread, activeProposedPlan, beginLocalDispatch, + clearComposerDraftContent, + composerDraftTarget, + composerRef, isConnecting, isSendBusy, isServerThread, @@ -5290,12 +5417,13 @@ function ChatViewContent(props: ChatViewProps) { persistThreadSettingsForNextTurn, resetLocalDispatch, runtimeMode, + setComposerDraftChatSelectionAnnotations, setComposerDraftInteractionMode, + setComposerDraftPrompt, setThreadError, startThreadTurn, autoOpenPlanSidebar, environmentId, - composerRef, ], ); @@ -5831,6 +5959,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 */} @@ -5959,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 e92ecd497e3..fb10d49197a 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; @@ -586,6 +589,7 @@ export interface ChatComposerProps { composerRef: React.RefObject; // Callbacks + onPromptMutation?: () => void; onSend: (e?: { preventDefault: () => void }) => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -674,6 +678,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContextsRef, composerElementContextsRef, onSend, + onPromptMutation, onInterrupt, onImplementPlanInNewThread, onRespondToApproval, @@ -704,6 +709,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 +736,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 +1035,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 +1179,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 +1189,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isConnecting, isPreparingWorktree, isSendBusy, + hasComposerPromptText, phase, - prompt, showPlanFollowUpPrompt, ]); @@ -1201,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); @@ -1208,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({ @@ -1297,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); @@ -1308,6 +1329,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerDraftTarget, composerTerminalContexts, promptRef, + onPromptMutation, removeComposerDraftTerminalContext, setPrompt, ], @@ -1544,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), @@ -1557,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)) { @@ -1573,6 +1597,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [ activePendingProgress?.activeQuestion, pendingUserInputs.length, + onPromptMutation, onChangeActivePendingUserInputCustomAnswer, promptRef, setPrompt, @@ -1604,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) { @@ -1630,6 +1656,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activePendingProgress?.activeQuestion, activePendingUserInput, onChangeActivePendingUserInputCustomAnswer, + onPromptMutation, promptRef, setPrompt, ], @@ -2622,6 +2649,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) elementContexts: composerElementContextsRef.current, previewAnnotations: composerPreviewAnnotations, reviewComments: composerReviewComments, + chatSelectionAnnotations: composerChatSelectionAnnotations, selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, @@ -2643,6 +2671,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerElementContextsRef, composerPreviewAnnotations, composerReviewComments, + composerChatSelectionAnnotations, isConnecting, isComposerApprovalState, pendingUserInputs.length, @@ -2822,6 +2851,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {showCollapsedMobilePromptRow ? (
+ {composerChatSelectionAnnotations.length > 0 ? ( + + removeComposerDraftChatSelectionAnnotation(composerDraftTarget, annotationId) + } + compact + className="max-w-[38%] shrink-0" + /> + ) : null}