feat(files): comment on selection in read-only markdown previews

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.
This commit is contained in:
Bohdan Triapitsyn
2026-08-24 01:21:35 +03:00
parent e47c027ea0
commit eb03ffaa80
22 changed files with 411 additions and 58 deletions
@@ -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 })
@@ -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<HTMLElement | null>;
@@ -67,56 +68,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ 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(() => {
@@ -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 <ContextCard icon="chat-1" summary={summary} title={payload.fileLabel} body={payload.quote} text={payload.text} />;
}
case 'chat-quote':
return (
<ContextCard