diff --git a/apps/web/src/chatSelectionAnnotation.test.ts b/apps/web/src/chatSelectionAnnotation.test.ts index 5c21bbb75b9..3413e6f2eaf 100644 --- a/apps/web/src/chatSelectionAnnotation.test.ts +++ b/apps/web/src/chatSelectionAnnotation.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from "vite-plus/test"; import { appendChatSelectionAnnotationsToPrompt, + collectChatSelectionAnnotationsByMessageId, + countChatSelectionAnnotationsForMessage, + deriveChatSelectionIndicators, formatChatSelectionAnnotation, parseChatSelectionMessageSegments, stripAppendedChatSelectionAnnotations, @@ -39,6 +42,16 @@ describe("chat selection annotations", () => { ); expect(segments.filter((segment) => segment.kind === "selection")).toHaveLength(2); + + expect(countChatSelectionAnnotationsForMessage([annotation, second], "selection-message")).toBe( + 0, + ); + expect( + countChatSelectionAnnotationsForMessage( + [{ ...annotation, messageId: "selection-message" }, second], + "selection-message", + ), + ).toBe(1); }); it("keeps user-authored chat selection markup as message text", () => { @@ -78,6 +91,55 @@ describe("chat selection annotations", () => { expect(formatChatSelectionAnnotation(annotation)).toContain(""); }); + it("persists the source message id in the sent prompt", () => { + const sourceAnnotation = { + ...annotation, + messageId: "assistant-1", + sourceStart: 42, + sourceEnd: 63, + }; + const prompt = appendChatSelectionAnnotationsToPrompt("Explain this", [sourceAnnotation]); + const segments = parseChatSelectionMessageSegments(prompt); + + expect(segments[1]).toEqual({ kind: "selection", annotation: sourceAnnotation }); + expect(prompt).toContain('message_id="assistant-1"'); + expect(prompt).toContain('source_start="42" source_end="63"'); + }); + + it("groups pending annotations by source message", () => { + const pending = { ...annotation, messageId: "assistant-1" }; + const byMessageId = collectChatSelectionAnnotationsByMessageId([pending]); + + expect(byMessageId.get("assistant-1")).toEqual([pending]); + expect(deriveChatSelectionIndicators([pending])).toEqual([ + { + id: pending.id, + kind: "text-comment", + number: 1, + annotation: pending, + }, + ]); + }); + + it("creates one numbered indicator for every annotation in source order", () => { + const second = { ...annotation, id: "selection-2", comment: "" }; + + expect(deriveChatSelectionIndicators([annotation, second])).toEqual([ + { + id: annotation.id, + kind: "text-comment", + number: 1, + annotation, + }, + { + id: second.id, + kind: "text-selection", + number: 2, + annotation: second, + }, + ]); + }); + it("preserves prompt whitespace when appending annotations", () => { const prompt = " Keep this "; diff --git a/apps/web/src/chatSelectionAnnotation.ts b/apps/web/src/chatSelectionAnnotation.ts index bf154f9ff94..21332491f47 100644 --- a/apps/web/src/chatSelectionAnnotation.ts +++ b/apps/web/src/chatSelectionAnnotation.ts @@ -1,128 +1 @@ -import * as Schema from "effect/Schema"; - -export const ChatSelectionAnnotationSchema = Schema.Struct({ - id: Schema.String, - selectedText: Schema.String, - comment: Schema.String, -}); - -export type ChatSelectionAnnotation = typeof ChatSelectionAnnotationSchema.Type; - -export type ChatSelectionMessageSegment = - | { readonly kind: "text"; readonly id: string; readonly text: string } - | { readonly kind: "selection"; readonly annotation: ChatSelectionAnnotation }; - -const CHAT_SELECTION_BLOCK_PATTERN = - /\n]*)>\s*\s*([\s\S]*?)\s*<\/selected_text>\s*\s*([\s\S]*?)\s*<\/user_comment>\s*<\/chat_selection>/g; -const CHAT_SELECTION_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g; -const CHAT_SELECTION_APP_MARKER_NAME = "data-t3code-appended"; -const CHAT_SELECTION_APP_MARKER_VALUE = "true"; -const CHAT_SELECTION_APP_MARKER = `${CHAT_SELECTION_APP_MARKER_NAME}="${CHAT_SELECTION_APP_MARKER_VALUE}"`; - -function escapeXml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -function unescapeXml(value: string): string { - return value - .replace(/"/g, '"') - .replace(/>/g, ">") - .replace(/</g, "<") - .replace(/&/g, "&"); -} - -function readId(rawAttributes: string, fallback: string): string { - for (const match of rawAttributes.matchAll(CHAT_SELECTION_ATTRIBUTE_PATTERN)) { - if (match[1] === "id" && match[2]) return unescapeXml(match[2]); - } - return fallback; -} - -function isAppendedChatSelection(rawAttributes: string): boolean { - for (const match of rawAttributes.matchAll(CHAT_SELECTION_ATTRIBUTE_PATTERN)) { - if ( - match[1] === CHAT_SELECTION_APP_MARKER_NAME && - match[2] === CHAT_SELECTION_APP_MARKER_VALUE - ) { - return true; - } - } - return false; -} - -export function formatChatSelectionAnnotation(annotation: ChatSelectionAnnotation): string { - return [ - ``, - "", - escapeXml(annotation.selectedText.trim()), - "", - "", - escapeXml(annotation.comment.trim()), - "", - "", - ].join("\n"); -} - -export function appendChatSelectionAnnotationsToPrompt( - prompt: string, - annotations: ReadonlyArray, -): string { - if (annotations.length === 0) return prompt; - const blocks = annotations.map(formatChatSelectionAnnotation).join("\n\n"); - const trimmedPrompt = prompt.trim(); - return trimmedPrompt.length > 0 ? `${prompt}\n\n${blocks}` : blocks; -} - -export function parseChatSelectionMessageSegments( - value: string, -): ReadonlyArray { - const segments: ChatSelectionMessageSegment[] = []; - let cursor = 0; - let parsedIndex = 0; - - for (const match of value.matchAll(CHAT_SELECTION_BLOCK_PATTERN)) { - const matchIndex = match.index ?? 0; - const beforeText = value.slice(cursor, matchIndex); - if (beforeText.length > 0) { - segments.push({ kind: "text", id: `chat-selection-text:${cursor}`, text: beforeText }); - } - - if (!isAppendedChatSelection(match[1] ?? "")) { - segments.push({ kind: "text", id: `chat-selection-text:${matchIndex}`, text: match[0] }); - cursor = matchIndex + match[0].length; - continue; - } - - const selectedText = unescapeXml(match[2] ?? "").trim(); - if (selectedText.length === 0) { - segments.push({ kind: "text", id: `chat-selection-invalid:${matchIndex}`, text: match[0] }); - } else { - segments.push({ - kind: "selection", - annotation: { - id: readId(match[1] ?? "", `chat-selection:${parsedIndex}`), - selectedText, - comment: unescapeXml(match[3] ?? "").trim(), - }, - }); - parsedIndex += 1; - } - cursor = matchIndex + match[0].length; - } - - const rest = value.slice(cursor); - if (rest.length > 0) { - segments.push({ kind: "text", id: `chat-selection-text:${cursor}`, text: rest }); - } - return segments; -} - -export function stripAppendedChatSelectionAnnotations(value: string): string { - return parseChatSelectionMessageSegments(value) - .flatMap((segment) => (segment.kind === "text" ? [segment.text] : [])) - .join(""); -} +export * from "@t3tools/shared/chatSelectionAnnotation"; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e708315055b..8349712f8e6 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -40,7 +40,17 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; -import { ChatTextSelectionPopover } from "./chat/ChatTextSelectionPopover"; +import { + ChatTextSelectionPopover, + type ChatTextSelectionPopoverProps, +} from "./chat/ChatTextSelectionPopover"; +import { ChatSelectionAnnotationEditor } from "./chat/ChatSelectionAnnotationEditor"; +import { AssistantMessageIndicators } from "./chat/AssistantMessageIndicators"; +import { + deriveChatSelectionIndicators, + type ChatSelectionAnnotation, + type ChatSelectionIndicator, +} from "../chatSelectionAnnotation"; import { CHAT_FILE_TAG_CHIP_CLASS_NAME, FileTagChipContent } from "./chat/FileTagChip"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { @@ -104,11 +114,342 @@ interface ChatMarkdownProps { className?: string; /** Treat single newlines as hard breaks — chat-style user input. */ lineBreaks?: boolean; - /** Enables attaching selected response text to the next chat message. */ - onTextSelection?: ((input: { selectedText: string; comment: string }) => void) | undefined; + /** Enables the answer-selection actions used by assistant messages. */ + onTextSelection?: + | ((input: { + selectedText: string; + comment: string; + sourceStart?: number; + sourceEnd?: number; + }) => void) + | undefined; + annotations?: ReadonlyArray; + 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; @@ -290,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; } @@ -1137,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( @@ -1166,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" }, @@ -1265,14 +1617,34 @@ function ChatMarkdown({ className, lineBreaks = false, onTextSelection, + annotations = EMPTY_CHAT_SELECTION_ANNOTATIONS, + editableAnnotationIds, + onUpdateAnnotation, + onRemoveAnnotation, }: ChatMarkdownProps) { const markdownRootRef = useRef(null); const selectionPointerControllerRef = useRef(null); const selectionReadFrameRef = useRef(null); - const [selectionPopover, setSelectionPopover] = useState<{ - text: string; - rect: { top: number; left: number; width: number; height: number }; - } | null>(null); + const [selectionPopover, setSelectionPopover] = useState< + | (Pick & { + 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 [selectionPopoverHasDraft, setSelectionPopoverHasDraft] = useState(false); const { resolvedTheme } = useTheme(); const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { @@ -1456,7 +1828,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, + }); }} /> ); @@ -1627,11 +2002,26 @@ function ChatMarkdown({ return; } if (selectionPopoverHasDraft) 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: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }, + rect: selectionRect, + avoidRects: readChatMarkdownTextRects(root, selectionRect), + ...(sourceOffsets + ? { + sourceStart: sourceOffsets.sourceStart, + sourceEnd: sourceOffsets.sourceEnd, + } + : {}), }); }, [onTextSelection, selectionPopoverHasDraft]); + const selectionActionsEnabled = onTextSelection !== undefined; const scheduleReadTextSelection = useCallback(() => { if (selectionReadFrameRef.current !== null) { window.cancelAnimationFrame(selectionReadFrameRef.current); @@ -1713,15 +2103,103 @@ function ChatMarkdown({ }; }, [selectionPopover, selectionPopoverHasDraft]); + 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={onTextSelection ? handleSelectionPointerDown : undefined} + onPointerDown={selectionActionsEnabled ? handleSelectionPointerDown : 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 }); + onTextSelection?.({ + selectedText: selectionPopover.text, + comment, + ...(selectionPopover.sourceStart !== undefined + ? { sourceStart: selectionPopover.sourceStart } + : {}), + ...(selectionPopover.sourceEnd !== undefined + ? { sourceEnd: selectionPopover.sourceEnd } + : {}), + }); setSelectionPopover(null); setSelectionPopoverHasDraft(false); }} @@ -1748,6 +2269,22 @@ function ChatMarkdown({ }} /> ) : 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.tsx b/apps/web/src/components/ChatView.tsx index 5dab6021a42..0ab4cdd31c1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -314,6 +314,7 @@ import { useAssetUrls } from "../assets/assetUrls"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; +const EMPTY_CHAT_SELECTION_ANNOTATIONS: ReadonlyArray = []; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; @@ -1247,6 +1248,11 @@ function ChatViewContent(props: ChatViewProps) { const composerInteractionMode = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.interactionMode ?? null, ); + const composerChatSelectionAnnotations = useComposerDraftStore( + (store) => + store.getComposerDraft(composerDraftTarget)?.chatSelectionAnnotations ?? + EMPTY_CHAT_SELECTION_ANNOTATIONS, + ); const composerActiveProvider = useComposerDraftStore( (store) => store.getComposerDraft(composerDraftTarget)?.activeProvider ?? null, ); @@ -1265,6 +1271,9 @@ function ChatViewContent(props: ChatViewProps) { const addComposerDraftChatSelectionAnnotation = useComposerDraftStore( (store) => store.addChatSelectionAnnotation, ); + const removeComposerDraftChatSelectionAnnotation = useComposerDraftStore( + (store) => store.removeChatSelectionAnnotation, + ); const setComposerDraftChatSelectionAnnotations = useComposerDraftStore( (store) => store.setChatSelectionAnnotations, ); @@ -2648,6 +2657,29 @@ function ChatViewContent(props: ChatViewProps) { }, [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; @@ -5912,6 +5944,9 @@ function ChatViewContent(props: ChatViewProps) { hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} onAddChatSelectionAnnotation={addChatSelectionAnnotation} + onUpdateChatSelectionAnnotation={updateChatSelectionAnnotation} + onRemoveChatSelectionAnnotation={removeChatSelectionAnnotation} + chatSelectionAnnotations={composerChatSelectionAnnotations} /> {/* scroll to end pill — shown when user has scrolled away from the live edge */} 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/ChatSelectionAnnotationEditor.tsx b/apps/web/src/components/chat/ChatSelectionAnnotationEditor.tsx new file mode 100644 index 00000000000..d3b086d4224 --- /dev/null +++ b/apps/web/src/components/chat/ChatSelectionAnnotationEditor.tsx @@ -0,0 +1,96 @@ +import { Trash2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import type { ChatSelectionAnnotation } from "~/chatSelectionAnnotation"; +import { Button } from "../ui/button"; +import { Textarea } from "../ui/textarea"; + +interface ChatSelectionAnnotationEditorProps { + annotation: ChatSelectionAnnotation; + anchorRect: { top: number; left: number; width: number; height: number }; + onCancel: () => void; + onDelete: () => void; + onSave: (comment: string) => void; +} + +function editorPosition(anchorRect: ChatSelectionAnnotationEditorProps["anchorRect"]) { + const width = Math.min(320, window.innerWidth - 32); + const rightSideLeft = anchorRect.left + anchorRect.width + 12; + const left = + rightSideLeft + width <= window.innerWidth - 16 + ? rightSideLeft + : Math.max(16, anchorRect.left - width - 12); + + return { + left, + top: Math.max(16, Math.min(window.innerHeight - 176, anchorRect.top - 24)), + width, + }; +} + +export function ChatSelectionAnnotationEditor({ + annotation, + anchorRect, + onCancel, + onDelete, + onSave, +}: ChatSelectionAnnotationEditorProps) { + const [comment, setComment] = useState(annotation.comment); + const textareaRef = useRef(null); + + useEffect(() => { + setComment(annotation.comment); + window.requestAnimationFrame(() => textareaRef.current?.focus()); + }, [annotation.comment, annotation.id]); + + return createPortal( +
event.stopPropagation()} + onSubmit={(event) => { + event.preventDefault(); + onSave(comment.trim()); + }} + > +