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
+1
View File
@@ -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',
+1
View File
@@ -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',
+1
View File
@@ -9,6 +9,7 @@ export const dict: Record<I18nKey, string> = {
'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',
+1
View File
@@ -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 dun 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é',
+1
View File
@@ -9,6 +9,7 @@ export const dict: Record<I18nKey, string> = {
'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': '選択したテキスト',
+1
View File
@@ -9,6 +9,7 @@ export const dict: Record<I18nKey, string> = {
'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': '선택한 텍스트',
+1
View File
@@ -9,6 +9,7 @@ export const dict: Record<I18nKey, string> = {
'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',
@@ -9,6 +9,7 @@ export const dict: Record<I18nKey, string> = {
'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',
+1
View File
@@ -9,6 +9,7 @@ export const dict: Record<I18nKey, string> = {
'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': 'Виділений текст',
@@ -9,6 +9,7 @@ export const dict: Record<I18nKey, string> = {
'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': '所选文本',
@@ -9,6 +9,7 @@ export const dict: Record<I18nKey, string> = {
'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': '所選文字',
@@ -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);
@@ -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(),
@@ -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));
};