mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
2c2900cfad
commit
dc2cb6b371
6 changed files with 343 additions and 29 deletions
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 |',
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue