feat: add agent todo tool and inline todo snapshot cards in chat

This commit is contained in:
Xiaohan-Tian
2026-06-02 21:52:44 -07:00
parent c1d9f4c9fb
commit 5aacf01457
24 changed files with 995 additions and 28 deletions
+91
View File
@@ -166,6 +166,97 @@
gap: 12px;
}
.chatbox-todo-card {
background: linear-gradient(180deg, #252525 0%, #202020 100%);
border: 1px solid #3a3a3a;
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.chatbox-todo-card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.chatbox-todo-card-header h4 {
margin: 0;
color: #f0f0f0;
font-size: 12px;
font-weight: 700;
}
.chatbox-todo-count {
color: #8fb8da;
font-size: 11px;
}
.chatbox-todo-active {
color: #d7d7d7;
font-size: 11px;
line-height: 1.4;
}
.chatbox-todo-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
}
.chatbox-todo-item {
display: flex;
gap: 8px;
align-items: flex-start;
color: #d8d8d8;
font-size: 11px;
line-height: 1.4;
}
.chatbox-todo-item.is-completed .chatbox-todo-text {
color: #9ba39f;
text-decoration: line-through;
}
.chatbox-todo-item.is-in_progress .chatbox-todo-text {
color: #f0f0f0;
}
.chatbox-todo-marker {
width: 10px;
flex: 0 0 10px;
color: #7cc2f1;
text-align: center;
}
.chatbox-todo-item.is-completed .chatbox-todo-marker {
color: #67c18a;
}
.chatbox-todo-item.is-pending .chatbox-todo-marker {
color: #a7a7a7;
}
.chatbox-todo-status {
font-size: 10px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.chatbox-todo-status.is-success {
color: #67c18a;
}
.chatbox-todo-status.is-error {
color: #d45a5a;
}
.message-container {
width: 100%;
word-wrap: break-word;
+24 -2
View File
@@ -15,7 +15,11 @@ const {
setLLMProvider: vi.fn(),
getLLMProvider: vi.fn(() => ({ getPreferredSystemPromptPath: vi.fn() })),
abortCurrentRequest: vi.fn(),
getAgentState: vi.fn(() => ({ getMessages: vi.fn(() => []) })),
getAgentState: vi.fn(() => ({
getMessages: vi.fn(() => []),
getTodos: vi.fn(() => []),
subscribeTodoChanges: vi.fn(() => () => undefined),
})),
compactConversation: vi.fn(async () => ({ changed: true, compactedConversation: 'summary' })),
shouldCompactBeforeNextTurn: vi.fn(async () => false),
},
@@ -25,7 +29,17 @@ const {
vi.mock('./chat', () => ({
UserMessage: ({ content }: { content: string }) => <div>{content}</div>,
AssistantMessage: ({ content }: { content: string }) => <div>{content}</div>,
AssistantMessage: ({
content,
todoSnapshot,
}: {
content: string;
todoSnapshot?: Array<{ text: string }>;
}) => (
<div>
{todoSnapshot ? `TODO SNAPSHOT: ${todoSnapshot.map(todo => todo.text).join(', ')}` : content}
</div>
),
}));
vi.mock('../agent/core/AgentCore', () => ({
@@ -205,4 +219,12 @@ describe('ChatBox', () => {
expect(screen.getByText('Conversation Compacted')).toBeTruthy();
});
});
it('does not render a pinned todo checklist from agent state', async () => {
renderWithLocale('en_us');
await waitFor(() => {
expect(screen.queryByText('Task Checklist')).toBeNull();
});
});
});
+3 -1
View File
@@ -428,7 +428,6 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
const localRuntimeMessage = localModelState.runtimeSupport.reason;
const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported;
return (
<div className={`chatbox ${isVisible ? '' : 'is-hidden'}`}>
<div className="chatbox-header">
@@ -526,6 +525,9 @@ const ChatBox: React.FC<ChatBoxProps> = ({ isVisible }) => {
content={message.content}
isStreaming={message.isStreaming}
performanceInfo={message.performanceInfo}
toolName={message.toolName}
toolSuccess={message.toolSuccess}
todoSnapshot={message.todoSnapshot}
onAbort={message.isStreaming ? handleAbort : undefined}
/>
)
@@ -1,6 +1,7 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import AssistantMessage from './AssistantMessage';
import type { TodoItem } from '../../agent/core/todo';
describe('AssistantMessage', () => {
afterEach(() => {
@@ -110,4 +111,26 @@ describe('AssistantMessage', () => {
expect(screen.getByLabelText('Nothing to Compact Yet')).toBeInTheDocument();
});
it('renders a structured todo snapshot card instead of markdown content', () => {
const todoSnapshot: TodoItem[] = [
{ id: '1', text: 'Inspect melody', status: 'completed', updatedAt: 1 },
{ id: '2', text: 'Write harmony', status: 'in_progress', activeText: 'Writing harmony', updatedAt: 2 },
];
render(
<AssistantMessage
content="fallback content"
toolName="update_todo_list"
toolSuccess={true}
todoSnapshot={todoSnapshot}
/>
);
expect(screen.getByLabelText('Agent task checklist snapshot')).toBeInTheDocument();
expect(screen.getByText('Task Checklist')).toBeInTheDocument();
expect(screen.getByText('1/2 completed')).toBeInTheDocument();
expect(screen.getByText('Working on: Writing harmony')).toBeInTheDocument();
expect(screen.queryByText('fallback content')).not.toBeInTheDocument();
});
});
+46 -1
View File
@@ -6,12 +6,17 @@ import remarkMath from 'remark-math';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import type { PerformanceInfo } from '../../agent/llm/StreamingTypes';
import { summarizeTodoCounts } from '../../agent/core/todo';
import type { TodoItem } from '../../agent/core/todo';
interface AssistantMessageProps {
content: string;
isStreaming?: boolean;
onAbort?: () => void;
performanceInfo?: PerformanceInfo;
toolName?: string;
toolSuccess?: boolean;
todoSnapshot?: TodoItem[];
}
// Memoized code component to prevent SyntaxHighlighter re-renders
@@ -58,7 +63,15 @@ const formatThinkingDuration = (elapsedSeconds: number): string => {
return `Thinking for ${minutes}m ${seconds.toString().padStart(2, '0')}s...`;
};
const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreaming, onAbort, performanceInfo }) => {
const AssistantMessage: React.FC<AssistantMessageProps> = ({
content,
isStreaming,
onAbort,
performanceInfo,
toolName,
toolSuccess,
todoSnapshot,
}) => {
const prefillTps = formatTps(performanceInfo?.prefillTps);
const generationTps = formatTps(performanceInfo?.generationTps);
const hasPerformanceInfo = Boolean(prefillTps || generationTps);
@@ -68,6 +81,7 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
const isCompactionBanner = content === COMPACTION_IN_PROGRESS_LABEL
|| content === COMPACTION_DONE_LABEL
|| content === COMPACTION_EMPTY_LABEL;
const isTodoSnapshotCard = toolName === 'update_todo_list' && Array.isArray(todoSnapshot);
useEffect(() => {
if (!isThinking) {
@@ -89,6 +103,37 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
}, [isThinking]);
const renderContent = () => {
if (isTodoSnapshotCard) {
const counts = summarizeTodoCounts(todoSnapshot);
const activeTodo = todoSnapshot.find(todo => todo.status === 'in_progress') ?? null;
return (
<section className="chatbox-todo-card" aria-label="Agent task checklist snapshot">
<div className="chatbox-todo-card-header">
<h4>Task Checklist</h4>
<span className="chatbox-todo-count">
{counts.completed}/{counts.total} completed
</span>
</div>
{activeTodo && (
<div className="chatbox-todo-active">
Working on: {activeTodo.activeText || activeTodo.text}
</div>
)}
<ul className="chatbox-todo-list">
{todoSnapshot.map((todo) => (
<li key={todo.id} className={`chatbox-todo-item is-${todo.status}`}>
<span className="chatbox-todo-marker" aria-hidden="true">
{todo.status === 'completed' ? '✓' : todo.status === 'in_progress' ? '→' : '•'}
</span>
<span className="chatbox-todo-text">{todo.text}</span>
</li>
))}
</ul>
</section>
);
}
if (isCompactionBanner) {
return (
<div className="message-divider-banner" aria-label={content}>