Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions src/__tests__/renderer/components/AgentTaskListBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { AgentTaskListBar } from '../../../renderer/components/AgentTaskListBar';
import { mockTheme } from '../../helpers/mockTheme';
import type { LogEntry } from '../../../renderer/types';

// The bar reads two booleans off the settings store; stub them rather than
// hydrating the whole store from IPC.
let showBar = true;
let autoExpand = false;
vi.mock('../../../renderer/stores/settingsStore', () => ({
useSettingsStore: (
selector: (s: { showAgentTaskListBar: boolean; autoExpandAgentTaskListBar: boolean }) => unknown
) => selector({ showAgentTaskListBar: showBar, autoExpandAgentTaskListBar: autoExpand }),
}));

function todoEntry(id: string, statuses: string[]): LogEntry {
return {
id,
timestamp: 0,
source: 'tool',
text: 'TodoWrite',
metadata: {
toolState: {
status: 'completed',
input: {
todos: statuses.map((status, i) => ({
content: `task ${i}`,
status,
activeForm: `doing task ${i}`,
})),
},
},
},
} as unknown as LogEntry;
}

describe('AgentTaskListBar', () => {
beforeEach(() => {
showBar = true;
autoExpand = false;
// The bar remembers its expanded state; start every case collapsed.
globalThis.localStorage?.clear();
});

it('renders nothing until an agent writes a checklist', () => {
const { container } = render(<AgentTaskListBar theme={mockTheme} logs={[]} />);
expect(container).toBeEmptyDOMElement();
});

it('shows the active task and progress count, collapsed by default', () => {
render(
<AgentTaskListBar
theme={mockTheme}
logs={[todoEntry('a', ['completed', 'in_progress', 'pending'])]}
/>
);

expect(screen.getByText('doing task 1 (1/3)')).toBeInTheDocument();
// Collapsed: the individual task rows are not rendered yet.
expect(screen.queryByText('task 2')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Expand agent task list' })).toHaveAttribute(
'aria-expanded',
'false'
);
});

it('expands to the full list on click', () => {
render(
<AgentTaskListBar
theme={mockTheme}
logs={[todoEntry('a', ['completed', 'in_progress', 'pending'])]}
/>
);

fireEvent.click(screen.getByRole('button', { name: 'Expand agent task list' }));

expect(screen.getByText('task 0')).toBeInTheDocument();
expect(screen.getByText('doing task 1')).toBeInTheDocument();
expect(screen.getByText('task 2')).toBeInTheDocument();
});

it('tracks the newest checklist as the agent rewrites it', () => {
const { rerender } = render(
<AgentTaskListBar theme={mockTheme} logs={[todoEntry('a', ['in_progress', 'pending'])]} />
);
expect(screen.getByText('doing task 0 (0/2)')).toBeInTheDocument();

rerender(
<AgentTaskListBar
theme={mockTheme}
logs={[
todoEntry('a', ['in_progress', 'pending']),
todoEntry('b', ['completed', 'in_progress']),
]}
/>
);
expect(screen.getByText('doing task 1 (1/2)')).toBeInTheDocument();
});

it('dismisses the current list, and comes back when the agent writes a new one', () => {
const first = todoEntry('a', ['in_progress', 'pending']);
const { rerender } = render(<AgentTaskListBar theme={mockTheme} logs={[first]} />);

fireEvent.click(screen.getByRole('button', { name: 'Hide this task list' }));
expect(screen.queryByTestId('agent-task-list-bar')).not.toBeInTheDocument();

// A re-render alone must not resurrect the dismissed list...
rerender(<AgentTaskListBar theme={mockTheme} logs={[first]} />);
expect(screen.queryByTestId('agent-task-list-bar')).not.toBeInTheDocument();

// ...but the next checklist update does.
rerender(
<AgentTaskListBar
theme={mockTheme}
logs={[first, todoEntry('b', ['completed', 'completed'])]}
/>
);
expect(screen.getByTestId('agent-task-list-bar')).toBeInTheDocument();
});

it('stays hidden when the setting is off', () => {
showBar = false;
const { container } = render(
<AgentTaskListBar theme={mockTheme} logs={[todoEntry('a', ['in_progress'])]} />
);
expect(container).toBeEmptyDOMElement();
});

it('ignores a checklist written inside a subagent', () => {
const nested = todoEntry('b', ['pending', 'pending']);
nested.metadata = { ...nested.metadata, parentToolUseId: 'task_1' };

render(
<AgentTaskListBar
theme={mockTheme}
logs={[todoEntry('a', ['in_progress', 'pending']), nested]}
/>
);

// The parent's plan, not the delegated worker's private one.
expect(screen.getByText('doing task 0 (0/2)')).toBeInTheDocument();
});

describe('with auto-expand on', () => {
beforeEach(() => {
autoExpand = true;
});

it('opens a new checklist to its full list', () => {
render(
<AgentTaskListBar
theme={mockTheme}
logs={[todoEntry('a', ['completed', 'in_progress', 'pending'])]}
/>
);

expect(screen.getByText('task 2')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Collapse agent task list' })).toHaveAttribute(
'aria-expanded',
'true'
);
});

it('re-expands when the agent writes the next checklist, after a manual collapse', () => {
const first = todoEntry('a', ['in_progress', 'pending']);
const { rerender } = render(<AgentTaskListBar theme={mockTheme} logs={[first]} />);

fireEvent.click(screen.getByRole('button', { name: 'Collapse agent task list' }));
expect(screen.queryByText('task 1')).not.toBeInTheDocument();

// The collapse applies to THAT checklist only.
rerender(
<AgentTaskListBar
theme={mockTheme}
logs={[first, todoEntry('b', ['completed', 'in_progress'])]}
/>
);
expect(screen.getByText('task 0')).toBeInTheDocument();
});

it('does not carry the sticky collapsed preference over', () => {
// A user who collapsed the bar with auto-expand OFF still gets the
// full list once they turn auto-expand ON.
globalThis.localStorage?.setItem('agentTaskList.bar.expanded', 'false');

render(<AgentTaskListBar theme={mockTheme} logs={[todoEntry('a', ['in_progress'])]} />);

expect(screen.getByText('doing task 0')).toBeInTheDocument();
});
});
});
58 changes: 58 additions & 0 deletions src/__tests__/renderer/utils/agentTaskList.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, it, expect } from 'vitest';
import {
extractAgentTaskList,
findLatestAgentTaskList,
summarizeAgentTaskList,
} from '../../../renderer/utils/agentTaskList';
import type { LogEntry } from '../../../renderer/types';

describe('extractAgentTaskList', () => {
it('extracts a Claude Code / OpenCode TodoWrite todos array', () => {
Expand Down Expand Up @@ -128,3 +130,59 @@ describe('summarizeAgentTaskList', () => {
expect(summarizeAgentTaskList(list)).toBe('Patch the parser (0/1)');
});
});

describe('findLatestAgentTaskList', () => {
const toolEntry = (id: string, input: unknown) =>
({
id,
timestamp: 0,
source: 'tool',
text: 'TodoWrite',
metadata: { toolState: { status: 'completed', input } },
}) as unknown as LogEntry;

const todos = (...statuses: string[]) => ({
todos: statuses.map((status, i) => ({ content: `task ${i}`, status })),
});

it('returns the newest checklist in the log', () => {
const latest = findLatestAgentTaskList([
toolEntry('a', todos('in_progress', 'pending')),
toolEntry('b', { file_path: '/tmp/x.ts' }),
toolEntry('c', todos('completed', 'in_progress')),
]);

expect(latest?.entryId).toBe('c');
expect(latest?.list.completed).toBe(1);
});

it('returns null when no tool call carried a checklist', () => {
expect(findLatestAgentTaskList([toolEntry('a', { command: 'ls' })])).toBeNull();
expect(findLatestAgentTaskList([])).toBeNull();
expect(findLatestAgentTaskList(undefined)).toBeNull();
});

it('ignores non-tool entries', () => {
const chat = { id: 'msg', timestamp: 0, source: 'ai', text: 'todos: 1. do it' } as LogEntry;
expect(findLatestAgentTaskList([chat])).toBeNull();
});

it("skips a subagent's private checklist", () => {
const nested = toolEntry('b', todos('pending', 'pending'));
nested.metadata = { ...nested.metadata, parentToolUseId: 'task_1' };

const latest = findLatestAgentTaskList([
toolEntry('a', todos('in_progress', 'pending')),
nested,
]);

expect(latest?.entryId).toBe('a');
});

it('returns null when the only checklist came from a subagent', () => {
const nested = toolEntry('a', todos('pending'));
nested.metadata = { ...nested.metadata, parentToolUseId: 'task_1' };

expect(findLatestAgentTaskList([nested])).toBeNull();
});
});
55 changes: 55 additions & 0 deletions src/renderer/components/AgentTaskItems.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Theme } from '../types';
import type { AgentTask } from '../utils/agentTaskList';

/**
* The task rows of an agent checklist: status glyph, label, completed styling.
*
* Shared by the two surfaces that render a checklist - the inline
* `AgentTaskListCard` in the transcript and the docked `AgentTaskListBar` above
* the composer - so "what a completed task looks like" is decided once. The
* surfaces differ in chrome and placement, not in how a task reads.
*/

/** Status glyph + color for a single task row. */
export function taskGlyph(task: AgentTask, theme: Theme): { glyph: string; color: string } {
if (task.status === 'completed') return { glyph: '✓', color: theme.colors.success };
if (task.status === 'in_progress') return { glyph: '▸', color: theme.colors.warning };
return { glyph: '○', color: theme.colors.textDim };
}

interface AgentTaskItemsProps {
theme: Theme;
tasks: AgentTask[];
className?: string;
}

export function AgentTaskItems({ theme, tasks, className }: AgentTaskItemsProps) {
return (
<ul className={className ?? 'space-y-0.5'}>
{tasks.map((task, index) => {
const { glyph, color } = taskGlyph(task, theme);
return (
<li
key={`${index}-${task.content}`}
className="flex items-start gap-2 break-words"
style={{
color: theme.colors.textMain,
opacity: task.status === 'completed' ? 0.45 : 0.8,
}}
>
<span className="shrink-0" style={{ color }} aria-hidden="true">
{glyph}
</span>
<span
style={{
textDecoration: task.status === 'completed' ? 'line-through' : undefined,
}}
>
{task.status === 'in_progress' && task.activeForm ? task.activeForm : task.content}
</span>
</li>
);
})}
</ul>
);
}
Loading
Loading