feat: add agent todo tool and inline todo snapshot cards in chat
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user