From eb03ffaa8003f4a7e8660f6a6805bcec131860d0 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 01:21:35 +0300 Subject: [PATCH] feat(files): comment on selection in read-only markdown previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting text in a rendered markdown preview shows a Comment pill; attaching stores a file-quote context draft carrying the file path, the selected fragment (not whole lines), the user's comment, and a best-effort source line range resolved by anchoring the fragment's first and last lines in the raw content — a partially located fragment gets no range rather than a misleading one. The fragment stays highlighted while the comment input is open, using the selection overlay rects shared with chat quote comments, and the preview's native selection color now matches chat messages. file-quote flows through the same context contract: composer chip previews, the message context card, and the metadata round-trip. --- .../chat/composer/ui/ComposerContextChips.tsx | 10 +- .../chat/message/TextSelectionMenu.tsx | 52 +--- .../chat/message/parts/UserContextPart.tsx | 9 + .../comments/FilePreviewCommentMenu.tsx | 258 ++++++++++++++++++ packages/ui/src/components/comments/index.ts | 1 + .../ui/src/components/views/FilesView.tsx | 20 +- packages/ui/src/index.css | 4 +- packages/ui/src/lib/i18n/messages/de.ts | 1 + packages/ui/src/lib/i18n/messages/en.ts | 1 + packages/ui/src/lib/i18n/messages/es.ts | 1 + packages/ui/src/lib/i18n/messages/fr.ts | 1 + packages/ui/src/lib/i18n/messages/ja.ts | 1 + packages/ui/src/lib/i18n/messages/ko.ts | 1 + packages/ui/src/lib/i18n/messages/pl.ts | 1 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 + packages/ui/src/lib/i18n/messages/uk.ts | 1 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 1 + .../ui/src/lib/messages/contextParts.test.ts | 9 + packages/ui/src/lib/messages/contextParts.ts | 34 +++ packages/ui/src/lib/selectionOverlayRects.ts | 57 ++++ .../src/stores/useInlineCommentDraftStore.ts | 4 +- 22 files changed, 411 insertions(+), 58 deletions(-) create mode 100644 packages/ui/src/components/comments/FilePreviewCommentMenu.tsx create mode 100644 packages/ui/src/lib/selectionOverlayRects.ts diff --git a/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx b/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx index cdb19baa..832dc623 100644 --- a/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx +++ b/packages/ui/src/components/chat/composer/ui/ComposerContextChips.tsx @@ -40,14 +40,14 @@ type ChipGroup = { drafts: InlineCommentDraft[]; }; -const REVIEW_SOURCES: readonly InlineCommentSource[] = ['diff', 'file', 'plan']; +const REVIEW_SOURCES: readonly InlineCommentSource[] = ['diff', 'file', 'plan', 'file-quote']; /** Sources whose drafts carry a user-written comment that can be edited. */ const editableSource = (source: InlineCommentSource): boolean => source !== 'terminal'; /** Captured code/output kinds read better monospaced; quoted prose does not. */ const monoSource = (source: InlineCommentSource): boolean => - source !== 'chat-quote' && source !== 'preview-annotation'; + source !== 'chat-quote' && source !== 'preview-annotation' && source !== 'file-quote'; const basename = (path: string): string => { const segments = path.split('/').filter(Boolean); @@ -259,6 +259,12 @@ export function ComposerContextChips({ draftTarget, colors }: ComposerContextChi return t('chat.message.context.prCheck', { label: draft.fileLabel }); case 'chat-quote': return t('chat.message.context.chatQuote'); + case 'file-quote': + return draft.startLine > 0 && draft.endLine > 0 + ? (draft.startLine === draft.endLine + ? t('chat.message.context.codeCommentLine', { file: basename(draft.fileLabel), line: draft.startLine }) + : t('chat.message.context.codeComment', { file: basename(draft.fileLabel), start: draft.startLine, end: draft.endLine })) + : t('chat.message.context.fileQuote', { file: basename(draft.fileLabel) }); default: return draft.startLine === draft.endLine ? t('chat.message.context.codeCommentLine', { file: basename(draft.fileLabel), line: draft.startLine }) diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index 6acb3dd0..d52f8d26 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -18,6 +18,7 @@ import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; +import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects'; interface TextSelectionMenuProps { containerRef: React.RefObject; @@ -67,56 +68,7 @@ export const TextSelectionMenu: React.FC = ({ containerR 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))); + setCommentRects(collectSelectionOverlayRects(range)); }, []); React.useEffect(() => { diff --git a/packages/ui/src/components/chat/message/parts/UserContextPart.tsx b/packages/ui/src/components/chat/message/parts/UserContextPart.tsx index 6b95823a..3f1e2cac 100644 --- a/packages/ui/src/components/chat/message/parts/UserContextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/UserContextPart.tsx @@ -113,6 +113,15 @@ const UserContextPart: React.FC<{ payload: ContextPartPayload }> = ({ payload }) text={payload.text} /> ); + case 'file-quote': { + const file = basename(payload.fileLabel); + const summary = payload.startLine != null && payload.endLine != null + ? (payload.startLine === payload.endLine + ? t('chat.message.context.codeCommentLine', { file, line: payload.startLine }) + : t('chat.message.context.codeComment', { file, start: payload.startLine, end: payload.endLine })) + : t('chat.message.context.fileQuote', { file }); + return ; + } case 'chat-quote': return ( ; + filePath: string; + /** Raw file source, used to locate the selected fragment's line range. */ + fileContent: string; +} + +/** + * Comment-on-selection for read-only file previews (rendered markdown and the + * like). Selecting text shows a small Comment pill above the selection; + * choosing it opens the shared comment input, and attaching stores a + * file-quote context draft carrying the selected fragment (with a best-effort + * source line range) plus the user's comment. + */ +export function FilePreviewCommentMenu({ containerRef, filePath, fileContent }: FilePreviewCommentMenuProps) { + const { t } = useI18n(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open); + const effectiveDirectory = useEffectiveDirectory(); + const addDraft = useInlineCommentDraftStore((state) => state.addDraft); + + const [anchor, setAnchor] = React.useState<{ x: number; y: number } | null>(null); + const [selectedText, setSelectedText] = React.useState(''); + const [commentMode, setCommentMode] = React.useState(false); + const commentModeRef = React.useRef(false); + const menuRef = React.useRef(null); + const selectionRangeRef = React.useRef(null); + // The selected fragment stays visibly highlighted while the comment input + // owns focus, same as chat quote comments. + const [highlightRects, setHighlightRects] = React.useState(null); + + const hide = React.useCallback(() => { + setAnchor(null); + setSelectedText(''); + setCommentMode(false); + commentModeRef.current = false; + selectionRangeRef.current = null; + setHighlightRects(null); + }, []); + + React.useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleSelectionChange = () => { + if (commentModeRef.current) return; + const selection = window.getSelection(); + const text = selection?.toString().trim() ?? ''; + if (!selection || !text || selection.rangeCount === 0) { + hide(); + return; + } + const range = selection.getRangeAt(0); + if (!container.contains(range.commonAncestorContainer)) { + hide(); + return; + } + const rect = range.getBoundingClientRect(); + selectionRangeRef.current = range.cloneRange(); + setSelectedText(text); + setAnchor({ x: rect.left + rect.width / 2, y: rect.top - 10 }); + }; + + const handlePointerDown = (event: PointerEvent) => { + // SAFETY: a pointer event target inside the document is always a Node; + // `contains` only needs that. + if (menuRef.current?.contains(event.target as Node)) return; + if (commentModeRef.current) { + hide(); + } + }; + + document.addEventListener('selectionchange', handleSelectionChange); + document.addEventListener('pointerdown', handlePointerDown); + return () => { + document.removeEventListener('selectionchange', handleSelectionChange); + document.removeEventListener('pointerdown', handlePointerDown); + }; + }, [containerRef, hide]); + + /** + * Best-effort mapping of the rendered-text selection back to source lines: + * an exact match of the fragment (or its first line) in the raw content + * yields a range; markdown syntax usually breaks the match, in which case + * the draft simply carries no line numbers. + */ + const resolveLineRange = React.useCallback((fragment: string): { start: number; end: number } | null => { + const lineAt = (index: number): number => fileContent.slice(0, index).split('\n').length; + // Inline markdown syntax (emphasis markers and such) breaks long matches, + // so each anchor line is tried at decreasing lengths. + const candidatesFor = (line: string): string[] => { + const trimmed = line.trim(); + return [trimmed, trimmed.slice(0, 32), trimmed.split(' ').slice(0, 4).join(' ')] + .filter((candidate) => candidate.length >= 8); + }; + const locate = (line: string, from: number): number => { + for (const candidate of candidatesFor(line)) { + const index = fileContent.indexOf(candidate, from); + if (index >= 0) return index; + } + return -1; + }; + + const exact = fragment.length >= 8 ? fileContent.indexOf(fragment) : -1; + if (exact >= 0) { + const start = lineAt(exact); + return { start, end: start + fragment.split('\n').length - 1 }; + } + + const fragmentLines = fragment.split('\n').map((line) => line.trim()).filter(Boolean); + if (fragmentLines.length === 0) return null; + const startIndex = locate(fragmentLines[0], 0); + if (startIndex < 0) return null; + const start = lineAt(startIndex); + if (fragmentLines.length === 1) return { start, end: start }; + const endIndex = locate(fragmentLines[fragmentLines.length - 1], startIndex); + // A partially located multi-line fragment gets no range rather than a + // misleading single-line one. + if (endIndex < 0) return null; + return { start, end: Math.max(start, lineAt(endIndex)) }; + }, [fileContent]); + + const updateHighlightRects = React.useCallback(() => { + const range = selectionRangeRef.current; + if (!range) { + setHighlightRects(null); + return; + } + setHighlightRects(collectSelectionOverlayRects(range)); + }, []); + + React.useEffect(() => { + if (!commentMode) return; + let frame: number | null = null; + const scheduleUpdate = () => { + if (frame !== null) return; + frame = window.requestAnimationFrame(() => { + frame = null; + updateHighlightRects(); + }); + }; + 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, updateHighlightRects]); + + const openComment = React.useCallback(() => { + if (!selectedText) return; + setCommentMode(true); + commentModeRef.current = true; + updateHighlightRects(); + window.getSelection()?.removeAllRanges(); + }, [selectedText, updateHighlightRects]); + + const saveComment = React.useCallback((text: string) => { + const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null); + if (!selectedText || !sessionKey || !effectiveDirectory) { + hide(); + return; + } + const lineRange = resolveLineRange(selectedText); + addDraft({ directory: effectiveDirectory, sessionKey }, { + source: 'file-quote', + fileLabel: filePath, + startLine: lineRange?.start ?? 0, + endLine: lineRange?.end ?? 0, + code: selectedText, + language: '', + text: text.trim(), + }); + hide(); + queueMicrotask(() => { + focusChatInput(); + }); + }, [addDraft, currentSessionId, effectiveDirectory, filePath, hide, newSessionDraftOpen, resolveLineRange, selectedText]); + + if (!anchor) return null; + + const lineRange = commentMode ? resolveLineRange(selectedText) : null; + + return createPortal( +
+ {commentMode && highlightRects && highlightRects.length > 0 + ? createPortal( +
+ {highlightRects.map((rect, index) => ( +
+ ))} +
, + document.body, + ) + : null} + {commentMode ? ( +
+ +
+ ) : ( +
+ +
+ )} +
, + document.body, + ); +} diff --git a/packages/ui/src/components/comments/index.ts b/packages/ui/src/components/comments/index.ts index 264f8db3..3f2dabec 100644 --- a/packages/ui/src/components/comments/index.ts +++ b/packages/ui/src/components/comments/index.ts @@ -4,3 +4,4 @@ export * from './useInlineCommentController'; export * from './CodeMirrorCommentWidgets'; export * from './PierreDiffCommentUtils'; export * from './PierreDiffCommentOverlays'; +export * from './FilePreviewCommentMenu'; diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 212d02ec..05ca0323 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -60,7 +60,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useGitStatus } from '@/stores/useGitStore'; import { useConfigStore } from '@/stores/useConfigStore'; -import { buildCodeMirrorCommentWidgets, normalizeLineRange, useInlineCommentController } from '@/components/comments'; +import { buildCodeMirrorCommentWidgets, FilePreviewCommentMenu, normalizeLineRange, useInlineCommentController } from '@/components/comments'; import { opencodeClient } from '@/lib/opencode/client'; import { useDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; @@ -1071,6 +1071,8 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { return lines.slice(startLine - 1, endLine).join('\n'); }, []); + const markdownPreviewRef = React.useRef(null); + const fileCommentController = useInlineCommentController({ source: 'file', fileLabel: selectedFile?.path ?? null, @@ -3943,7 +3945,12 @@ export const FilesView: React.FC = ({ mode = 'full' }) => {
) : selectedFile && isMarkdown && getMdViewMode() === 'preview' ? ( -
+
+ {fileContent.length > 500 * 1024 && (
{t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })} @@ -4309,7 +4316,14 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { ) : null}
) : isMarkdown && getMdViewMode() === 'preview' ? ( -
+
+ {selectedFile ? ( + + ) : null} {fileContent.length > 500 * 1024 && (
{t('filesView.warning.largeFilePreviewLimited', { sizeKb: Math.round(fileContent.length / 1024) })} diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 0a87622c..c487cd09 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -76,7 +76,9 @@ button, } :root.light .message-content-text::selection, -:root.light .message-content-text ::selection { +:root.light .message-content-text ::selection, +:root.light .oc-file-preview::selection, +:root.light .oc-file-preview ::selection { background: color-mix(in srgb, var(--interactive-border-focus) 18%, transparent); color: var(--surface-foreground); } diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 2afe1428..4f0f3bc2 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2907,6 +2907,7 @@ export const dict = { 'chat.message.context.codeComment': 'Kommentar zu {file}, Zeilen {start}-{end}', 'chat.message.context.codeCommentLine': 'Kommentar zu {file}, Zeile {line}', 'chat.message.context.chatQuote': 'Zitat aus einer früheren Nachricht', + 'chat.message.context.fileQuote': 'Auswahl aus {file}', 'chat.chatInput.chatQuoteContext': 'Chat-Zitate', 'chat.chatInput.chatQuoteContextRemove': 'Chat-Zitate entfernen', 'chat.chatInput.contextPreview.selectedLabel': 'Ausgewählter Text', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 3926eab2..fc9a2717 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -8,6 +8,7 @@ export const dict = { 'chat.message.context.codeComment': 'Comment on {file}, lines {start}-{end}', 'chat.message.context.codeCommentLine': 'Comment on {file}, line {line}', 'chat.message.context.chatQuote': 'Quoted from an earlier message', + 'chat.message.context.fileQuote': 'Selection from {file}', 'chat.chatInput.chatQuoteContext': 'Chat quotes', 'chat.chatInput.chatQuoteContextRemove': 'Remove chat quotes', 'chat.chatInput.contextPreview.selectedLabel': 'Selected text', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index d722b79c..17fec35e 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -9,6 +9,7 @@ export const dict: Record = { 'chat.message.context.codeComment': 'Comentario en {file}, líneas {start}-{end}', 'chat.message.context.codeCommentLine': 'Comentario en {file}, línea {line}', 'chat.message.context.chatQuote': 'Cita de un mensaje anterior', + 'chat.message.context.fileQuote': 'Selección de {file}', 'chat.chatInput.chatQuoteContext': 'Citas del chat', 'chat.chatInput.chatQuoteContextRemove': 'Quitar citas del chat', 'chat.chatInput.contextPreview.selectedLabel': 'Texto seleccionado', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index d65c0585..0cf10e61 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -8,6 +8,7 @@ export const dict = { 'chat.message.context.codeComment': 'Commentaire sur {file}, lignes {start}-{end}', 'chat.message.context.codeCommentLine': 'Commentaire sur {file}, ligne {line}', 'chat.message.context.chatQuote': 'Citation d’un message précédent', + 'chat.message.context.fileQuote': 'Sélection de {file}', 'chat.chatInput.chatQuoteContext': 'Citations du chat', 'chat.chatInput.chatQuoteContextRemove': 'Supprimer les citations du chat', 'chat.chatInput.contextPreview.selectedLabel': 'Texte sélectionné', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index fbaae9d8..5796a5b0 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -9,6 +9,7 @@ export const dict: Record = { 'chat.message.context.codeComment': '{file} の {start}〜{end} 行へのコメント', 'chat.message.context.codeCommentLine': '{file} の {line} 行へのコメント', 'chat.message.context.chatQuote': '以前のメッセージからの引用', + 'chat.message.context.fileQuote': '{file} からの選択', 'chat.chatInput.chatQuoteContext': 'チャット引用', 'chat.chatInput.chatQuoteContextRemove': 'チャット引用を削除', 'chat.chatInput.contextPreview.selectedLabel': '選択したテキスト', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 8bc95a7e..80d4f60e 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -9,6 +9,7 @@ export const dict: Record = { 'chat.message.context.codeComment': '{file} {start}-{end}행에 대한 댓글', 'chat.message.context.codeCommentLine': '{file} {line}행에 대한 댓글', 'chat.message.context.chatQuote': '이전 메시지에서 인용', + 'chat.message.context.fileQuote': '{file}에서 선택한 부분', 'chat.chatInput.chatQuoteContext': '채팅 인용', 'chat.chatInput.chatQuoteContextRemove': '채팅 인용 제거', 'chat.chatInput.contextPreview.selectedLabel': '선택한 텍스트', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index b8bf6843..c29a1dfa 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -9,6 +9,7 @@ export const dict: Record = { 'chat.message.context.codeComment': 'Komentarz do {file}, wiersze {start}-{end}', 'chat.message.context.codeCommentLine': 'Komentarz do {file}, wiersz {line}', 'chat.message.context.chatQuote': 'Cytat z wcześniejszej wiadomości', + 'chat.message.context.fileQuote': 'Zaznaczenie z {file}', 'chat.chatInput.chatQuoteContext': 'Cytaty z czatu', 'chat.chatInput.chatQuoteContextRemove': 'Usuń cytaty z czatu', 'chat.chatInput.contextPreview.selectedLabel': 'Zaznaczony tekst', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 7e2643ca..5e988ce3 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -9,6 +9,7 @@ export const dict: Record = { 'chat.message.context.codeComment': 'Comentário em {file}, linhas {start}-{end}', 'chat.message.context.codeCommentLine': 'Comentário em {file}, linha {line}', 'chat.message.context.chatQuote': 'Citação de uma mensagem anterior', + 'chat.message.context.fileQuote': 'Seleção de {file}', 'chat.chatInput.chatQuoteContext': 'Citações do chat', 'chat.chatInput.chatQuoteContextRemove': 'Remover citações do chat', 'chat.chatInput.contextPreview.selectedLabel': 'Texto selecionado', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 1e660cd9..1f10374f 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -9,6 +9,7 @@ export const dict: Record = { 'chat.message.context.codeComment': 'Коментар до {file}, рядки {start}-{end}', 'chat.message.context.codeCommentLine': 'Коментар до {file}, рядок {line}', 'chat.message.context.chatQuote': 'Цитата з попереднього повідомлення', + 'chat.message.context.fileQuote': 'Виділене з {file}', 'chat.chatInput.chatQuoteContext': 'Цитати з чату', 'chat.chatInput.chatQuoteContextRemove': 'Прибрати цитати з чату', 'chat.chatInput.contextPreview.selectedLabel': 'Виділений текст', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index f54c4640..2b0c7263 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -9,6 +9,7 @@ export const dict: Record = { 'chat.message.context.codeComment': '对 {file} 第 {start}-{end} 行的评论', 'chat.message.context.codeCommentLine': '对 {file} 第 {line} 行的评论', 'chat.message.context.chatQuote': '引用自先前的消息', + 'chat.message.context.fileQuote': '来自 {file} 的选择', 'chat.chatInput.chatQuoteContext': '聊天引用', 'chat.chatInput.chatQuoteContextRemove': '移除聊天引用', 'chat.chatInput.contextPreview.selectedLabel': '所选文本', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index e2338503..842450c1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -9,6 +9,7 @@ export const dict: Record = { 'chat.message.context.codeComment': '對 {file} 第 {start}-{end} 行的評論', 'chat.message.context.codeCommentLine': '對 {file} 第 {line} 行的評論', 'chat.message.context.chatQuote': '引用自先前的訊息', + 'chat.message.context.fileQuote': '來自 {file} 的選取內容', 'chat.chatInput.chatQuoteContext': '聊天引用', 'chat.chatInput.chatQuoteContextRemove': '移除聊天引用', 'chat.chatInput.contextPreview.selectedLabel': '所選文字', diff --git a/packages/ui/src/lib/messages/contextParts.test.ts b/packages/ui/src/lib/messages/contextParts.test.ts index 81cfd8cc..eb980435 100644 --- a/packages/ui/src/lib/messages/contextParts.test.ts +++ b/packages/ui/src/lib/messages/contextParts.test.ts @@ -68,6 +68,13 @@ describe('model-facing text', () => { .toBe('Comment on this fragment of an earlier message in this conversation:\n> first line\n> second line\n\nwhy so?'); }); + test('file quotes carry the fragment with an optional line range', () => { + expect(formatContextText(contextPayloadFromDraft(draft({ source: 'file-quote', fileLabel: 'docs/CHANGELOG.md', startLine: 12, endLine: 13, code: 'a\nb', text: 'why?' })))) + .toBe('Comment on this fragment of `docs/CHANGELOG.md` lines 12-13:\n> a\n> b\n\nwhy?'); + expect(formatContextText(contextPayloadFromDraft(draft({ source: 'file-quote', fileLabel: 'docs/CHANGELOG.md', startLine: 0, endLine: 0, code: 'a', text: '' })))) + .toBe('Comment on this fragment of `docs/CHANGELOG.md`:\n> a'); + }); + test('PR comments and checks keep their attachment wording', () => { expect(formatContextText(contextPayloadFromDraft(draft({ source: 'pr-comment', fileLabel: 'octo/repo#7', code: 'the comment', text: '' })))) .toBe('Attached GitHub PR comment (octo/repo#7):\n\nthe comment'); @@ -91,6 +98,8 @@ describe('round-trip through part metadata', () => { contextPayloadFromDraft(draft({ source: 'pr-comment' })), contextPayloadFromDraft(draft({ source: 'pr-check' })), contextPayloadFromDraft(draft({ source: 'chat-quote', fileLabel: 'msg_1' })), + contextPayloadFromDraft(draft({ source: 'file-quote', startLine: 3, endLine: 5 })), + contextPayloadFromDraft(draft({ source: 'file-quote', startLine: 0, endLine: 0 })), ]; for (const payload of payloads) { expect(readContextPart(asPart(payload))).toEqual(payload); diff --git a/packages/ui/src/lib/messages/contextParts.ts b/packages/ui/src/lib/messages/contextParts.ts index 41a95e2a..d31e0ee0 100644 --- a/packages/ui/src/lib/messages/contextParts.ts +++ b/packages/ui/src/lib/messages/contextParts.ts @@ -71,6 +71,16 @@ type GitHubIssueContext = { url: string; }; +type FileQuoteContext = { + kind: 'file-quote'; + fileLabel: string; + /** Present when the fragment could be located in the file source. */ + startLine?: number; + endLine?: number; + quote: string; + text: string; +}; + type ChatQuoteContext = { kind: 'chat-quote'; /** The message the quote came from, when known. */ @@ -92,6 +102,7 @@ export type ContextPartPayload = | BrowserAnnotationContext | PrCommentContext | PrCheckContext + | FileQuoteContext | ChatQuoteContext | GitHubIssueContext | GitHubPrContext; @@ -128,6 +139,13 @@ export function formatContextText(payload: ContextPartPayload): string { return payload.text ? `${payload.prompt}\n\n${payload.text}` : payload.prompt; case 'pr-comment': return `Attached GitHub PR comment (${payload.label}):\n\n${payload.body}${payload.text ? `\n\n${payload.text}` : ''}`; + case 'file-quote': { + const location = payload.startLine != null && payload.endLine != null + ? ` lines ${payload.startLine}-${payload.endLine}` + : ''; + const quoted = payload.quote.split('\n').map((line) => `> ${line}`).join('\n'); + return `Comment on this fragment of \`${payload.fileLabel}\`${location}:\n${quoted}${payload.text ? `\n\n${payload.text}` : ''}`; + } case 'chat-quote': { const quoted = payload.quote.split('\n').map((line) => `> ${line}`).join('\n'); return `Comment on this fragment of an earlier message in this conversation:\n${quoted}${payload.text ? `\n\n${payload.text}` : ''}`; @@ -179,6 +197,14 @@ export function contextPayloadFromDraft(draft: InlineCommentDraft): ContextPartP return { kind: 'pr-comment', label: draft.fileLabel, body: draft.code, text: draft.text }; case 'pr-check': return { kind: 'pr-check', label: draft.fileLabel, output: draft.code, text: draft.text }; + case 'file-quote': { + const payload: FileQuoteContext = { kind: 'file-quote', fileLabel: draft.fileLabel, quote: draft.code, text: draft.text }; + if (draft.startLine > 0 && draft.endLine > 0) { + payload.startLine = draft.startLine; + payload.endLine = draft.endLine; + } + return payload; + } case 'chat-quote': { const payload: ChatQuoteContext = { kind: 'chat-quote', quote: draft.code, text: draft.text }; if (draft.fileLabel) payload.messageId = draft.fileLabel; @@ -245,6 +271,14 @@ const contextPayloadSchema = z.discriminatedUnion('kind', [ output: z.string(), text: z.string(), }), + z.object({ + kind: z.literal('file-quote'), + fileLabel: z.string(), + startLine: z.number().optional(), + endLine: z.number().optional(), + quote: z.string(), + text: z.string(), + }), z.object({ kind: z.literal('chat-quote'), messageId: z.string().optional(), diff --git a/packages/ui/src/lib/selectionOverlayRects.ts b/packages/ui/src/lib/selectionOverlayRects.ts new file mode 100644 index 00000000..322b2b38 --- /dev/null +++ b/packages/ui/src/lib/selectionOverlayRects.ts @@ -0,0 +1,57 @@ +/** + * Overlay rectangles for a captured text selection. + * + * While a comment input owns focus the native selection is gone, so the + * quoted fragment is repainted with these rects (styled by + * `.oc-chat-comment-rect`). Raw Range.getClientRects() mixes block-container + * boxes with text boxes and the translucent overlaps paint double-dark bands, + * so rects are taken from the text nodes only and merged into one strip per + * visual line, each stretched to its element's line-height the way the native + * selection paints a line box. + */ +export const collectSelectionOverlayRects = (range: Range): DOMRect[] => { + 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); + 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 { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (node instanceof Text && range.intersectsNode(node)) pushNodeRects(node); + } + } + + 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 }); + } + } + return lines.map((line) => new DOMRect(line.left, line.top, line.right - line.left, line.bottom - line.top)); +}; diff --git a/packages/ui/src/stores/useInlineCommentDraftStore.ts b/packages/ui/src/stores/useInlineCommentDraftStore.ts index 5b144b78..c90bc07a 100644 --- a/packages/ui/src/stores/useInlineCommentDraftStore.ts +++ b/packages/ui/src/stores/useInlineCommentDraftStore.ts @@ -5,7 +5,7 @@ import { getRuntimeKey } from '@/lib/runtime-switch'; import { normalizePath } from '@/lib/pathNormalization'; import { createDeferredSafeJSONStorage } from './utils/safeStorage'; -export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-annotation' | 'terminal' | 'pr-comment' | 'pr-check' | 'chat-quote'; +export type InlineCommentSource = 'diff' | 'plan' | 'file' | 'preview-annotation' | 'terminal' | 'pr-comment' | 'pr-check' | 'chat-quote' | 'file-quote'; export type InlineCommentDraftTarget = { directory: string; @@ -170,7 +170,7 @@ const EMPTY_PERSISTED_STATE: InlineCommentDraftState = { drafts: {}, touchedAt: const persistedDraftSchema = z.object({ id: z.string(), sessionKey: z.string(), - source: z.enum(['diff', 'plan', 'file', 'preview-annotation', 'terminal', 'pr-comment', 'pr-check', 'chat-quote']), + source: z.enum(['diff', 'plan', 'file', 'preview-annotation', 'terminal', 'pr-comment', 'pr-check', 'chat-quote', 'file-quote']), fileLabel: z.string(), startLine: z.number(), endLine: z.number(),