diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index faf197604..71d900610 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -4,6 +4,7 @@ import { MessageRenderer } from "./Messages/MessageRenderer"; import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier"; import { StreamingBarrier } from "./Messages/ChatBarrier/StreamingBarrier"; import { RetryBarrier } from "./Messages/ChatBarrier/RetryBarrier"; +import { PinnedTodoList } from "./PinnedTodoList"; import { getAutoRetryKey } from "@/constants/storage"; import { ChatInput, type ChatInputAPI } from "./ChatInput"; import { ChatMetaSidebar } from "./ChatMetaSidebar"; @@ -487,6 +488,7 @@ const AIViewInner: React.FC = ({ )} )} + {canInterrupt && ( ` + display: inline-block; + transition: transform 0.2s; + transform: ${(props) => (props.expanded ? "rotate(90deg)" : "rotate(0deg)")}; + font-size: 8px; +`; + +interface PinnedTodoListProps { + workspaceId: string; +} + +/** + * Pinned TODO list displayed at bottom of chat (before StreamingBarrier). + * Shows current TODOs from active stream only. + * Reuses TodoList component for consistent styling. + */ +export const PinnedTodoList: React.FC = ({ workspaceId }) => { + const workspaceStore = useWorkspaceStoreRaw(); + const [expanded, setExpanded] = usePersistedState("pinnedTodoExpanded", true); + + // Subscribe to workspace state changes to re-render when TODOs update + useSyncExternalStore( + (callback) => workspaceStore.subscribeKey(workspaceId, callback), + () => workspaceStore.getWorkspaceState(workspaceId) + ); + + // Get current TODOs (uses latest aggregator state) + const todos = workspaceStore.getTodos(workspaceId); + + // Don't render if no TODOs + if (todos.length === 0) { + return null; + } + + return ( + + setExpanded(!expanded)}> + + TODO{expanded ? ":" : ""} + + {expanded && } + + ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..3e3a9eaa3 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,136 @@ +import React from "react"; +import styled from "@emotion/styled"; +import type { TodoItem } from "@/types/tools"; + +const TodoListContainer = styled.div` + display: flex; + flex-direction: column; + gap: 3px; + padding: 6px 8px; +`; + +const TodoItemContainer = styled.div<{ status: TodoItem["status"] }>` + display: flex; + align-items: flex-start; + gap: 6px; + padding: 4px 8px; + background: ${(props) => { + switch (props.status) { + case "completed": + return "color-mix(in srgb, #4caf50, transparent 92%)"; + case "in_progress": + return "color-mix(in srgb, #2196f3, transparent 92%)"; + case "pending": + default: + return "color-mix(in srgb, #888, transparent 96%)"; + } + }}; + border-left: 2px solid + ${(props) => { + switch (props.status) { + case "completed": + return "#4caf50"; + case "in_progress": + return "#2196f3"; + case "pending": + default: + return "#666"; + } + }}; + border-radius: 3px; + font-family: var(--font-monospace); + font-size: 11px; + line-height: 1.35; + color: var(--color-text); +`; + +const TodoIcon = styled.div` + font-size: 12px; + flex-shrink: 0; + margin-top: 1px; + opacity: 0.8; +`; + +const TodoContent = styled.div` + flex: 1; + min-width: 0; +`; + +const TodoText = styled.div<{ status: TodoItem["status"] }>` + color: ${(props) => { + switch (props.status) { + case "completed": + return "#888"; + case "in_progress": + return "#2196f3"; + default: + return "var(--color-text)"; + } + }}; + text-decoration: ${(props) => (props.status === "completed" ? "line-through" : "none")}; + opacity: ${(props) => (props.status === "completed" ? "0.7" : "1")}; + font-weight: ${(props) => (props.status === "in_progress" ? "500" : "normal")}; + white-space: nowrap; + + ${(props) => + props.status === "in_progress" && + ` + &::after { + content: "..."; + display: inline; + overflow: hidden; + animation: ellipsis 1.5s steps(4, end) infinite; + } + + @keyframes ellipsis { + 0% { + content: ""; + } + 25% { + content: "."; + } + 50% { + content: ".."; + } + 75% { + content: "..."; + } + } + `} +`; + +interface TodoListProps { + todos: TodoItem[]; +} + +function getStatusIcon(status: TodoItem["status"]): string { + switch (status) { + case "completed": + return "✓"; + case "in_progress": + return "⏳"; + case "pending": + default: + return "○"; + } +} + +/** + * Shared TODO list component used by: + * - TodoToolCall (in expanded tool history) + * - PinnedTodoList (pinned at bottom of chat) + */ +export const TodoList: React.FC = ({ todos }) => { + return ( + + {todos.map((todo, index) => ( + + {getStatusIcon(todo.status)} + + {todo.content} + + + ))} + + ); +}; diff --git a/src/components/tools/TodoToolCall.tsx b/src/components/tools/TodoToolCall.tsx index 4a06abf7c..68404e829 100644 --- a/src/components/tools/TodoToolCall.tsx +++ b/src/components/tools/TodoToolCall.tsx @@ -1,6 +1,5 @@ import React from "react"; -import styled from "@emotion/styled"; -import type { TodoWriteToolArgs, TodoWriteToolResult, TodoItem } from "@/types/tools"; +import type { TodoWriteToolArgs, TodoWriteToolResult } from "@/types/tools"; import { ToolContainer, ToolHeader, @@ -10,96 +9,7 @@ import { } from "./shared/ToolPrimitives"; import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils"; import { TooltipWrapper, Tooltip } from "../Tooltip"; - -const TodoList = styled.div` - display: flex; - flex-direction: column; - gap: 3px; - padding: 6px 8px; -`; - -const TodoItemContainer = styled.div<{ status: TodoItem["status"] }>` - display: flex; - align-items: flex-start; - gap: 6px; - padding: 4px 8px; - background: ${(props) => { - switch (props.status) { - case "completed": - return "color-mix(in srgb, #4caf50, transparent 92%)"; - case "in_progress": - return "color-mix(in srgb, #2196f3, transparent 92%)"; - case "pending": - default: - return "color-mix(in srgb, #888, transparent 96%)"; - } - }}; - border-left: 2px solid - ${(props) => { - switch (props.status) { - case "completed": - return "#4caf50"; - case "in_progress": - return "#2196f3"; - case "pending": - default: - return "#666"; - } - }}; - border-radius: 3px; - font-family: var(--font-monospace); - font-size: 11px; - line-height: 1.35; - color: var(--color-text); -`; - -const TodoIcon = styled.div` - font-size: 12px; - flex-shrink: 0; - margin-top: 1px; - opacity: 0.8; -`; - -const TodoContent = styled.div` - flex: 1; - min-width: 0; -`; - -const TodoText = styled.div<{ status: TodoItem["status"] }>` - color: ${(props) => (props.status === "completed" ? "#888" : "var(--color-text)")}; - text-decoration: ${(props) => (props.status === "completed" ? "line-through" : "none")}; - opacity: ${(props) => (props.status === "completed" ? "0.7" : "1")}; -`; - -const TodoActiveForm = styled.div` - color: #2196f3; - font-weight: 500; - font-size: 11px; - opacity: 0.95; - white-space: nowrap; - - &::after { - content: "..."; - display: inline; - overflow: hidden; - animation: ellipsis 1.5s steps(4, end) infinite; - } - - @keyframes ellipsis { - 0% { - content: ""; - } - 25% { - content: "."; - } - 50% { - content: ".."; - } - 75% { - content: "..."; - } - } -`; +import { TodoList } from "../TodoList"; interface TodoToolCallProps { args: TodoWriteToolArgs; @@ -107,24 +17,12 @@ interface TodoToolCallProps { status?: ToolStatus; } -function getStatusIcon(status: TodoItem["status"]): string { - switch (status) { - case "completed": - return "✓"; - case "in_progress": - return "⏳"; - case "pending": - default: - return "○"; - } -} - export const TodoToolCall: React.FC = ({ args, result: _result, status = "pending", }) => { - const { expanded, toggleExpanded } = useToolExpansion(true); // Expand by default + const { expanded, toggleExpanded } = useToolExpansion(false); // Collapsed by default const statusDisplay = getStatusDisplay(status); return ( @@ -140,20 +38,7 @@ export const TodoToolCall: React.FC = ({ {expanded && ( - - {args.todos.map((todo, index) => ( - - {getStatusIcon(todo.status)} - - {todo.status === "in_progress" ? ( - {todo.activeForm} - ) : ( - {todo.content} - )} - - - ))} - + )} diff --git a/src/services/tools/todo.test.ts b/src/services/tools/todo.test.ts index aa7b6f7df..848bda3a3 100644 --- a/src/services/tools/todo.test.ts +++ b/src/services/tools/todo.test.ts @@ -22,19 +22,16 @@ describe("Todo Storage", () => { it("should store todo list in temp directory", async () => { const todos: TodoItem[] = [ { - content: "Install dependencies", + content: "Installed dependencies", status: "completed", - activeForm: "Installing dependencies", }, { - content: "Write tests", + content: "Writing tests", status: "in_progress", - activeForm: "Writing tests", }, { content: "Update documentation", status: "pending", - activeForm: "Updating documentation", }, ]; @@ -50,12 +47,10 @@ describe("Todo Storage", () => { { content: "Task 1", status: "pending", - activeForm: "Doing task 1", }, { content: "Task 2", status: "pending", - activeForm: "Doing task 2", }, ]; @@ -66,17 +61,14 @@ describe("Todo Storage", () => { { content: "Task 1", status: "completed", - activeForm: "Doing task 1", }, { content: "Task 2", status: "in_progress", - activeForm: "Doing task 2", }, { content: "Task 3", status: "pending", - activeForm: "Doing task 3", }, ]; @@ -93,7 +85,6 @@ describe("Todo Storage", () => { { content: "Task 1", status: "pending", - activeForm: "Doing task 1", }, ]); @@ -109,7 +100,6 @@ describe("Todo Storage", () => { { content: "Step 1", status: "pending", - activeForm: "Handling step 1", }, ]; @@ -119,12 +109,10 @@ describe("Todo Storage", () => { { content: "Step 1", status: "in_progress", - activeForm: "Handling step 1", }, { content: "Step 2", status: "in_progress", - activeForm: "Handling step 2", }, ]; @@ -141,12 +129,10 @@ describe("Todo Storage", () => { { content: "Step 1", status: "pending", - activeForm: "Handling step 1", }, { content: "Step 2", status: "in_progress", - activeForm: "Handling step 2", }, ]; @@ -160,12 +146,10 @@ describe("Todo Storage", () => { { content: "Step 1", status: "in_progress", - activeForm: "Handling step 1", }, { content: "Step 2", status: "completed", - activeForm: "Handling step 2", }, ]; @@ -179,12 +163,10 @@ describe("Todo Storage", () => { { content: "Step 1", status: "completed", - activeForm: "Handling step 1", }, { content: "Step 2", status: "completed", - activeForm: "Handling step 2", }, ]; @@ -204,12 +186,10 @@ describe("Todo Storage", () => { { content: "Task 1", status: "completed", - activeForm: "Doing task 1", }, { content: "Task 2", status: "in_progress", - activeForm: "Doing task 2", }, ]; @@ -231,7 +211,6 @@ describe("Todo Storage", () => { { content: "Stream 1 task", status: "pending", - activeForm: "Working on stream 1", }, ]; @@ -239,7 +218,6 @@ describe("Todo Storage", () => { { content: "Stream 2 task", status: "pending", - activeForm: "Working on stream 2", }, ]; @@ -266,7 +244,6 @@ describe("Todo Storage", () => { { content: "Task 1", status: "pending", - activeForm: "Doing task 1", }, ]; diff --git a/src/stores/GitStatusStore.test.ts b/src/stores/GitStatusStore.test.ts index 54d57b7af..b0dcc3264 100644 --- a/src/stores/GitStatusStore.test.ts +++ b/src/stores/GitStatusStore.test.ts @@ -48,7 +48,7 @@ describe("GitStatusStore", () => { afterEach(() => { store.dispose(); // Cleanup mocked window to avoid leaking between tests - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (globalThis as { window?: unknown }).window; }); diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index a15f9d035..d5cb86245 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -2,6 +2,7 @@ import type { CmuxMessage, DisplayedMessage } from "@/types/message"; import { createCmuxMessage } from "@/types/message"; import type { WorkspaceMetadata } from "@/types/workspace"; import type { WorkspaceChatMessage } from "@/types/ipc"; +import type { TodoItem } from "@/types/tools"; import { StreamingMessageAggregator } from "@/utils/messages/StreamingMessageAggregator"; import { updatePersistedState } from "@/hooks/usePersistedState"; import { getRetryStateKey } from "@/constants/storage"; @@ -252,6 +253,15 @@ export class WorkspaceStore { return this.getOrCreateAggregator(workspaceId); } + /** + * Get current TODO list for a workspace. + * Returns empty array if workspace doesn't exist or has no TODOs. + */ + getTodos(workspaceId: string): TodoItem[] { + const aggregator = this.aggregators.get(workspaceId); + return aggregator ? aggregator.getCurrentTodos() : []; + } + /** * Add a workspace and subscribe to its IPC events. */ diff --git a/src/types/tools.ts b/src/types/tools.ts index df069ee2f..0173acb4b 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -141,7 +141,6 @@ export interface ProposePlanToolResult { export interface TodoItem { content: string; status: "pending" | "in_progress" | "completed"; - activeForm: string; } export interface TodoWriteToolArgs { diff --git a/src/utils/messages/StreamingMessageAggregator.ts b/src/utils/messages/StreamingMessageAggregator.ts index 4a084c422..00ebfeb18 100644 --- a/src/utils/messages/StreamingMessageAggregator.ts +++ b/src/utils/messages/StreamingMessageAggregator.ts @@ -11,6 +11,7 @@ import type { ReasoningDeltaEvent, ReasoningEndEvent, } from "@/types/stream"; +import type { TodoItem } from "@/types/tools"; import type { WorkspaceChatMessage, StreamErrorMessage, DeleteMessage } from "@/types/ipc"; import type { @@ -46,6 +47,9 @@ export class StreamingMessageAggregator { // Delta history for token counting and TPS calculation private deltaHistory = new Map(); + // Current TODO list (updated when todo_write succeeds) + private currentTodos: TodoItem[] = []; + private invalidateCache(): void { this.cachedAllMessages = null; this.cachedDisplayedMessages = null; @@ -69,6 +73,23 @@ export class StreamingMessageAggregator { return this.recencyTimestamp; } + /** + * Get the current TODO list. + * Updated whenever todo_write succeeds. + */ + getCurrentTodos(): TodoItem[] { + return this.currentTodos; + } + + /** + * Clean up stream-scoped state when stream ends (normally or abnormally). + * Called by handleStreamEnd, handleStreamAbort, and handleStreamError. + */ + private cleanupStreamState(messageId: string): void { + this.currentTodos = []; + this.activeStreams.delete(messageId); + } + addMessage(message: CmuxMessage): void { // Just store the message - backend assigns historySequence this.messages.set(message.id, message); @@ -252,8 +273,8 @@ export class StreamingMessageAggregator { } } - // Clean up active stream - direct delete by messageId - this.activeStreams.delete(data.messageId); + // Clean up stream-scoped state (TODOs, active stream tracking) + this.cleanupStreamState(data.messageId); } else { // Reconnection case: user reconnected after stream completed // We reconstruct the entire message from the stream-end event @@ -291,8 +312,8 @@ export class StreamingMessageAggregator { }; } - // Clean up active stream - direct delete by messageId - this.activeStreams.delete(data.messageId); + // Clean up stream-scoped state (TODOs, active stream tracking) + this.cleanupStreamState(data.messageId); this.invalidateCache(); } } @@ -310,8 +331,8 @@ export class StreamingMessageAggregator { message.metadata.errorType = data.errorType; } - // Clean up active stream - direct delete by messageId - this.activeStreams.delete(data.messageId); + // Clean up stream-scoped state (TODOs, active stream tracking) + this.cleanupStreamState(data.messageId); this.invalidateCache(); } } @@ -375,6 +396,18 @@ export class StreamingMessageAggregator { // Type assertion needed because TypeScript can't narrow the discriminated union (toolPart as DynamicToolPartAvailable).state = "output-available"; (toolPart as DynamicToolPartAvailable).output = data.result; + + // Update TODO state if this was a successful todo_write + if ( + data.toolName === "todo_write" && + typeof data.result === "object" && + data.result !== null && + "success" in data.result && + data.result.success + ) { + const args = toolPart.input as { todos: TodoItem[] }; + this.currentTodos = args.todos; + } } this.invalidateCache(); } diff --git a/src/utils/tools/toolDefinitions.ts b/src/utils/tools/toolDefinitions.ts index 875a6a849..14b33a2c2 100644 --- a/src/utils/tools/toolDefinitions.ts +++ b/src/utils/tools/toolDefinitions.ts @@ -160,15 +160,19 @@ export const TOOL_DEFINITIONS = { "Mark ONE task as in_progress at a time. " + "Order tasks as: completed first, then in_progress (max 1), then pending last. " + "Update frequently as work progresses to provide visibility into ongoing operations. " + - "Before finishing the stream, ensure all todos are marked as completed.", + "Before finishing your response, ensure all todos are marked as completed. " + + "Use appropriate tense in content: past tense for completed (e.g., 'Added tests'), " + + "present progressive for in_progress (e.g., 'Adding tests'), " + + "and imperative/infinitive for pending (e.g., 'Add tests').", schema: z.object({ todos: z.array( z.object({ - content: z.string().describe("Task description"), - status: z.enum(["pending", "in_progress", "completed"]).describe("Task status"), - activeForm: z + content: z .string() - .describe("Present progressive form of the task (e.g., 'Adding tests')"), + .describe( + "Task description with tense matching status: past for completed, present progressive for in_progress, imperative for pending" + ), + status: z.enum(["pending", "in_progress", "completed"]).describe("Task status"), }) ), }),