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(),