diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 2981248e..1a5a72f4 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -21,7 +21,6 @@ import { buildLinkedIssue } from '@/lib/linkedIssues'; import { useUserMessageHistory } from "@/sync/sync-context"; import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore'; import { useSnippetsStore } from '@/stores/useSnippetsStore'; -import { appendInlineComments } from '@/lib/messages/inlineComments'; import { renderMagicPrompt } from '@/lib/magicPrompts'; import { startReviewFlow } from '@/lib/reviewFlow'; import { getRuntimeKey } from '@/lib/runtime-switch'; @@ -757,21 +756,21 @@ const ChatInputComponent: React.FC = ({ React.useCallback( (state) => { const drafts = inlineDraftKey ? (state.drafts[inlineDraftKey] ?? []) : []; - let previewConsole = 0; let previewAnnotation = 0; let review = 0; let terminal = 0; let prComment = 0; let prCheck = 0; + let chatQuote = 0; for (const draft of drafts) { - if (draft.source === 'preview-console') previewConsole += 1; - else if (draft.source === 'preview-annotation') previewAnnotation += 1; + if (draft.source === 'preview-annotation') previewAnnotation += 1; else if (draft.source === 'terminal') terminal += 1; else if (draft.source === 'pr-comment') prComment += 1; else if (draft.source === 'pr-check') prCheck += 1; + else if (draft.source === 'chat-quote') chatQuote += 1; else review += 1; } - return `${previewConsole}:${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}`; + return `${previewAnnotation}:${review}:${terminal}:${prComment}:${prCheck}:${chatQuote}`; }, [inlineDraftKey] ) @@ -779,11 +778,11 @@ const ChatInputComponent: React.FC = ({ const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts); const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft); const hasDrafts = draftCount > 0; - const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0); + const [previewAnnotationCount, reviewCount, terminalContextCount, prCommentCount, prCheckCount, chatQuoteCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0); const terminalContextDrafts = terminalContextCount > 0 ? (inlineDraftKey ? useInlineCommentDraftStore.getState().drafts[inlineDraftKey] ?? [] : []).filter((draft) => draft.source === 'terminal') : []; - const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => { + const removePreviewDrafts = React.useCallback((source: 'preview-annotation' | 'pr-comment' | 'pr-check' | 'chat-quote') => { if (!inlineDraftTarget) return; const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget); for (const draft of drafts) { @@ -797,7 +796,7 @@ const ChatInputComponent: React.FC = ({ if (!inlineDraftTarget) return; const drafts = useInlineCommentDraftStore.getState().getDrafts(inlineDraftTarget); for (const draft of drafts) { - if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check') { + if (draft.source !== 'preview-annotation' && draft.source !== 'terminal' && draft.source !== 'pr-comment' && draft.source !== 'pr-check' && draft.source !== 'chat-quote') { removeInlineCommentDraft(inlineDraftTarget, draft.id); } } @@ -930,12 +929,9 @@ const ChatInputComponent: React.FC = ({ const inputSnapshot = getCurrentInputSnapshot(); if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return; - const drafts = inlineDraftTarget ? consumeDrafts(inlineDraftTarget) : []; - - let messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, ''); - if (drafts.length > 0) { - messageToQueue = appendInlineComments(messageToQueue, drafts); - } + // Context drafts stay in their store: the send that later delivers the + // queue consumes them and attaches them as structured context parts. + const messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, ''); const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles); addToQueue(messageQueueTarget, { @@ -961,7 +957,7 @@ const ChatInputComponent: React.FC = ({ if (!isMobile) { composerRef.current?.focus(); } - }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, inlineDraftTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]); + }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant]); const handleQueuedMessageEdit = React.useCallback((content: string) => { setMessage(content); @@ -1140,9 +1136,11 @@ const ChatInputComponent: React.FC = ({ } // Inline review comments and synthetic context are consumed before - // assembly so a failed send can restore exactly what it took. + // assembly so a failed send can restore exactly what it took. Context + // drafts ride with whichever send goes out next, including queued + // auto-sends: queueing leaves them in the store on purpose. const syntheticParts = consumePendingSyntheticParts(); - const consumedDraftTarget = queuedOnly ? null : inlineDraftTarget; + const consumedDraftTarget = inlineDraftTarget; const drafts: InlineCommentDraft[] = consumedDraftTarget ? consumeDrafts(consumedDraftTarget) : []; @@ -1157,9 +1155,11 @@ const ChatInputComponent: React.FC = ({ composerAttachments: attachedFiles, inlineComments: drafts, syntheticTexts: syntheticParts?.map((part) => part.text) ?? [], - linkedIssueContext: linkedIssue?.contextText ?? null, + linkedIssue: linkedIssue + ? { number: linkedIssue.number, title: linkedIssue.title, url: linkedIssue.url, contextText: linkedIssue.contextText } + : null, linkedPr: linkedPr - ? { instructions: linkedPr.instructionsText, context: linkedPr.contextText } + ? { number: linkedPr.number, title: linkedPr.title, url: linkedPr.url, instructions: linkedPr.instructionsText, context: linkedPr.contextText } : null, }, { parseAgentMention: (text) => { @@ -1172,8 +1172,6 @@ const ChatInputComponent: React.FC = ({ }, sanitizeAttachments: sanitizeAttachmentsForSend, collectSkillNames: (text) => collectInlineSkillMentions(text, availableSkillNames), - appendComments: (text, comments) => - appendInlineComments(text, comments as InlineCommentDraft[]), buildSkillInstruction: buildSkillMentionInstruction, }); @@ -2667,8 +2665,8 @@ const ChatInputComponent: React.FC = ({ reviewCount={reviewCount} prCommentCount={prCommentCount} prCheckCount={prCheckCount} - previewConsoleCount={previewConsoleCount} previewAnnotationCount={previewAnnotationCount} + chatQuoteCount={chatQuoteCount} draftTarget={inlineDraftTarget} onRemoveDraft={removeInlineCommentDraft} onRemoveReviewDrafts={removeReviewDrafts} diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index f800230b..f3b03529 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -124,10 +124,14 @@ and the send path reading the same grammar. drawn caret through a class it only writes while applying an update, so the selection has to be the update that follows the focus. - `submit/buildOutgoingMessage.ts` flattens queued messages, the composer text, - inline comments and context into OpenCode's one-primary-plus-parts shape. The - oldest queued message becomes primary; **inline comments attach to the last - body the user authored** rather than becoming their own part; PR instructions - precede the PR diff. + context drafts and linked references into OpenCode's one-primary-plus-parts + shape. The oldest queued message becomes primary. **Every attached context + item (inline comments, terminal selections, browser annotations, PR context, + linked issue/PR) becomes its own synthetic text part carrying structured + metadata** built by `lib/messages/contextParts.ts`; the timeline reads that + metadata back to render context blocks. PR instructions precede the PR diff. + Queueing a message leaves context drafts in their store on purpose — the send + that later delivers the queue consumes them. - `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory, session) identity. Writes are debounced while typing but forced at every edge where the page may stop running, because a pending timer is not a saved diff --git a/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts b/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts index 751f568b..2c219085 100644 --- a/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts +++ b/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from 'bun:test'; import type { AttachedFile } from '@/stores/types/sessionTypes'; +import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; +import { CONTEXT_METADATA_KEY, contextPayloadFromDraft } from '@/lib/messages/contextParts'; import { buildOutgoingMessage, type OutgoingMessageDeps, @@ -26,7 +28,6 @@ const deps = (overrides: Partial = {}): OutgoingMessageDeps }, sanitizeAttachments: (files) => [...(files ?? [])], collectSkillNames: (text) => [...text.matchAll(/\/(\w+)/g)].map((m) => m[1]), - appendComments: (text, comments) => `${text}\n[${comments.length} comments]`, buildSkillInstruction: (names) => (names.length ? `use: ${names.join(',')}` : null), ...overrides, }); @@ -37,7 +38,7 @@ const input = (overrides: Partial = {}): OutgoingMessageIn composerAttachments: [], inlineComments: [], syntheticTexts: [], - linkedIssueContext: null, + linkedIssue: null, linkedPr: null, ...overrides, }); @@ -130,36 +131,51 @@ describe('agent mentions', () => { }); }); -describe('inline comments', () => { - test('attach to the composer text when nothing was queued', () => { +const commentDraft = (overrides: Partial = {}): InlineCommentDraft => ({ + id: 'icd-1', + sessionKey: 's1', + source: 'diff', + fileLabel: 'src/app.ts', + startLine: 3, + endLine: 5, + side: 'modified', + code: 'const x = 1;', + language: 'ts', + text: 'fix this', + createdAt: 1, + ...overrides, +}); + +describe('context drafts', () => { + test('each becomes a synthetic part carrying structured metadata', () => { const result = buildOutgoingMessage(input({ composerText: 'body', - inlineComments: [{}, {}], + inlineComments: [commentDraft(), commentDraft({ id: 'icd-2', source: 'file', side: undefined })], }), deps()); - expect(result.primaryText).toBe('body\n[2 comments]'); + expect(result.primaryText).toBe('body'); + expect(result.additionalParts).toHaveLength(2); + expect(result.additionalParts.every((p) => p.synthetic)).toBe(true); + expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY]) + .toEqual(contextPayloadFromDraft(commentDraft())); + expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY]) + .toEqual(contextPayloadFromDraft(commentDraft({ id: 'icd-2', source: 'file', side: undefined }))); + expect(result.additionalParts[0].text).toContain('Comment on `src/app.ts` lines 3-5 (modified):'); + expect(result.additionalParts[0].text).toContain('fix this'); }); - test('attach to the last authored part when messages were queued', () => { + test('context parts precede other synthetic context', () => { const result = buildOutgoingMessage(input({ - queued: [{ content: 'queued' }], - composerText: 'typed', - inlineComments: [{}], + composerText: 'body', + inlineComments: [commentDraft()], + syntheticTexts: ['conflict note'], }), deps()); - expect(result.primaryText).toBe('queued'); - expect(result.additionalParts[0].text).toBe('typed\n[1 comments]'); + expect(result.additionalParts.map((p) => p.text.startsWith('Comment on') ? 'comment' : p.text)) + .toEqual(['comment', 'conflict note']); }); - test('fall back to primary when the queue produced no additional parts', () => { - const result = buildOutgoingMessage(input({ - queued: [{ content: 'only queued' }], - inlineComments: [{}], - }), deps()); - expect(result.primaryText).toBe('only queued\n[1 comments]'); - }); - - test('no comments changes nothing', () => { - expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).primaryText) - .toBe('body'); + test('no drafts changes nothing', () => { + expect(buildOutgoingMessage(input({ composerText: 'body' }), deps()).additionalParts) + .toEqual([]); }); }); @@ -167,26 +183,31 @@ describe('synthetic context', () => { test('a linked PR sends its instructions before its diff', () => { const result = buildOutgoingMessage(input({ composerText: 'review this', - linkedPr: { instructions: 'how to read it', context: 'the diff' }, + linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'how to read it', context: 'the diff' }, }), deps()); expect(result.additionalParts.map((p) => p.text)) .toEqual(['how to read it', 'the diff']); expect(result.additionalParts.every((p) => p.synthetic)).toBe(true); + expect(result.additionalParts[1].metadata?.[CONTEXT_METADATA_KEY]) + .toEqual({ kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' }); }); test('a linked issue is sent as context', () => { const result = buildOutgoingMessage(input({ composerText: 'fix it', - linkedIssueContext: 'issue body', + linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' }, }), deps()); - expect(result.additionalParts).toEqual([{ text: 'issue body', synthetic: true }]); + expect(result.additionalParts).toHaveLength(1); + expect(result.additionalParts[0].text).toBe('issue body'); + expect(result.additionalParts[0].metadata?.[CONTEXT_METADATA_KEY]) + .toEqual({ kind: 'github-issue', number: 3, title: 'Bug', url: 'https://x/issues/3' }); }); test('synthetic texts precede the linked references', () => { const result = buildOutgoingMessage(input({ composerText: 'x', syntheticTexts: ['conflict note'], - linkedIssueContext: 'issue body', + linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' }, }), deps()); expect(result.additionalParts.map((p) => p.text)) .toEqual(['conflict note', 'issue body']); @@ -211,7 +232,9 @@ describe('synthetic context', () => { }); test('context alone is still worth sending', () => { - const result = buildOutgoingMessage(input({ linkedIssueContext: 'issue body' }), deps()); + const result = buildOutgoingMessage(input({ + linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue body' }, + }), deps()); expect(result.isEmpty).toBe(false); }); @@ -230,8 +253,8 @@ describe('full assembly order', () => { queued: [{ content: 'q1' }, { content: 'q2' }], composerText: 'typed /deploy', syntheticTexts: ['synthetic'], - linkedIssueContext: 'issue', - linkedPr: { instructions: 'pr-how', context: 'pr-diff' }, + linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' }, + linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' }, }), deps()); expect(result.primaryText).toBe('q1'); diff --git a/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts b/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts index e92395aa..15c33d6d 100644 --- a/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts +++ b/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts @@ -14,12 +14,16 @@ */ import type { AttachedFile } from '@/stores/types/sessionTypes'; +import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; +import { contextPayloadFromDraft, createContextPart, type ContextPartMetadata } from '@/lib/messages/contextParts'; export interface OutgoingPart { text: string; attachments?: AttachedFile[]; /** Synthetic parts are context for the model, not shown as user content. */ synthetic?: boolean; + /** Structured context (see contextParts.ts), persisted with the part. */ + metadata?: ContextPartMetadata; } export interface OutgoingMessage { @@ -43,12 +47,12 @@ export interface OutgoingMessageInput { /** The composer's own text, or null when this send skips it. */ composerText: string | null; composerAttachments: readonly AttachedFile[]; - /** Inline review comments, appended to the user's last authored text. */ - inlineComments: readonly unknown[]; + /** Context drafts (code comments, terminal selections, annotations, PR context). */ + inlineComments: readonly InlineCommentDraft[]; /** Synthetic context produced elsewhere (conflict resolution, and such). */ syntheticTexts: readonly string[]; - linkedIssueContext: string | null; - linkedPr: { instructions: string; context: string } | null; + linkedIssue: { number: number; title: string; url: string; contextText: string } | null; + linkedPr: { number: number; title: string; url: string; instructions: string; context: string } | null; } /** @@ -64,8 +68,6 @@ export interface OutgoingMessageDeps { sanitizeAttachments: (files: readonly AttachedFile[] | undefined) => AttachedFile[]; /** Skills named inline with `/name`. */ collectSkillNames: (text: string) => string[]; - /** Append inline review comments to a message body. */ - appendComments: (text: string, comments: readonly unknown[]) => string; /** Instruction telling the model which skills the user named. */ buildSkillInstruction: (names: string[]) => string | null; } @@ -134,33 +136,29 @@ export function buildOutgoingMessage( } } - // Inline comments attach to the last thing the user authored, so they read - // as a continuation of it rather than as a separate turn. - if (input.inlineComments.length > 0) { - const lastAuthored = input.queued.length > 0 && additionalParts.length > 0 - ? additionalParts[additionalParts.length - 1] - : null; - if (lastAuthored) { - lastAuthored.text = deps.appendComments(lastAuthored.text, input.inlineComments); - } else { - primaryText = deps.appendComments(primaryText, input.inlineComments); - } + // Everything below is context for the model, never plain user text. Each + // attached context item becomes its own synthetic part carrying structured + // metadata, so the timeline can render it as a context block after the + // server echoes the message back. + for (const draft of input.inlineComments) { + additionalParts.push(createContextPart(contextPayloadFromDraft(draft))); } - // Everything below is context for the model, never user-visible content. for (const text of input.syntheticTexts) { additionalParts.push({ text, synthetic: true }); } - if (input.linkedIssueContext) { - additionalParts.push({ text: input.linkedIssueContext, synthetic: true }); + if (input.linkedIssue) { + const { number, title, url, contextText } = input.linkedIssue; + additionalParts.push(createContextPart({ kind: 'github-issue', number, title, url }, contextText)); } if (input.linkedPr) { // Instructions before context: the model is told how to read the diff // before it is given the diff. - additionalParts.push({ text: input.linkedPr.instructions, synthetic: true }); - additionalParts.push({ text: input.linkedPr.context, synthetic: true }); + const { number, title, url, instructions, context } = input.linkedPr; + additionalParts.push({ text: instructions, synthetic: true }); + additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context)); } const skillInstruction = deps.buildSkillInstruction(skillNames); diff --git a/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx b/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx index d731e2b0..32373b3f 100644 --- a/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx +++ b/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx @@ -20,12 +20,12 @@ export interface ComposerContextChipsProps { reviewCount: number; prCommentCount: number; prCheckCount: number; - previewConsoleCount: number; previewAnnotationCount: number; + chatQuoteCount: number; draftTarget: InlineCommentDraftTarget | null; onRemoveDraft: (target: InlineCommentDraftTarget, draftId: string) => void; onRemoveReviewDrafts: () => void; - onRemovePreviewDrafts: (source: 'preview-console' | 'preview-annotation' | 'pr-comment' | 'pr-check') => void; + onRemovePreviewDrafts: (source: 'preview-annotation' | 'pr-comment' | 'pr-check' | 'chat-quote') => void; colors: Theme['colors']; } @@ -72,8 +72,8 @@ export function ComposerContextChips(props: ComposerContextChipsProps) { reviewCount, prCommentCount, prCheckCount, - previewConsoleCount, previewAnnotationCount, + chatQuoteCount, draftTarget, onRemoveDraft, onRemoveReviewDrafts, @@ -141,13 +141,14 @@ export function ComposerContextChips(props: ComposerContextChipsProps) { /> ) : null} - {previewConsoleCount > 0 ? ( + {chatQuoteCount > 0 ? ( onRemovePreviewDrafts('preview-console')} + label={t('chat.chatInput.chatQuoteContext')} + count={chatQuoteCount} + removeLabel={t('chat.chatInput.chatQuoteContextRemove')} + onRemove={() => onRemovePreviewDrafts('chat-quote')} colors={colors} + icon={} /> ) : null} diff --git a/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx b/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx index 831efacb..6bb2e10b 100644 --- a/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx +++ b/packages/ui/src/components/chat/composer/ui/MobilePillComposer.tsx @@ -84,7 +84,8 @@ export function MobilePillComposer(props: MobilePillComposerProps) { />
( @@ -46,6 +48,103 @@ export const TextSelectionMenu: React.FC = ({ containerR const [position, setPosition] = React.useState({ x: 0, y: 0, show: false }); const [selectedText, setSelectedText] = React.useState(''); const [selectedTextMarkdown, setSelectedTextMarkdown] = React.useState(''); + const [selectedMessageId, setSelectedMessageId] = React.useState(null); + const [commentMode, setCommentMode] = React.useState(false); + const commentModeRef = React.useRef(false); + const [commentText, setCommentText] = React.useState(''); + const commentInputRef = React.useRef(null); + + // While the comment input owns focus the native selection is gone, so the + // quoted fragment is repainted with our own overlay rectangles. Raw + // Range.getClientRects() mixes block-container boxes with text boxes and + // the translucent overlaps paint double-dark bands, so the rects are taken + // from the text nodes only and merged into one strip per visual line. + const [commentRects, setCommentRects] = React.useState(null); + const updateCommentRects = React.useCallback(() => { + const range = pendingSelectionRef.current?.range; + if (!range) { + setCommentRects(null); + return; + } + + const textRects: DOMRect[] = []; + const pushNodeRects = (node: Text) => { + const nodeRange = document.createRange(); + nodeRange.selectNodeContents(node); + if (node === range.startContainer) nodeRange.setStart(node, range.startOffset); + if (node === range.endContainer) nodeRange.setEnd(node, range.endOffset); + // Text rects cover only the glyph box; the native selection paints the + // full line box, so each rect is stretched to its element's line-height. + const lineHeight = node.parentElement + ? Number.parseFloat(window.getComputedStyle(node.parentElement).lineHeight) + : Number.NaN; + for (const rect of nodeRange.getClientRects()) { + if (rect.width <= 0 || rect.height <= 0) continue; + if (Number.isFinite(lineHeight) && lineHeight > rect.height) { + const expand = (lineHeight - rect.height) / 2; + textRects.push(new DOMRect(rect.left, rect.top - expand, rect.width, lineHeight)); + } else { + textRects.push(rect); + } + } + }; + const root = range.commonAncestorContainer; + if (root instanceof Text) { + pushNodeRects(root); + } else { + // SAFETY: the walker is created with SHOW_TEXT, so every node it + // yields is a Text node. + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (range.intersectsNode(node)) pushNodeRects(node as Text); + } + } + + // Merge rects that sit on the same visual line into one strip, the way + // the native selection paints a line box. + const lines: Array<{ left: number; right: number; top: number; bottom: number }> = []; + for (const rect of textRects) { + const line = lines.find((candidate) => ( + Math.abs(candidate.top - rect.top) < 6 && Math.abs(candidate.bottom - rect.bottom) < 6 + )); + if (line) { + line.left = Math.min(line.left, rect.left); + line.right = Math.max(line.right, rect.right); + line.top = Math.min(line.top, rect.top); + line.bottom = Math.max(line.bottom, rect.bottom); + } else { + lines.push({ left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }); + } + } + setCommentRects(lines.map((line) => new DOMRect(line.left, line.top, line.right - line.left, line.bottom - line.top))); + }, []); + + React.useEffect(() => { + if (!commentMode) return; + let frame: number | null = null; + const scheduleUpdate = () => { + if (frame !== null) return; + frame = window.requestAnimationFrame(() => { + frame = null; + updateCommentRects(); + }); + }; + document.addEventListener('scroll', scheduleUpdate, { capture: true, passive: true }); + window.addEventListener('resize', scheduleUpdate); + return () => { + if (frame !== null) window.cancelAnimationFrame(frame); + document.removeEventListener('scroll', scheduleUpdate, { capture: true }); + window.removeEventListener('resize', scheduleUpdate); + }; + }, [commentMode, updateCommentRects]); + + // Grow the comment box with its content, up to five lines. + const resizeCommentInput = React.useCallback(() => { + const element = commentInputRef.current; + if (!element) return; + element.style.height = 'auto'; + element.style.height = `${Math.min(element.scrollHeight, 120)}px`; + }, []); const isDraggingRef = React.useRef(false); const [isOpening, setIsOpening] = React.useState(false); const [isAddingToNotes, setIsAddingToNotes] = React.useState(false); @@ -57,6 +156,8 @@ export const TextSelectionMenu: React.FC = ({ containerR const isMenuVisibleRef = React.useRef(false); const createSession = useSessionUIStore((state) => state.createSession); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open); + const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft); const setPendingInputText = useInputStore((state) => state.setPendingInputText); const isMobile = useUIStore((state) => state.isMobile); const projects = useProjectsStore((state) => state.projects); @@ -64,6 +165,39 @@ export const TextSelectionMenu: React.FC = ({ containerR const effectiveDirectory = useEffectiveDirectory(); const sessions = useSessions(); + // Mobile: the comment bar is rendered inside the composer form (its + // positioning context), so it inherits the runtime's own keyboard handling + // — browser viewport resizing and Capacitor choreography alike. This effect + // only centers it on the composer pill in the form's local coordinates; no + // viewport math, which Safari's keyboard handling reliably breaks for + // fixed elements. + React.useEffect(() => { + if (!commentMode || !isMobile) return; + const update = () => { + const element = menuRef.current; + const host = element?.offsetParent; + if (!element || !host) return; + const pill = document.querySelector('[data-mobile-composer-pill="true"]') + ?? document.querySelector('[data-chat-input="true"]'); + const pillRect = pill?.getBoundingClientRect(); + if (!pillRect || pillRect.height <= 0) return; + const hostRect = host.getBoundingClientRect(); + element.style.top = `${pillRect.top - hostRect.top + (pillRect.height - element.offsetHeight) / 2}px`; + element.style.left = `${pillRect.left - hostRect.left}px`; + element.style.width = `${pillRect.width}px`; + element.style.bottom = 'auto'; + }; + update(); + const raf = window.requestAnimationFrame(update); + // The composer relayouts with its own transitions and timeouts that emit + // no event; a light poll keeps the overlay glued to the pill. + const poll = window.setInterval(update, 200); + return () => { + window.cancelAnimationFrame(raf); + window.clearInterval(poll); + }; + }, [commentMode, isMobile]); + React.useEffect(() => { isMenuVisibleRef.current = position.show; }, [position.show]); @@ -83,6 +217,7 @@ export const TextSelectionMenu: React.FC = ({ containerR const hideMenu = React.useCallback(() => { pendingSelectionRef.current = null; + setCommentRects(null); if (!isMenuVisibleRef.current) { return; @@ -97,6 +232,10 @@ export const TextSelectionMenu: React.FC = ({ containerR setPosition((prev) => ({ ...prev, show: false })); setSelectedText(''); setSelectedTextMarkdown(''); + setSelectedMessageId(null); + setCommentMode(false); + commentModeRef.current = false; + setCommentText(''); isMenuVisibleRef.current = false; }, []); @@ -121,7 +260,7 @@ export const TextSelectionMenu: React.FC = ({ containerR const showMenu = React.useCallback(() => { if (!pendingSelectionRef.current) return; - const { plainText, markdownText, rect } = pendingSelectionRef.current; + const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current; const shouldAnimateIn = !position.show; // Position menu above the selection @@ -132,6 +271,7 @@ export const TextSelectionMenu: React.FC = ({ containerR setSelectedText(plainText); setSelectedTextMarkdown(markdownText); + setSelectedMessageId(messageId); setPosition({ x: menuX, y: menuY, @@ -187,6 +327,11 @@ export const TextSelectionMenu: React.FC = ({ containerR }, [getDesktopClampedX, isMobile, position.show]); const handleSelectionChange = React.useCallback(() => { + // While the comment input is open, clicking or typing in it collapses the + // text selection; the captured quote must survive that. + if (commentModeRef.current) { + return; + } const selection = window.getSelection(); const container = containerRef.current; @@ -221,10 +366,15 @@ export const TextSelectionMenu: React.FC = ({ containerR const rect = range.getBoundingClientRect(); // Store the selection but don't show menu yet if dragging + const anchorElement = range.commonAncestorContainer instanceof Element + ? range.commonAncestorContainer + : range.commonAncestorContainer.parentElement; pendingSelectionRef.current = { plainText: text, markdownText: rangeToMarkdown(range, text), rect, + messageId: anchorElement?.closest('[data-message-id]')?.getAttribute('data-message-id') ?? null, + range: range.cloneRange(), }; // Only show menu if we're not currently dragging @@ -238,7 +388,12 @@ export const TextSelectionMenu: React.FC = ({ containerR if (!container) return; // Track when dragging starts - const handleMouseDown = () => { + const handleMouseDown = (event: MouseEvent) => { + // SAFETY: a MouseEvent target inside the document is always a Node; + // `contains` only needs that. + if (commentModeRef.current && menuRef.current?.contains(event.target as Node)) { + return; + } isDraggingRef.current = true; hideMenu(); }; @@ -254,6 +409,11 @@ export const TextSelectionMenu: React.FC = ({ containerR // Small delay to ensure selection is finalized mouseUpTimeoutRef.current = window.setTimeout(() => { mouseUpTimeoutRef.current = null; + // The click that opened the comment input cleared the selection on + // purpose; the input must survive this deferred check. + if (commentModeRef.current) { + return; + } const selection = window.getSelection(); if (selection && selection.toString().trim()) { showMenu(); @@ -275,7 +435,7 @@ export const TextSelectionMenu: React.FC = ({ containerR if ( menuRef.current && !menuRef.current.contains(e.target as Node) && - !window.getSelection()?.toString().trim() + (commentModeRef.current || !window.getSelection()?.toString().trim()) ) { hideMenu(); } @@ -310,6 +470,38 @@ export const TextSelectionMenu: React.FC = ({ containerR }); }, [selectedTextMarkdown, setPendingInputText, hideMenu]); + const handleOpenComment = React.useCallback(() => { + if (!selectedTextMarkdown) return; + setCommentMode(true); + commentModeRef.current = true; + updateCommentRects(); + window.getSelection()?.removeAllRanges(); + queueMicrotask(() => { + commentInputRef.current?.focus(); + }); + }, [selectedTextMarkdown, updateCommentRects]); + + const handleAttachComment = React.useCallback(() => { + const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); + if (!selectedTextMarkdown || !sessionKey || !effectiveDirectory) { + hideMenu(); + return; + } + addContextDraft({ directory: effectiveDirectory, sessionKey }, { + source: 'chat-quote', + fileLabel: selectedMessageId ?? '', + startLine: 1, + endLine: 1, + code: selectedTextMarkdown, + language: '', + text: commentText.trim(), + }); + hideMenu(); + queueMicrotask(() => { + focusChatInput(); + }); + }, [addContextDraft, commentText, currentSessionId, effectiveDirectory, hideMenu, newSessionDraftOpen, selectedMessageId, selectedTextMarkdown]); + const handleCreateNewSession = React.useCallback(async () => { if (!selectedText) return; @@ -322,18 +514,6 @@ export const TextSelectionMenu: React.FC = ({ containerR window.getSelection()?.removeAllRanges(); }, [selectedText, createSession, setPendingInputText, hideMenu]); - const handleCopy = React.useCallback(async () => { - if (!selectedText) return; - - const result = await copyTextToClipboard(selectedText); - if (!result.ok) { - console.error('Failed to copy:', result.error); - } - - hideMenu(); - window.getSelection()?.removeAllRanges(); - }, [selectedText, hideMenu]); - const currentSession = React.useMemo(() => { if (!currentSessionId) { return null; @@ -390,15 +570,110 @@ export const TextSelectionMenu: React.FC = ({ containerR if (!position.show) return null; + const commentHighlightOverlay = commentMode && commentRects && commentRects.length > 0 + ? createPortal( +
+ {commentRects.map((rect, index) => ( +
+ ))} +
, + document.body, + ) + : null; + + const commentInput = ( +
+