feat(web-shell): collapsible TodoWrite history with status diff (#5109)

* feat(web-shell): collapsible TodoWrite history with status diff

Inline todo_write updates rendered as a generic, non-collapsible tool row
crammed in with surrounding tool calls — the todo-specific renderer was
effectively dead code because the detector matched the literal "todowrite"
while the wire name is "todo_write" (kind "think").

- Detect the todo tool by name (todo_write / todowrite) instead of relying
  on the unrelated tool kind, reviving the rich rendering and also
  populating the floating todo panel that was empty on the daemon path.
- Render each update as its own standalone group, collapsed by default to
  the per-snapshot diff (just-completed / just-started items) or the current
  step, expanding to the full checklist; the header shows completed/total.
- Use consistent status glyphs (●/◐/○) in both collapsed and expanded views
  via a shared TodoView component used by ToolGroup and PlanMessage.

* fix(web-shell): scope todo diff per task identity; address review

- [Critical] computeTodoTimeline keyed its running state on todo.id alone, but
  ids aren't globally unique (ACP assigns positional ids, models renumber per
  plan), so a later plan diffed against a previous plan's stale terminal status
  and silently dropped events in the collapsed view. Key on id+content so
  distinct tasks stay separate; add an id-reuse regression test.
- Stabilize the TodoTimelineContext value with a signature-cached Map so
  streaming ticks that don't touch a todo snapshot no longer re-render every
  todo/plan row.
- Drop the unused completed/total from TodoSnapshotDiff (consumers compute the
  count locally — single source of truth).
- Fall back to the raw result summary when a todo_write payload is unparseable.
- Add PlanMessage rendering tests and extractTodosFromToolCall coverage; remove
  stale "step time" comments left after dropping per-step timing.

* test(web-shell): document todo-diff keying limits; cover signature

Follow-up to review on the id+content keying in computeTodoTimeline:
- Document the two rare trade-offs in the todoStateKey doc (a mid-task reword on
  a stable id, and unrelated plans reusing both id and content), both degrading
  to "the collapsed diff omits one event" while the expanded list stays correct.
- Pin behavior with tests: an item carried over and completed in a later turn
  (which id+content handles but a user-turn reset would drop), plus the two
  documented gaps.
- Add todoTimelineSignature tests: stable across non-todo edits; changes on any
  id/status/content change.

* refactor(web-shell): isolate plan context read; expand todo test coverage

Address follow-up review:
- Extract PlanEventSummary as the sole TodoTimelineContext consumer, mirroring
  ToolGroup's TodoToolBody so the memo-shielded PlanMessage stays stable when
  the timeline Map reference changes.
- Note the spurious-`started` axis of the reword trade-off in the todoStateKey
  doc (a reword can drop a completion or emit a stray start).
- Add direct isTodoWriteToolName tests (incl. the `todowrite` ACP variant) and
  a todoTimelineSignature empty-transcript test.
This commit is contained in:
Shaojin Wen 2026-06-15 00:44:15 +08:00 committed by GitHub
parent 34f7f91282
commit 9f2168f78e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1137 additions and 291 deletions

View file

@ -120,7 +120,12 @@ import {
import { TASKS_STATUS_ACTIVE_EVENT } from './components/messages/TasksStatusMessage';
import { BtwMessage } from './components/messages/BtwMessage';
import type { ACPToolCall, Message, PermissionRequest } from './adapters/types';
import { getFloatingTodos } from './utils/todos';
import {
computeTodoTimeline,
getFloatingTodos,
todoTimelineSignature,
type TodoSnapshotDiff,
} from './utils/todos';
import { ThemeProvider } from './themeContext';
import {
WebShellThemeId,
@ -140,6 +145,16 @@ import styles from './App.module.css';
export const CompactModeContext = createContext(false);
/**
* Per-snapshot status diffs (keyed by tool callId or plan message id), so a
* history row can render what changed in that snapshot without re-deriving it
* from the whole transcript. Empty by default so a row rendered outside the
* provider still falls back gracefully.
*/
export const TodoTimelineContext = createContext<Map<string, TodoSnapshotDiff>>(
new Map(),
);
const MODES_CYCLE = DAEMON_APPROVAL_MODES;
const MAX_DISPLAYED_QUEUED_PROMPTS = 3;
const MAX_QUEUED_PROMPT_PREVIEW_CHARS = 240;
@ -672,6 +687,22 @@ export function App({
() => getFloatingTodos(messages),
[messages],
);
// Keep the timeline Map referentially stable across streaming ticks that
// don't touch any todo snapshot. The Map is a context value, so a fresh
// reference would re-render every todo/plan row regardless of memoization;
// only rebuild when the todo snapshots themselves change.
const todoTimelineRef = useRef<{
signature: string;
timeline: Map<string, TodoSnapshotDiff>;
} | null>(null);
const todoTimeline = useMemo(() => {
const signature = todoTimelineSignature(messages);
const cached = todoTimelineRef.current;
if (cached && cached.signature === signature) return cached.timeline;
const timeline = computeTodoTimeline(messages);
todoTimelineRef.current = { signature, timeline };
return timeline;
}, [messages]);
const floatingTodos = useStableArray(
floatingTodosState.todos,
(t) => `${t.id}:${t.status}:${t.content}`,
@ -2552,138 +2583,140 @@ export function App({
<WebShellCustomizationProvider value={customization}>
<CompactModeContext.Provider value={compactMode}>
<div
className={
showFloatingTodos
? `${styles.content} ${styles.contentHasMessages}`
: styles.content
}
style={dialogOpen ? { visibility: 'hidden' } : undefined}
>
<MessageList
ref={messageListRef}
messages={displayMessages}
pendingApproval={pendingApproval}
onConfirm={handleConfirm}
onShowContextDetail={handleShowContextDetail}
catchingUp={connection.catchingUp}
workspaceCwd={connection.workspaceCwd || ''}
shellOutputMaxLines={shellOutputMaxLines}
showRetryHint={showRetryHint}
onRetryClick={handleRetry}
welcomeHeader={welcomeHeader}
tailContent={
agentsInlineMode ||
memoryInlineOpen ||
modelInlineMode ||
authInlineOpen ||
approvalModeInlineOpen ||
settingsInlineOpen ? (
<>
{authInlineOpen && (
<AuthMessage
onMessage={(text, type = 'status') => {
store.dispatch([
type === 'error'
? { type: 'error', text }
: { type: 'status', text },
]);
}}
onClose={() => setAuthInlineOpen(false)}
/>
)}
{approvalModeInlineOpen && (
<ApprovalModeMessage
currentMode={currentMode}
onSelect={handleSetMode}
onClose={() => setApprovalModeInlineOpen(false)}
/>
)}
{modelInlineMode && (
<ModelMessage
mode={modelInlineMode}
onSelect={
modelInlineMode === 'fast'
? handleFastModelSelect
: handleModelSelect
}
onClose={() => setModelInlineMode(null)}
/>
)}
{agentsInlineMode && (
<AgentsMessage
mode={agentsInlineMode}
onMessage={(text) =>
store.dispatch([{ type: 'status', text }])
}
onClose={() => setAgentsInlineMode(null)}
/>
)}
{memoryInlineOpen && (
<MemoryMessage
refreshSignal={memoryRefreshSignal}
addSignal={memoryAddSignal}
addScope={memoryAddScope}
portalHost={memoryPortalHost}
onMessage={(text, type = 'status') => {
store.dispatch([{ type, text }]);
}}
onClose={() => setMemoryInlineOpen(false)}
/>
)}
{settingsInlineOpen && (
<SettingsMessage
settingsState={workspaceSettingsState}
onClose={() => setSettingsInlineOpen(false)}
onLanguageChange={handleSettingsLanguageChange}
onThemeChange={handleThemeChange}
onSubDialog={(key) => {
setSettingsInlineOpen(false);
if (key === 'fastModel')
setModelInlineMode('fast');
else if (key === 'tools.approvalMode')
setApprovalModeInlineOpen(true);
}}
/>
)}
</>
) : undefined
<TodoTimelineContext.Provider value={todoTimeline}>
<div
className={
showFloatingTodos
? `${styles.content} ${styles.contentHasMessages}`
: styles.content
}
tailKey={
agentsInlineMode ||
memoryInlineOpen ||
modelInlineMode ||
authInlineOpen ||
approvalModeInlineOpen ||
settingsInlineOpen
? `inline-${authInlineOpen ? 'auth' : 'none'}-${modelInlineMode ?? 'none'}-${agentsInlineMode ?? 'none'}-${memoryInlineOpen ? 'memory' : 'none'}-${approvalModeInlineOpen ? 'approval' : 'none'}-${settingsInlineOpen ? 'settings' : 'none'}`
: undefined
}
// The approval-mode/model pickers and the settings panel are
// reachable by mouse from the status bar, so they reveal
// themselves when opened while the user is scrolled up; the
// agents/memory panels keep the user's scroll position.
autoScrollTailIntoView={
approvalModeInlineOpen ||
modelInlineMode !== null ||
settingsInlineOpen
}
virtualScrollThreshold={virtualScrollThreshold}
/>
style={dialogOpen ? { visibility: 'hidden' } : undefined}
>
<MessageList
ref={messageListRef}
messages={displayMessages}
pendingApproval={pendingApproval}
onConfirm={handleConfirm}
onShowContextDetail={handleShowContextDetail}
catchingUp={connection.catchingUp}
workspaceCwd={connection.workspaceCwd || ''}
shellOutputMaxLines={shellOutputMaxLines}
showRetryHint={showRetryHint}
onRetryClick={handleRetry}
welcomeHeader={welcomeHeader}
tailContent={
agentsInlineMode ||
memoryInlineOpen ||
modelInlineMode ||
authInlineOpen ||
approvalModeInlineOpen ||
settingsInlineOpen ? (
<>
{authInlineOpen && (
<AuthMessage
onMessage={(text, type = 'status') => {
store.dispatch([
type === 'error'
? { type: 'error', text }
: { type: 'status', text },
]);
}}
onClose={() => setAuthInlineOpen(false)}
/>
)}
{approvalModeInlineOpen && (
<ApprovalModeMessage
currentMode={currentMode}
onSelect={handleSetMode}
onClose={() => setApprovalModeInlineOpen(false)}
/>
)}
{modelInlineMode && (
<ModelMessage
mode={modelInlineMode}
onSelect={
modelInlineMode === 'fast'
? handleFastModelSelect
: handleModelSelect
}
onClose={() => setModelInlineMode(null)}
/>
)}
{agentsInlineMode && (
<AgentsMessage
mode={agentsInlineMode}
onMessage={(text) =>
store.dispatch([{ type: 'status', text }])
}
onClose={() => setAgentsInlineMode(null)}
/>
)}
{memoryInlineOpen && (
<MemoryMessage
refreshSignal={memoryRefreshSignal}
addSignal={memoryAddSignal}
addScope={memoryAddScope}
portalHost={memoryPortalHost}
onMessage={(text, type = 'status') => {
store.dispatch([{ type, text }]);
}}
onClose={() => setMemoryInlineOpen(false)}
/>
)}
{settingsInlineOpen && (
<SettingsMessage
settingsState={workspaceSettingsState}
onClose={() => setSettingsInlineOpen(false)}
onLanguageChange={handleSettingsLanguageChange}
onThemeChange={handleThemeChange}
onSubDialog={(key) => {
setSettingsInlineOpen(false);
if (key === 'fastModel')
setModelInlineMode('fast');
else if (key === 'tools.approvalMode')
setApprovalModeInlineOpen(true);
}}
/>
)}
</>
) : undefined
}
tailKey={
agentsInlineMode ||
memoryInlineOpen ||
modelInlineMode ||
authInlineOpen ||
approvalModeInlineOpen ||
settingsInlineOpen
? `inline-${authInlineOpen ? 'auth' : 'none'}-${modelInlineMode ?? 'none'}-${agentsInlineMode ?? 'none'}-${memoryInlineOpen ? 'memory' : 'none'}-${approvalModeInlineOpen ? 'approval' : 'none'}-${settingsInlineOpen ? 'settings' : 'none'}`
: undefined
}
// The approval-mode/model pickers and the settings panel are
// reachable by mouse from the status bar, so they reveal
// themselves when opened while the user is scrolled up; the
// agents/memory panels keep the user's scroll position.
autoScrollTailIntoView={
approvalModeInlineOpen ||
modelInlineMode !== null ||
settingsInlineOpen
}
virtualScrollThreshold={virtualScrollThreshold}
/>
{btwMessage?.role === 'btw' && (
<div className={styles.btwPanel}>
<BtwMessage
question={btwMessage.question}
answer={btwMessage.answer}
isPending={btwMessage.isPending}
/>
</div>
)}
{btwMessage?.role === 'btw' && (
<div className={styles.btwPanel}>
<BtwMessage
question={btwMessage.question}
answer={btwMessage.answer}
isPending={btwMessage.isPending}
/>
</div>
)}
<StreamingStatus />
</div>
<div ref={setMemoryPortalHost} data-web-shell-overlay-root />
<StreamingStatus />
</div>
<div ref={setMemoryPortalHost} data-web-shell-overlay-root />
</TodoTimelineContext.Provider>
</CompactModeContext.Provider>
</WebShellCustomizationProvider>

View file

@ -229,8 +229,9 @@ describe('transcriptBlocksToDaemonMessages', () => {
}),
]);
// Adjacent tool blocks share one tool_group (Native CLI batch parity),
// but each TodoWrite call keeps its own tool entry and todo payload.
// Each TodoWrite update stands alone in its own group (it renders as a
// self-contained collapsible checklist), rather than merging with adjacent
// tool calls.
expect(messages).toEqual([
{
id: 'tg-todo-1',
@ -241,6 +242,13 @@ describe('transcriptBlocksToDaemonMessages', () => {
callId: 'todo-call-1',
toolName: 'TodoWrite',
}),
],
},
{
id: 'tg-todo-2',
role: 'tool_group',
timestamp: 2,
tools: [
expect.objectContaining({
callId: 'todo-call-2',
toolName: 'TodoWrite',
@ -332,6 +340,24 @@ describe('transcriptBlocksToDaemonMessages', () => {
]);
});
it('never merges todo_write updates into or after a regular tool_group', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'completed', 1, { toolName: 'Read' }),
toolBlock('todo-1', 'todo-call-1', 'completed', 2, {
toolName: 'todo_write',
toolKind: 'think',
rawInput: { todos: [{ id: '1', content: 'A', status: 'in_progress' }] },
}),
toolBlock('t2', 'tc2', 'completed', 3, { toolName: 'Edit' }),
]);
expect(messages).toMatchObject([
{ role: 'tool_group', tools: [{ callId: 'tc1' }] },
{ role: 'tool_group', tools: [{ callId: 'todo-call-1' }] },
{ role: 'tool_group', tools: [{ callId: 'tc2' }] },
]);
});
it('does not merge real tool calls into synthetic raw-shell groups', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('u1', 'user', 'run it', 1),

View file

@ -21,6 +21,7 @@ import type {
DaemonMessageTodoItem,
DaemonUserMessage,
} from './messageTypes.js';
import { isTodoWriteToolName } from '../utils/todos.js';
interface PermissionToolInfo {
title?: string;
@ -509,13 +510,18 @@ function appendToolCallMessage(
//
// Synthetic raw-shell groups (pushed by the `shell` block fallback) use the
// bare block id without the `tg-` prefix and never absorb real tool calls.
// Sub-agent calls and todo_write updates each stand alone in their own group
// box instead of being crammed in with the tools around them: an agent renders
// an expandable panel, and a todo update is its own collapsible checklist.
const isStandalone = (t: DaemonMessageToolCall) =>
isSubAgentToolCall(t) || isTodoWriteToolName(t.toolName);
const last = messages[messages.length - 1];
if (
last &&
last.role === 'tool_group' &&
last.id.startsWith('tg-') &&
!isSubAgentToolCall(toolCall) &&
!last.tools.some(isSubAgentToolCall)
!isStandalone(toolCall) &&
!last.tools.some(isStandalone)
) {
last.tools.push(toolCall);
return;

View file

@ -69,7 +69,7 @@ export const MessageItem = memo(function MessageItem({
/>
);
case 'plan':
return <PlanMessage todos={message.todos} />;
return <PlanMessage id={message.id} todos={message.todos} />;
case 'system':
return (
<SystemMessage

View file

@ -3,54 +3,38 @@
padding: 2px 0;
}
.title {
color: var(--text-primary);
font-weight: 600;
margin-bottom: 4px;
}
.list {
display: flex;
flex-direction: column;
gap: 2px;
}
.item {
.header {
display: flex;
align-items: baseline;
gap: 6px;
font-size: 13px;
line-height: 1.5;
width: 100%;
padding: 0;
margin-bottom: 4px;
background: none;
border: none;
cursor: pointer;
text-align: left;
font: inherit;
}
.num {
.chevron {
flex-shrink: 0;
text-align: right;
width: 12px;
color: var(--text-dimmed);
font-variant-numeric: tabular-nums;
font-size: 10px;
}
.marker {
width: 14px;
flex-shrink: 0;
color: var(--text-dimmed);
.title {
color: var(--text-primary);
font-weight: 600;
}
.content {
color: var(--text-secondary);
}
.inProgress .marker,
.inProgress .content {
.header:hover .title {
color: var(--accent-color);
}
.completed .marker {
color: var(--success-color);
}
.completed .content {
.progress {
color: var(--text-dimmed);
text-decoration: line-through;
text-decoration-color: var(--text-dimmed);
font-size: 12px;
font-variant-numeric: tabular-nums;
}

View file

@ -0,0 +1,134 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../../i18n';
import type { TodoItem } from '../../adapters/types';
// PlanMessage imports App only for TodoTimelineContext; mock it so the unit
// test doesn't pull the whole application graph.
vi.mock('../../App', async () => {
const { createContext } = await import('react');
return { TodoTimelineContext: createContext(new Map()) };
});
const { PlanMessage } = await import('./PlanMessage');
const { TodoTimelineContext } = await import('../../App');
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
});
function todo(
id: string,
content: string,
status: TodoItem['status'],
): TodoItem {
return { id, content, status };
}
function renderPlan(
id: string,
todos: TodoItem[],
timeline?: Map<string, unknown>,
): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<TodoTimelineContext.Provider value={timeline ?? new Map()}>
<PlanMessage id={id} todos={todos} />
</TodoTimelineContext.Provider>
</I18nProvider>,
);
});
mounted.push({ root, container });
return container;
}
function click(el: Element): void {
act(() => {
el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
}
const TODOS = [
todo('1', 'First task', 'completed'),
todo('2', 'Second task', 'in_progress'),
todo('3', 'Third task', 'pending'),
];
describe('PlanMessage', () => {
it('collapses to the current step with a progress count', () => {
const container = renderPlan('p1', TODOS);
expect(container.textContent).toContain('1/3');
expect(container.textContent).toContain('Second task');
expect(container.textContent).not.toContain('Third task');
expect(container.textContent).toContain('▸');
});
it('expands to the full list on click', () => {
const container = renderPlan('p1', TODOS);
const chevron = [...container.querySelectorAll('span')].find(
(s) => s.textContent === '▸',
);
click(chevron!.parentElement!);
expect(container.textContent).toContain('First task');
expect(container.textContent).toContain('Second task');
expect(container.textContent).toContain('Third task');
expect(container.textContent).toContain('▾');
});
it('shows the plan-keyed diff when a timeline is present', () => {
const timeline = new Map<string, unknown>([
[
'p1',
{
events: [
{ kind: 'completed', id: '1', content: 'First task' },
{ kind: 'started', id: '2', content: 'Second task' },
],
},
],
]);
const container = renderPlan('p1', TODOS, timeline);
expect(container.textContent).toContain('First task');
expect(container.textContent).toContain('Second task');
expect(container.textContent).not.toContain('Third task');
});
it('shows an all-done summary when every item is completed', () => {
const container = renderPlan('p1', [
todo('1', 'First task', 'completed'),
todo('2', 'Second task', 'completed'),
]);
expect(container.textContent).toContain('2/2');
expect(container.textContent).toContain('All tasks completed');
});
it('falls back to the first pending item when nothing is in progress', () => {
const container = renderPlan('p1', [
todo('1', 'First task', 'pending'),
todo('2', 'Second task', 'pending'),
]);
expect(container.textContent).toContain('0/2');
expect(container.textContent).toContain('First task');
expect(container.textContent).not.toContain('Second task');
});
it('renders nothing for an empty plan', () => {
const container = renderPlan('p1', []);
expect(container.textContent).toBe('');
});
});

View file

@ -1,53 +1,57 @@
import { memo } from 'react';
import { memo, useContext, useState } from 'react';
import type { TodoItem } from '../../adapters/types';
import { getTodoStatusIcon } from '../../utils/todos';
import { TodoTimelineContext } from '../../App';
import { TodoEventSummary, TodoFullList } from './TodoView';
import { useI18n } from '../../i18n';
import styles from './PlanMessage.module.css';
interface PlanMessageProps {
id: string;
todos: TodoItem[];
}
function getStatusClass(status: TodoItem['status']): string {
switch (status) {
case 'completed':
return styles.completed;
case 'in_progress':
return styles.inProgress;
case 'pending':
return '';
}
// Isolating the context read here (mirroring ToolGroup's TodoToolBody) keeps the
// memo-shielded PlanMessage from re-rendering when the timeline Map reference
// changes — only this small summary does.
function PlanEventSummary({ id, todos }: PlanMessageProps) {
const timeline = useContext(TodoTimelineContext);
const events = timeline.get(id)?.events ?? [];
return <TodoEventSummary todos={todos} events={events} />;
}
export const PlanMessage = memo(function PlanMessage({
id,
todos,
}: PlanMessageProps) {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
if (todos.length === 0) return null;
// Size the number column to the widest index so the status markers stay
// aligned once the list grows past 9 items.
const numColumnWidth = `${String(todos.length).length + 1}ch`;
const total = todos.length;
const completed = todos.filter((td) => td.status === 'completed').length;
return (
<div className={styles.message}>
<div className={styles.title}>{t('plan.title')}</div>
<div className={styles.list}>
{todos.map((todo, index) => (
<div
key={todo.id || index}
className={`${styles.item} ${getStatusClass(todo.status)}`}
>
<span className={styles.num} style={{ minWidth: numColumnWidth }}>
{index + 1}.
</span>
<span className={styles.marker}>
{getTodoStatusIcon(todo.status)}
</span>
<span className={styles.content}>{todo.content}</span>
</div>
))}
</div>
<button
type="button"
className={styles.header}
onClick={() => setExpanded((value) => !value)}
aria-expanded={expanded}
title={expanded ? t('todo.collapse') : t('todo.expand')}
>
<span className={styles.chevron} aria-hidden="true">
{expanded ? '▾' : '▸'}
</span>
<span className={styles.title}>{t('plan.title')}</span>
<span className={styles.progress}>
{completed}/{total}
</span>
</button>
{expanded ? (
<TodoFullList todos={todos} numbered />
) : (
<PlanEventSummary id={id} todos={todos} />
)}
</div>
);
});

View file

@ -0,0 +1,59 @@
.list,
.summary {
display: flex;
flex-direction: column;
gap: 2px;
}
.row {
display: flex;
align-items: baseline;
gap: 6px;
font-size: 13px;
line-height: 1.5;
}
.num {
flex-shrink: 0;
text-align: right;
color: var(--text-dimmed);
font-variant-numeric: tabular-nums;
}
.icon {
width: 14px;
flex-shrink: 0;
color: var(--text-dimmed);
}
.text {
color: var(--text-secondary);
}
/* The collapsed summary keeps each row to one line so a long task title never
wraps; the expanded list stays free to wrap and show the full text. */
.summary .text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inProgress .icon,
.inProgress .text {
color: var(--accent-color);
}
.completed .icon {
color: var(--success-color);
}
/* Strike-through belongs to the persistent expanded list, where a completed
item lingers as crossed-off. A summary row is an event ("just completed")
that reads as an accomplishment, so it stays in normal text. */
.list .completed .text {
color: var(--text-dimmed);
text-decoration: line-through;
text-decoration-color: var(--text-dimmed);
}

View file

@ -0,0 +1,115 @@
import type { TodoItem } from '../../adapters/types';
import { getTodoStatusIcon, type TodoEvent } from '../../utils/todos';
import { useI18n } from '../../i18n';
import styles from './TodoView.module.css';
function statusClass(status: TodoItem['status']): string {
switch (status) {
case 'completed':
return styles.completed;
case 'in_progress':
return styles.inProgress;
case 'pending':
return '';
}
}
/**
* Collapsed view: the change a single snapshot introduced items that just
* completed and items that just started. With no tracked change (an unchanged
* re-emit, or a snapshot rendered without a timeline) it falls back to the
* current focus item so the row is never empty.
*/
export function TodoEventSummary({
todos,
events,
}: {
todos: TodoItem[];
events: readonly TodoEvent[];
}) {
const { t } = useI18n();
if (events.length === 0) {
const allCompleted =
todos.length > 0 && todos.every((td) => td.status === 'completed');
if (allCompleted) {
return (
<div className={styles.summary}>
<div className={`${styles.row} ${styles.completed}`}>
<span className={styles.icon} aria-hidden="true">
</span>
<span className={styles.text}>{t('todo.allDone')}</span>
</div>
</div>
);
}
const current =
todos.find((td) => td.status === 'in_progress') ??
todos.find((td) => td.status === 'pending');
if (!current) return null;
return (
<div className={styles.summary}>
<div className={`${styles.row} ${statusClass(current.status)}`}>
<span className={styles.icon} aria-hidden="true">
{getTodoStatusIcon(current.status)}
</span>
<span className={styles.text}>{current.content}</span>
</div>
</div>
);
}
return (
<div className={styles.summary}>
{events.map((event) => (
<div
key={`${event.kind}-${event.id}`}
className={`${styles.row} ${
event.kind === 'completed' ? styles.completed : styles.inProgress
}`}
>
<span className={styles.icon} aria-hidden="true">
{getTodoStatusIcon(
event.kind === 'completed' ? 'completed' : 'in_progress',
)}
</span>
<span className={styles.text}>{event.content}</span>
</div>
))}
</div>
);
}
/** Expanded view: the full list. `numbered` adds the 1. 2. 3. index column. */
export function TodoFullList({
todos,
numbered = false,
}: {
todos: TodoItem[];
numbered?: boolean;
}) {
// Size the number column to the widest index so the markers stay aligned once
// the list grows past 9 items.
const numColumnWidth = `${String(todos.length).length + 1}ch`;
return (
<div className={styles.list}>
{todos.map((todo, index) => (
<div
key={todo.id || index}
className={`${styles.row} ${statusClass(todo.status)}`}
>
{numbered && (
<span className={styles.num} style={{ minWidth: numColumnWidth }}>
{index + 1}.
</span>
)}
<span className={styles.icon} aria-hidden="true">
{getTodoStatusIcon(todo.status)}
</span>
<span className={styles.text}>{todo.content}</span>
</div>
))}
</div>
);
}

View file

@ -5,14 +5,19 @@ import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../../i18n';
import type { ACPToolCall } from '../../adapters/types';
// ToolGroup imports App only for CompactModeContext; loading the real App
// module would pull the whole application graph into this unit test.
// ToolGroup imports App only for CompactModeContext and TodoTimelineContext;
// loading the real App module would pull the whole application graph into this
// unit test.
vi.mock('../../App', async () => {
const { createContext } = await import('react');
return { CompactModeContext: createContext(false) };
return {
CompactModeContext: createContext(false),
TodoTimelineContext: createContext(new Map()),
};
});
const { ToolGroup } = await import('./ToolGroup');
const { TodoTimelineContext } = await import('../../App');
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
@ -321,7 +326,105 @@ describe('auto-collapse on finish', () => {
render('completed');
expect(container.textContent).toContain('SubToolMarker');
});
});
function makeTodoTool(): ACPToolCall {
return {
callId: 'call-todo-1',
toolName: 'todo_write',
status: 'completed',
kind: 'think',
args: {
todos: [
{ id: '1', content: 'First task', status: 'completed' },
{ id: '2', content: 'Second task', status: 'in_progress' },
{ id: '3', content: 'Third task', status: 'pending' },
],
},
};
}
function renderTodoTool(
tool: ACPToolCall,
timeline?: Map<string, unknown>,
): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<TodoTimelineContext.Provider value={timeline ?? new Map()}>
<ToolGroup tools={[tool]} />
</TodoTimelineContext.Provider>
</I18nProvider>,
);
});
mounted.push({ root, container });
return container;
}
describe('todo_write tool rendering', () => {
it('detects the todo_write wire name and collapses to the current step', () => {
const container = renderTodoTool(makeTodoTool());
// Collapsed by default: only the current (in_progress) step and the
// progress count show; other items stay hidden until expanded.
expect(container.textContent).toContain('1/3');
expect(container.textContent).toContain('Second task');
expect(container.textContent).not.toContain('Third task');
expect(container.textContent).toContain('▸');
});
it('expands to the full list on click', () => {
const container = renderTodoTool(makeTodoTool());
const chevron = [...container.querySelectorAll('span')].find(
(s) => s.textContent === '▸',
);
click(chevron!.parentElement!);
expect(container.textContent).toContain('First task');
expect(container.textContent).toContain('Second task');
expect(container.textContent).toContain('Third task');
expect(container.textContent).toContain('▾');
});
it('shows the snapshot diff when a timeline is present', () => {
const timeline = new Map<string, unknown>([
[
'call-todo-1',
{
events: [
{ kind: 'completed', id: '1', content: 'First task' },
{ kind: 'started', id: '2', content: 'Second task' },
],
},
],
]);
const container = renderTodoTool(makeTodoTool(), timeline);
// The collapsed diff: just-completed item (●), just-started item (◐), and
// pending items still hidden — same status glyphs as the expanded list.
expect(container.textContent).toContain('●');
expect(container.textContent).toContain('First task');
expect(container.textContent).toContain('◐');
expect(container.textContent).toContain('Second task');
expect(container.textContent).not.toContain('Third task');
});
it('falls back to the result summary when the todo payload is unparseable', () => {
// Malformed args (todos is a string) → no list to render; the row must not
// be blank — it shows the raw result summary instead.
const container = renderTodoTool({
callId: 'call-todo-bad',
toolName: 'todo_write',
status: 'completed',
kind: 'think',
args: { todos: 'oops not an array' },
rawOutput: { output: 'Todos updated summary line' },
});
expect(container.textContent).toContain('Todos updated summary line');
});
});
describe('user-expanded tool persistence', () => {
it('keeps a tool the user manually expanded open when it completes', () => {
const container = document.createElement('div');
document.body.appendChild(container);

View file

@ -13,7 +13,11 @@ import { SubAgentPanel } from './tools/SubAgentPanel';
import { DiffView } from './tools/DiffView';
import { ToolApproval } from './ToolApproval';
import { parseAnsi, hasAnsi } from '../../utils/ansi';
import { extractTodosFromToolCall, getTodoStatusIcon } from '../../utils/todos';
import {
extractTodosFromToolCall,
isTodoWriteToolName,
} from '../../utils/todos';
import { TodoEventSummary, TodoFullList } from './TodoView';
import {
formatDurationMs,
formatElapsed,
@ -37,7 +41,7 @@ import {
toolContainsCallId,
} from './toolFormatting';
import { useI18n } from '../../i18n';
import { CompactModeContext } from '../../App';
import { CompactModeContext, TodoTimelineContext } from '../../App';
import {
type ToolHeaderExtraRenderInfo,
type ToolHeaderKind,
@ -333,58 +337,33 @@ function getWriteContent(tool: ACPToolCall): string {
return '';
}
function TodoWriteContent({ tool }: { tool: ACPToolCall }) {
const todos = extractTodosFromToolCall(tool);
if (todos) {
return (
<div className={styles.todoList}>
{todos.map((todo, i) => (
<div
key={todo.id || i}
className={`${styles.todoItem} ${getTodoClass(todo.status)}`}
>
{getTodoStatusIcon(todo.status)} {todo.content}
</div>
))}
</div>
);
}
const text = extractText(tool) || '';
const lines = text.split('\n').filter((l) => l.trim());
if (lines.length === 0) return null;
// Collapsed by default: the diff of this todo_write call (just-completed and
// just-started items), expanding to the full list on click. The per-snapshot
// diff comes from the timeline context, so this is isolated in its own
// component — only todo rows subscribe and re-render when the timeline changes,
// not every tool row.
function TodoToolBody({
tool,
todos,
expanded,
}: {
tool: ACPToolCall;
todos: TodoItem[];
expanded: boolean;
}) {
const timeline = useContext(TodoTimelineContext);
const events = timeline.get(tool.callId)?.events ?? [];
return (
<div className={styles.todoList}>
{lines.map((line, i) => {
const isCompleted = line.startsWith('●');
const isInProgress = line.startsWith('◐');
const cls = isCompleted
? styles.todoDone
: isInProgress
? styles.todoActive
: styles.todoPending;
return (
<div key={i} className={`${styles.todoItem} ${cls}`}>
{line}
</div>
);
})}
<div className={styles.todoBody}>
{expanded ? (
<TodoFullList todos={todos} />
) : (
<TodoEventSummary todos={todos} events={events} />
)}
</div>
);
}
function getTodoClass(status: TodoItem['status']): string {
switch (status) {
case 'completed':
return styles.todoDone;
case 'in_progress':
return styles.todoActive;
case 'pending':
return styles.todoPending;
}
}
interface ToolLineProps {
tool: ACPToolCall;
approval?: PermissionRequest | null;
@ -478,7 +457,7 @@ function getToolHeaderKind(tool: ACPToolCall): ToolHeaderKind {
if (isSubAgentToolCall(tool)) return 'agent';
if (isShellToolName(name)) return 'shell';
if (isWebFetchToolName(name)) return 'fetch';
if (name === 'todowrite') return 'todo';
if (isTodoWriteToolName(name)) return 'todo';
if (name === 'read' || name === 'read_file' || name === 'readfile')
return 'read';
if (name === 'edit' || name === 'editfile') return 'edit';
@ -800,13 +779,20 @@ export const ToolLine = memo(function ToolLine({
: formatElapsed(tool.startTime, tool.endTime);
const name = tool.toolName.toLowerCase();
const isTodo = name === 'todowrite';
// A row expands when it has detail output (bash/diff/read content) or when
// its description is long enough to be ellipsised. When a long description is
// expanded we move it out of the header into a wrapped block below, so the
// header drops its single-line copy.
const isTodo = isTodoWriteToolName(name);
const todoItems = isTodo ? extractTodosFromToolCall(tool) : undefined;
const hasTodoList = !!todoItems && todoItems.length > 0;
const todoCompleted = todoItems
? todoItems.filter((td) => td.status === 'completed').length
: 0;
// A row expands when it has a todo list to reveal, detail output
// (bash/diff/read content), or a description long enough to be ellipsised.
// When a long description is expanded we move it out of the header into a
// wrapped block below, so the header drops its single-line copy.
const descExpandable = !isTodo && isDescriptionExpandable(description);
const expandable = !isTodo && (hasExpandableContent(tool) || descExpandable);
const expandable = isTodo
? hasTodoList
: hasExpandableContent(tool) || descExpandable;
const relocateDescription = expanded && descExpandable;
// Whether the expanded row renders a kind-specific detail view. When it does
// not (e.g. grep/glob/web_fetch with a long description), keep the result
@ -837,18 +823,33 @@ export const ToolLine = memo(function ToolLine({
<ToggleChevron expandable={expandable} expanded={expanded} />
<StatusIcon status={tool.status} />
<span className={styles.lineName}>{displayName}</span>
{isTodo && hasTodoList && (
<span className={styles.todoProgress}>
{todoCompleted}/{todoItems!.length}
</span>
)}
<ToolHeaderExtra
info={{
kind: getToolHeaderKind(tool),
tool,
displayName,
description: relocateDescription ? '' : description,
elapsed,
// A todo row carries its checklist in the body below; a redundant
// "Update Todos" description and the instant write duration would
// only clutter the header next to the progress count.
description: isTodo || relocateDescription ? '' : description,
elapsed: isTodo ? '' : elapsed,
workspaceCwd,
}}
/>
</div>
{isTodo && <TodoWriteContent tool={tool} />}
{isTodo && hasTodoList && (
<TodoToolBody tool={tool} todos={todoItems!} expanded={expanded} />
)}
{/* Todo tool whose payload couldn't be parsed (e.g. malformed args):
fall back to the raw result summary so the row isn't blank. */}
{isTodo && !hasTodoList && result && (
<div className={styles.lineOutput}>{result}</div>
)}
{relocateDescription && (
<div className={styles.lineFullArg}>{description}</div>
)}

View file

@ -191,30 +191,15 @@
background: var(--info-bg);
}
.todoList {
padding: 6px 0 4px 24px;
display: flex;
flex-direction: column;
gap: 2px;
}
.todoItem {
font-size: 13px;
font-family: var(--font-mono);
line-height: 1.6;
padding-left: 4px;
}
.todoDone {
color: var(--text-primary);
}
.todoActive {
font-weight: 600;
}
.todoPending {
.todoProgress {
color: var(--text-dimmed);
font-size: 12px;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.todoBody {
padding: 4px 0 2px 24px;
}
.compactGroup {

View file

@ -1,11 +1,27 @@
import { describe, expect, it } from 'vitest';
import type { Message, TodoItem } from '../adapters/types';
import { getFloatingTodos, getTodoStatusIcon, getTodoWindow } from './todos';
import type { ACPToolCall, Message, TodoItem } from '../adapters/types';
import {
computeTodoTimeline,
extractTodosFromToolCall,
getFloatingTodos,
getTodoStatusIcon,
getTodoWindow,
isTodoWriteToolName,
todoTimelineSignature,
} from './todos';
function todo(id: string, status: TodoItem['status']): TodoItem {
return { id, content: `task ${id}`, status };
}
function item(
id: string,
content: string,
status: TodoItem['status'],
): TodoItem {
return { id, content, status };
}
function planMessage(id: string, todos: TodoItem[]): Message {
return { id, role: 'plan', todos };
}
@ -17,8 +33,9 @@ function todoWriteMessage(id: string, todos: TodoItem[]): Message {
tools: [
{
callId: `call-${id}`,
toolName: 'TodoWrite',
toolName: 'todo_write',
status: 'completed',
kind: 'think',
args: { todos },
},
],
@ -129,6 +146,249 @@ describe('getFloatingTodos', () => {
});
});
describe('computeTodoTimeline', () => {
it('emits started and completed events across snapshots', () => {
const timeline = computeTodoTimeline([
planMessage('p1', [todo('1', 'in_progress'), todo('2', 'pending')]),
planMessage('p2', [todo('1', 'completed'), todo('2', 'in_progress')]),
]);
expect(timeline.get('p1')).toEqual({
events: [{ kind: 'started', id: '1', content: 'task 1' }],
});
expect(timeline.get('p2')).toEqual({
events: [
{ kind: 'completed', id: '1', content: 'task 1' },
{ kind: 'started', id: '2', content: 'task 2' },
],
});
});
it('emits a completed event when an item skips in_progress', () => {
const timeline = computeTodoTimeline([
planMessage('p1', [todo('1', 'pending')]),
planMessage('p2', [todo('1', 'completed')]),
]);
expect(timeline.get('p1')?.events).toEqual([]);
expect(timeline.get('p2')?.events).toEqual([
{ kind: 'completed', id: '1', content: 'task 1' },
]);
});
it('does not replay completions for items first seen already completed', () => {
const timeline = computeTodoTimeline([
planMessage('p1', [todo('1', 'completed'), todo('2', 'in_progress')]),
]);
expect(timeline.get('p1')).toEqual({
events: [{ kind: 'started', id: '2', content: 'task 2' }],
});
});
it('produces no events for an unchanged re-emitted snapshot', () => {
const timeline = computeTodoTimeline([
planMessage('p1', [todo('1', 'in_progress')]),
planMessage('p2', [todo('1', 'in_progress')]),
]);
expect(timeline.get('p2')?.events).toEqual([]);
});
it('tracks todo_write tool-call snapshots, keyed by callId', () => {
const timeline = computeTodoTimeline([
todoWriteMessage('m1', [todo('1', 'in_progress')]),
todoWriteMessage('m2', [todo('1', 'completed')]),
]);
expect(timeline.get('call-m1')?.events).toEqual([
{ kind: 'started', id: '1', content: 'task 1' },
]);
expect(timeline.get('call-m2')?.events).toEqual([
{ kind: 'completed', id: '1', content: 'task 1' },
]);
});
it('ignores messages that carry no todo snapshot', () => {
const timeline = computeTodoTimeline([
userMessage('u1'),
assistantMessage('a1'),
todoWriteMessage('m1', [todo('1', 'in_progress')]),
]);
expect(timeline.size).toBe(1);
expect(timeline.get('call-m1')?.events).toEqual([
{ kind: 'started', id: '1', content: 'task 1' },
]);
});
it('does not diff a reused id against a previous, unrelated plan', () => {
// Both plans number their first item "1" (positional/per-plan numbering),
// but they are different tasks. Plan A leaves "1" in_progress; plan B's "1"
// must still register its own start and completion rather than being
// suppressed by plan A's stale id-"1" status.
const timeline = computeTodoTimeline([
planMessage('a', [item('1', 'Set up project', 'in_progress')]),
userMessage('u1'),
planMessage('b1', [item('1', 'Write the report', 'in_progress')]),
planMessage('b2', [item('1', 'Write the report', 'completed')]),
]);
expect(timeline.get('b1')?.events).toEqual([
{ kind: 'started', id: '1', content: 'Write the report' },
]);
expect(timeline.get('b2')?.events).toEqual([
{ kind: 'completed', id: '1', content: 'Write the report' },
]);
});
it('tracks the same item across a tool call and a later plan snapshot', () => {
const timeline = computeTodoTimeline([
todoWriteMessage('m1', [todo('1', 'in_progress')]),
planMessage('p1', [todo('1', 'completed')]),
]);
expect(timeline.get('call-m1')?.events).toEqual([
{ kind: 'started', id: '1', content: 'task 1' },
]);
expect(timeline.get('p1')?.events).toEqual([
{ kind: 'completed', id: '1', content: 'task 1' },
]);
});
it('tracks an item that carries over and completes in a later turn', () => {
// id+content keys the same task across a user turn, so a "continue" turn
// that finishes a carried-over item still surfaces the completion (a
// user-turn reset would drop this).
const timeline = computeTodoTimeline([
planMessage('p1', [item('1', 'Build feature', 'in_progress')]),
userMessage('u1'),
planMessage('p2', [item('1', 'Build feature', 'completed')]),
]);
expect(timeline.get('p2')?.events).toEqual([
{ kind: 'completed', id: '1', content: 'Build feature' },
]);
});
// Documented limitation of id+content keying: a mid-task reword reads as a new
// task, so its completion is treated as first-seen and omitted from the diff.
it('omits the completion when a todo is reworded on the same id (known gap)', () => {
const timeline = computeTodoTimeline([
planMessage('p1', [item('1', 'Write report', 'in_progress')]),
planMessage('p2', [item('1', 'Write the final report', 'completed')]),
]);
expect(timeline.get('p2')?.events).toEqual([]);
});
// Documented limitation: two unrelated plans reusing both id AND content
// collide, so the second plan's completion is suppressed.
it('collides when unrelated plans reuse the same id and content (known gap)', () => {
const timeline = computeTodoTimeline([
planMessage('a', [item('1', 'Run tests', 'completed')]),
userMessage('u1'),
planMessage('b', [item('1', 'Run tests', 'completed')]),
]);
expect(timeline.get('b')?.events).toEqual([]);
});
});
describe('todoTimelineSignature', () => {
it('is unchanged by edits to non-todo messages', () => {
const a = todoTimelineSignature([
planMessage('p1', [todo('1', 'in_progress')]),
assistantMessage('a1'),
]);
const b = todoTimelineSignature([
planMessage('p1', [todo('1', 'in_progress')]),
{ id: 'a1', role: 'assistant', content: 'different text' },
]);
expect(b).toBe(a);
});
it('changes when an item id, status, or content changes', () => {
const base = todoTimelineSignature([
planMessage('p1', [todo('1', 'in_progress')]),
]);
const status = todoTimelineSignature([
planMessage('p1', [todo('1', 'completed')]),
]);
const id = todoTimelineSignature([
planMessage('p1', [todo('2', 'in_progress')]),
]);
const content = todoTimelineSignature([
planMessage('p1', [item('1', 'reworded', 'in_progress')]),
]);
expect(status).not.toBe(base);
expect(id).not.toBe(base);
expect(content).not.toBe(base);
});
it('is empty for a transcript with no todo snapshots', () => {
expect(todoTimelineSignature([])).toBe('');
expect(todoTimelineSignature([userMessage('u1')])).toBe('');
});
});
describe('isTodoWriteToolName', () => {
it.each(['todo_write', 'todowrite', 'TodoWrite', 'TODO_WRITE'])(
'matches %s',
(name) => {
expect(isTodoWriteToolName(name)).toBe(true);
},
);
it.each(['read', 'edit', 'write_file', ''])('rejects %s', (name) => {
expect(isTodoWriteToolName(name)).toBe(false);
});
});
describe('extractTodosFromToolCall', () => {
function toolCall(overrides: Partial<ACPToolCall>): ACPToolCall {
return {
callId: 'c1',
toolName: 'todo_write',
status: 'completed',
kind: 'think',
...overrides,
};
}
it('reads todos from args', () => {
const todos = extractTodosFromToolCall(
toolCall({ args: { todos: [item('1', 'A', 'pending')] } }),
);
expect(todos).toEqual([{ id: '1', content: 'A', status: 'pending' }]);
});
it('reads todos from rawOutput.todos', () => {
const todos = extractTodosFromToolCall(
toolCall({ rawOutput: { todos: [item('1', 'A', 'in_progress')] } }),
);
expect(todos?.map((t) => t.status)).toEqual(['in_progress']);
});
it('reads todos from rawOutput.entries', () => {
const todos = extractTodosFromToolCall(
toolCall({ rawOutput: { entries: [item('1', 'A', 'completed')] } }),
);
expect(todos?.map((t) => t.status)).toEqual(['completed']);
});
it('returns undefined for a non-todo tool even if it carries a todos array', () => {
const todos = extractTodosFromToolCall(
toolCall({
toolName: 'read',
kind: 'read',
args: { todos: [item('1', 'A', 'pending')] },
}),
);
expect(todos).toBeUndefined();
});
});
describe('getTodoStatusIcon', () => {
it('maps each status to its glyph', () => {
expect(getTodoStatusIcon('completed')).toBe('●');

View file

@ -1,5 +1,15 @@
import type { ACPToolCall, Message, TodoItem } from '../adapters/types';
/**
* The todo tool is registered as `todo_write` on the wire, but older paths and
* the ACP plan bridge use `todowrite`. Match both so detection never hinges on
* the (unrelated) tool `kind`, which is `think` for this tool.
*/
export function isTodoWriteToolName(name: string): boolean {
const normalized = name.toLowerCase();
return normalized === 'todo_write' || normalized === 'todowrite';
}
export function parseTodoItemsFromEntries(
entries: readonly unknown[],
): TodoItem[] | undefined {
@ -22,8 +32,7 @@ export function parseTodoItemsFromEntries(
export function extractTodosFromToolCall(
tool: ACPToolCall,
): TodoItem[] | undefined {
const toolName = tool.toolName.toLowerCase();
if (toolName !== 'todowrite' && tool.kind !== 'other') {
if (!isTodoWriteToolName(tool.toolName) && tool.kind !== 'other') {
return undefined;
}
@ -119,6 +128,133 @@ export function getFloatingTodos(
return { todos, allCompleted, sourceMessageId, sourceCallId };
}
/** A status transition surfaced for a single todo snapshot. */
export interface TodoEvent {
kind: 'started' | 'completed';
id: string;
content: string;
}
/** What changed in one todo snapshot relative to the conversation so far. */
export interface TodoSnapshotDiff {
events: TodoEvent[];
}
interface TodoSnapshot {
/** Key the diff is stored under: tool callId, or plan message id. */
key: string;
todos: TodoItem[];
}
/**
* Identity used to track an item across snapshots. Folds content into the key
* because todo ids are NOT globally unique: the ACP bridge assigns positional
* ids (`plan-0`, `plan-1`, ) and models restart numbering at `1, 2, 3` for each
* new `todo_write` plan, so a later, unrelated list reuses an earlier list's
* ids. Keying on id alone would diff a new plan's items against a previous
* plan's stale terminal status; id+content keeps distinct tasks separate, and
* unlike a user-turn reset it still tracks a list correctly when it spans
* turns (a "continue" turn that completes an item carried over from before).
*
* Two rare cases this trades for, both only affecting the collapsed diff while
* the expanded list stays correct:
* - A todo reworded on a stable id reads as a new task. Reworded while still
* `in_progress` it emits a spurious `started`; reworded straight to
* `completed` (`1 "Write report"` `1 "Write the final report" completed`)
* the completion is treated as first-seen and dropped.
* - Two unrelated plans that reuse both the id AND the exact content (a generic
* recurring todo like `"Run tests"`) still collide.
*/
function todoStateKey(todo: TodoItem): string {
return JSON.stringify([todo.id, todo.content]);
}
/**
* The todo snapshots carried by one message, in order. In the web-shell daemon
* path todos arrive as `todo_write` tool calls; the ACP bridge instead emits
* `plan` messages. Handle both so the timeline works regardless of source.
*/
function todoSnapshotsOf(message: Message): TodoSnapshot[] {
if (message.role === 'plan') {
return [{ key: message.id, todos: message.todos }];
}
if (message.role === 'tool_group') {
const snapshots: TodoSnapshot[] = [];
for (const tool of message.tools) {
const todos = extractTodosFromToolCall(tool);
if (todos) snapshots.push({ key: tool.callId, todos });
}
return snapshots;
}
return [];
}
/**
* Walk the todo snapshots in order and, for each one, derive what changed
* relative to the running state: which items just started and which just
* completed.
*
* Keyed by snapshot id (tool callId or plan message id) so a history row can
* look up its own diff. Only transitions actually witnessed produce events an
* item first seen already completed (e.g. a restored session's opening
* snapshot) is recorded silently so its old completion is not replayed as if it
* just happened.
*/
export function computeTodoTimeline(
messages: readonly Message[],
): Map<string, TodoSnapshotDiff> {
const result = new Map<string, TodoSnapshotDiff>();
const lastStatus = new Map<string, TodoItem['status']>();
for (const message of messages) {
for (const { key, todos } of todoSnapshotsOf(message)) {
const events: TodoEvent[] = [];
for (const todo of todos) {
const stateKey = todoStateKey(todo);
const prev = lastStatus.get(stateKey);
if (todo.status === 'in_progress' && prev !== 'in_progress') {
events.push({ kind: 'started', id: todo.id, content: todo.content });
} else if (
todo.status === 'completed' &&
prev !== 'completed' &&
prev !== undefined
) {
events.push({
kind: 'completed',
id: todo.id,
content: todo.content,
});
}
lastStatus.set(stateKey, todo.status);
}
result.set(key, { events });
}
}
return result;
}
/**
* A cheap signature of the todo snapshots in a transcript: each snapshot's key
* plus its items' id, status, and content. App memoizes the timeline on this so
* the context provider value stays referentially stable across unrelated
* streaming ticks (which would otherwise re-render every todo/plan row that
* consumes the timeline).
*/
export function todoTimelineSignature(messages: readonly Message[]): string {
const parts: string[] = [];
for (const message of messages) {
for (const { key, todos } of todoSnapshotsOf(message)) {
parts.push(
JSON.stringify([key, todos.map((t) => [t.id, t.status, t.content])]),
);
}
}
return parts.join('\n');
}
export interface TodoWindow {
start: number;
end: number;