feat(web): render code comment directives (#2871)

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
Co-authored-by: mrcfps <mrc@powerformer.com>
This commit is contained in:
chaoxiaoche 2026-05-25 19:09:59 +08:00 committed by GitHub
parent 2c2900cfad
commit dc2cb6b371
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 343 additions and 29 deletions

View file

@ -39,7 +39,11 @@ import type { PluginFolderAgentAction } from "./design-files/pluginFolderActions
import { Icon } from "./Icon";
import { useT } from "../i18n";
import { deriveFileOps, type FileOpEntry } from "../runtime/file-ops";
import { unfinishedTodosFromEvents, type TodoItem } from "../runtime/todos";
import {
isTodoWriteToolName,
unfinishedTodosFromEvents,
type TodoItem,
} from "../runtime/todos";
import type { Dict } from "../i18n/types";
import { agentDisplayName, exactAgentDisplayName } from "../utils/agentLabels";
import {
@ -1592,6 +1596,8 @@ const SNAPSHOT_TOOL_NAMES = new Set([
"ask_user_question",
"TodoWrite",
"todowrite",
"todo_write",
"update_plan",
]);
function dedupeSnapshotToolRetries(items: ToolItem[]): ToolItem[] {
@ -1619,9 +1625,7 @@ function dedupeSnapshotToolRetries(items: ToolItem[]): ToolItem[] {
// differ). We detect by checking whether all items share a TodoWrite
// name after the input-key dedupe above.
const collapsed = Array.from(lastByKey.values());
const allTodoWrite = collapsed.every(
(it) => it.use.name === "TodoWrite" || it.use.name === "todowrite",
);
const allTodoWrite = collapsed.every((it) => isTodoWriteToolName(it.use.name));
if (allTodoWrite && collapsed.length > 1) {
return [collapsed[collapsed.length - 1]!];
}
@ -1749,7 +1753,7 @@ function toolFamily(name: string): string {
if (name === "Glob" || name === "list_files") return "glob";
if (name === "Grep") return "grep";
if (name === "Bash") return "bash";
if (name === "TodoWrite") return "todo";
if (isTodoWriteToolName(name)) return "todo";
if (name === "WebFetch" || name === "web_fetch") return "fetch";
if (name === "WebSearch" || name === "web_search") return "search";
return name.toLowerCase();
@ -1830,9 +1834,7 @@ type Block =
function stripTodoToolGroups(blocks: Block[]): Block[] {
return blocks.filter((block) => {
if (block.kind !== "tool-group") return true;
return !block.items.every(
(it) => it.use.name === "TodoWrite" || it.use.name === "todowrite",
);
return !block.items.every((it) => isTodoWriteToolName(it.use.name));
});
}

View file

@ -10,7 +10,7 @@
*/
import { useState } from 'react';
import { useT } from '../i18n';
import { parseTodoWriteInput } from '../runtime/todos';
import { isTodoWriteToolName, parseTodoWriteInput } from '../runtime/todos';
import { getToolRenderer, toRenderProps } from '../runtime/tool-renderers';
import type { AgentEvent } from '../types';
@ -89,7 +89,7 @@ export function ToolCard({
onAnswerToolUse={onAnswerToolUse}
/>
);
if (name === 'TodoWrite' || name === 'todowrite') return <TodoCard input={use.input} runStreaming={isStreaming} runSucceeded={isSucceeded} />;
if (isTodoWriteToolName(name)) return <TodoCard input={use.input} runStreaming={isStreaming} runSucceeded={isSucceeded} />;
if (name === 'Write' || name === 'write' || name === 'create_file')
return <FileWriteCard input={use.input} result={result} runStreaming={isStreaming} runSucceeded={isSucceeded} ctx={ctx} />;
if (name === 'Edit' || name === 'str_replace_edit')

View file

@ -11,7 +11,10 @@
* Output is a React fragment of typed elements no dangerouslySetInnerHTML,
* so untrusted text can't smuggle markup through.
*/
import { Fragment, type MouseEvent, type ReactNode } from 'react';
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
import { useT } from '../i18n';
import { copyToClipboard } from '../lib/copy-to-clipboard';
import type { MouseEvent } from 'react';
export type MarkdownLinkClickHandler = (
href: string,
@ -47,9 +50,19 @@ type Block =
| { kind: 'ul'; items: string[] }
| { kind: 'ol'; items: string[] }
| { kind: 'code'; lang: string | null; body: string }
| { kind: 'codeComment'; comment: CodeCommentDirective }
| { kind: 'table'; aligns: TableAlign[]; headers: string[]; rows: string[][] }
| { kind: 'hr' };
interface CodeCommentDirective {
title: string;
body: string;
file: string;
start?: number;
end?: number;
priority?: number;
}
function splitTableCells(line: string): string[] {
// Walk char-by-char so we can respect three GFM cell-content rules without
// any placeholder substitution:
@ -126,6 +139,12 @@ function parseBlocks(input: string): Block[] {
i++;
continue;
}
const codeComment = parseCodeCommentDirective(line);
if (codeComment) {
out.push({ kind: 'codeComment', comment: codeComment });
i++;
continue;
}
// Fenced code block.
const fence = /^```(\w[\w+-]*)?\s*$/.exec(line);
if (fence) {
@ -202,6 +221,7 @@ function parseBlocks(input: string): Block[] {
if (/^#{1,4}\s+/.test(next)) break;
if (/^\s*[-*+]\s+/.test(next)) break;
if (/^\s*\d+\.\s+/.test(next)) break;
if (parseCodeCommentDirective(next)) break;
if (isTableStartAt(lines, i)) break;
buf.push(next);
i++;
@ -239,11 +259,16 @@ function renderBlock(block: Block, key: number, options?: RenderMarkdownOptions)
}
if (block.kind === 'code') {
return (
<pre key={key} className="md-code">
<code data-lang={block.lang ?? undefined}>{block.body}</code>
</pre>
<MarkdownCodeBlock
key={key}
body={block.body}
lang={block.lang}
/>
);
}
if (block.kind === 'codeComment') {
return <CodeCommentBlock key={key} comment={block.comment} />;
}
if (block.kind === 'table') {
const { aligns, headers, rows } = block;
const cellStyle = (idx: number): { textAlign: 'left' | 'right' | 'center' } | undefined => {
@ -279,6 +304,116 @@ function renderBlock(block: Block, key: number, options?: RenderMarkdownOptions)
return null;
}
function parseCodeCommentDirective(line: string): CodeCommentDirective | null {
const match = /^\s*::code-comment\{([\s\S]*)\}\s*$/.exec(line);
if (!match) return null;
const attrs = parseDirectiveAttributes(match[1] ?? '');
const body = attrs.get('body')?.trim() ?? '';
const file = attrs.get('file')?.trim() ?? '';
if (!body || !file) return null;
const title = attrs.get('title')?.trim() || 'Code comment';
const start = parsePositiveInt(attrs.get('start'));
const end = parsePositiveInt(attrs.get('end'));
const priority = parsePositiveInt(attrs.get('priority'));
return {
title,
body,
file,
...(start === undefined ? {} : { start }),
...(end === undefined ? {} : { end }),
...(priority === undefined ? {} : { priority }),
};
}
function parseDirectiveAttributes(raw: string): Map<string, string> {
const attrs = new Map<string, string>();
const attrRe = /([A-Za-z_][\w-]*)\s*=\s*("([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|[^\s}]+)/g;
let match: RegExpExecArray | null;
while ((match = attrRe.exec(raw))) {
const key = match[1]!;
const quoted = match[3] ?? match[4];
const value = quoted ?? match[2] ?? '';
attrs.set(key, unescapeDirectiveValue(value.replace(/^['"]|['"]$/g, '')));
}
return attrs;
}
function unescapeDirectiveValue(value: string): string {
return value.replace(/\\(["'\\])/g, '$1');
}
function parsePositiveInt(value: string | undefined): number | undefined {
if (!value) return undefined;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
function CodeCommentBlock({ comment }: { comment: CodeCommentDirective }) {
const location = codeCommentLocation(comment);
return (
<article className="md-code-comment" data-priority={comment.priority ?? undefined}>
<div className="md-code-comment-head">
<span className="md-code-comment-icon" aria-hidden>!</span>
<strong>{renderInline(comment.title)}</strong>
{comment.priority ? (
<span className="md-code-comment-priority">P{comment.priority}</span>
) : null}
</div>
<p className="md-code-comment-body">{renderInline(comment.body)}</p>
<code className="md-code-comment-file">{location}</code>
</article>
);
}
function codeCommentLocation(comment: CodeCommentDirective): string {
if (!comment.start) return comment.file;
if (comment.end && comment.end !== comment.start) {
return `${comment.file}:${comment.start}-${comment.end}`;
}
return `${comment.file}:${comment.start}`;
}
function MarkdownCodeBlock({ body, lang }: { body: string; lang: string | null }) {
const t = useT();
const [copied, setCopied] = useState(false);
const resetTimerRef = useRef<number | null>(null);
const copyLabel = copied ? t('fileViewer.copied') : t('fileViewer.copy');
useEffect(() => () => {
if (resetTimerRef.current != null) window.clearTimeout(resetTimerRef.current);
}, []);
async function handleCopy() {
const ok = await copyToClipboard(body);
if (!ok) return;
setCopied(true);
if (resetTimerRef.current != null) window.clearTimeout(resetTimerRef.current);
resetTimerRef.current = window.setTimeout(() => {
setCopied(false);
resetTimerRef.current = null;
}, 1600);
}
return (
<div className="md-code-block">
<div className="md-code-actions">
<button
type="button"
className="md-code-action"
onClick={() => { void handleCopy(); }}
aria-label={copyLabel}
title={copyLabel}
>
{copyLabel}
</button>
</div>
<pre className="md-code">
<code data-lang={lang ?? undefined}>{body}</code>
</pre>
</div>
);
}
// Allowed schemes / forms for image `src` attributes. The BYOK chat
// tool loop emits relative URLs like `/api/byok-image/<id>.png` which
// the web's Next.js rewrites proxy to the daemon — that's the common

View file

@ -10,27 +10,48 @@ export interface TodoItem {
export function parseTodoWriteInput(input: unknown): TodoItem[] {
if (!input || typeof input !== 'object') return [];
const obj = input as { todos?: unknown };
if (!Array.isArray(obj.todos)) return [];
return obj.todos
const obj = input as { plan?: unknown; todos?: unknown };
const rawItems = Array.isArray(obj.todos)
? obj.todos
: Array.isArray(obj.plan)
? obj.plan
: [];
return rawItems
.map((todo): TodoItem | null => {
if (!todo || typeof todo !== 'object') return null;
const record = todo as Record<string, unknown>;
const content = typeof record.content === 'string' ? record.content : '';
const content =
typeof record.content === 'string'
? record.content
: typeof record.step === 'string'
? record.step
: '';
if (!content) return null;
const status =
record.status === 'completed' || record.status === 'in_progress' || record.status === 'stopped'
? record.status
: 'pending';
const status = normalizeTodoStatus(record.status);
return {
content,
status,
activeForm: typeof record.activeForm === 'string' ? record.activeForm : undefined,
activeForm:
typeof record.activeForm === 'string'
? record.activeForm
: typeof record.active_form === 'string'
? record.active_form
: undefined,
};
})
.filter((todo): todo is TodoItem => todo !== null);
}
function normalizeTodoStatus(status: unknown): TodoStatus {
if (status === 'completed' || status === 'in_progress' || status === 'stopped') {
return status;
}
if (status === 'cancelled' || status === 'canceled' || status === 'failed') {
return 'stopped';
}
return 'pending';
}
export function latestTodosFromEvents(events: AgentEvent[] | undefined): TodoItem[] {
if (!events) return [];
for (let i = events.length - 1; i >= 0; i -= 1) {
@ -93,8 +114,13 @@ export function latestTodoWriteInputForPinnedCard<
return null;
}
function isTodoWriteToolName(name: string): boolean {
return name === 'TodoWrite' || name === 'todowrite';
export function isTodoWriteToolName(name: string): boolean {
return (
name === 'TodoWrite' ||
name === 'todowrite' ||
name === 'todo_write' ||
name === 'update_plan'
);
}
function hasTerminalRunEnded(
@ -111,11 +137,13 @@ function hasTerminalRunEnded(
function stoppedTodoWriteInput(input: unknown): unknown {
if (!input || typeof input !== 'object') return input;
const obj = input as { todos?: unknown };
if (!Array.isArray(obj.todos)) return input;
const obj = input as { todos?: unknown; plan?: unknown };
const key = Array.isArray(obj.todos) ? 'todos' : Array.isArray(obj.plan) ? 'plan' : null;
if (!key) return input;
const items = obj[key] as unknown[];
return {
...(input as Record<string, unknown>),
todos: obj.todos.map((todo) => {
[key]: items.map((todo) => {
if (!todo || typeof todo !== 'object') return todo;
const record = todo as Record<string, unknown>;
if (record.status !== 'in_progress') return todo;

View file

@ -1,4 +1,7 @@
import { describe, expect, it } from 'vitest';
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
import { renderMarkdown } from '../../src/runtime/markdown';
@ -7,6 +10,30 @@ function html(input: string): string {
}
describe('renderMarkdown', () => {
let writeTextMock: ReturnType<typeof vi.fn>;
let originalClipboard: PropertyDescriptor | undefined;
beforeEach(() => {
originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: writeTextMock,
},
});
});
afterEach(() => {
if (originalClipboard) {
Object.defineProperty(navigator, 'clipboard', originalClipboard);
} else {
delete (navigator as { clipboard?: Clipboard }).clipboard;
}
cleanup();
vi.clearAllMocks();
});
it('autolinks bare https URLs without breaking on underscores in query params', () => {
// OAuth-style URL with underscores in `response_type`, `client_id`,
// `code_challenge`, `code_challenge_method`. The previous renderer
@ -102,6 +129,52 @@ describe('renderMarkdown', () => {
expect(out).toContain('<code class="md-inline-code">https://example.com/x</code>');
});
it('adds copy controls to fenced code blocks', () => {
const out = html('```tsx\nexport const ok = true;\n```');
expect(out).toContain('class="md-code-block"');
expect(out).toContain('class="md-code-actions"');
expect(out).toContain('class="md-code-action"');
expect(out).toContain('>Copy</button>');
expect(out).toContain('export const ok = true;');
});
it('copies fenced code block contents', async () => {
render(<>{renderMarkdown('```css\n.card { color: red; }\n```')}</>);
fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalledWith('.card { color: red; }');
});
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Copied!' })).toBeTruthy();
});
});
it('renders Codex code-comment directives as annotation cards', () => {
const out = html(
'Before\n' +
'::code-comment{title="[P2] Guard empty state" body="This should check the empty queue before reading the first task." file="/repo/apps/web/src/Chat.tsx" start=42 end=44 priority=2}\n' +
'After',
);
expect(out).toContain('Before');
expect(out).toContain('class="md-code-comment"');
expect(out).toContain('[P2] Guard empty state');
expect(out).toContain('This should check the empty queue before reading the first task.');
expect(out).toContain('/repo/apps/web/src/Chat.tsx:42-44');
expect(out).toContain('P2');
expect(out).toContain('After');
expect(out).not.toContain('::code-comment');
});
it('leaves malformed code-comment directives as text', () => {
const out = html('::code-comment{title="No file" body="Missing file"}');
expect(out).toContain('::code-comment');
expect(out).not.toContain('class="md-code-comment"');
});
it('renders a GFM pipe table with header, body, and alignment', () => {
const md = [
'| L | C | R |',

View file

@ -74,6 +74,53 @@ describe('todo event helpers', () => {
]);
});
it('normalizes Codex update_plan input as the current task queue', () => {
const events: AgentEvent[] = [
{
kind: 'tool_use',
id: 'plan-1',
name: 'update_plan',
input: {
plan: [
{ step: 'Inspect chat rendering', status: 'completed' },
{ step: 'Add annotation card', status: 'in_progress' },
{ step: 'Run focused tests', status: 'pending' },
],
},
},
];
expect(latestTodosFromEvents(events)).toEqual([
{ content: 'Inspect chat rendering', status: 'completed', activeForm: undefined },
{ content: 'Add annotation card', status: 'in_progress', activeForm: undefined },
{ content: 'Run focused tests', status: 'pending', activeForm: undefined },
]);
expect(unfinishedTodosFromEvents(events)).toEqual([
{ content: 'Add annotation card', status: 'in_progress', activeForm: undefined },
{ content: 'Run focused tests', status: 'pending', activeForm: undefined },
]);
});
it('recognizes snake_case todo_write events', () => {
const input = latestTodoWriteInputForPinnedCard([
{
runStatus: 'running',
events: [
{
kind: 'tool_use',
id: 'todo-1',
name: 'todo_write',
input: { todos: [{ content: 'Port task queue card', status: 'pending' }] },
},
],
},
]);
expect(parseTodoWriteInput(input)).toEqual([
{ content: 'Port task queue card', status: 'pending', activeForm: undefined },
]);
});
it('uses lowercase todowrite as the latest todo truth over older TodoWrite events', () => {
const events: AgentEvent[] = [
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
@ -176,4 +223,33 @@ describe('todo event helpers', () => {
{ content: 'Self-check', status: 'pending', activeForm: undefined },
]);
});
it('marks update_plan items as stopped when a terminal run ends with stale progress', () => {
const input = latestTodoWriteInputForPinnedCard([
{
runStatus: 'succeeded',
endedAt: 3_000,
events: [
{
kind: 'tool_use',
id: 'plan-1',
name: 'update_plan',
input: {
plan: [
{ step: 'Inspect chat rendering', status: 'completed' },
{ step: 'Add annotation card', status: 'in_progress' },
{ step: 'Run focused tests', status: 'pending' },
],
},
},
],
},
]);
expect(parseTodoWriteInput(input)).toEqual([
{ content: 'Inspect chat rendering', status: 'completed', activeForm: undefined },
{ content: 'Add annotation card', status: 'stopped', activeForm: undefined },
{ content: 'Run focused tests', status: 'pending', activeForm: undefined },
]);
});
});