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