diff --git a/crates/agent-gateway/web/src/pages/chat/TodoListView.tsx b/crates/agent-gateway/web/src/pages/chat/TodoListView.tsx index 528d79bf3..e1b5597ed 100644 --- a/crates/agent-gateway/web/src/pages/chat/TodoListView.tsx +++ b/crates/agent-gateway/web/src/pages/chat/TodoListView.tsx @@ -1,6 +1,8 @@ -import { CheckCircle2, Circle, Loader2 } from "../../components/icons"; +import { CheckCircle2, Circle, ListChecks, Loader2 } from "../../components/icons"; import { useLocale } from "../../i18n"; -import type { TodoItem } from "../../lib/tools/builtinTypes"; +import type { ToolTraceItem } from "../../lib/chat/uiMessages"; +import type { TodoItem, TodoWriteResultDetails } from "../../lib/tools/builtinTypes"; +import { getBuiltinResultKind } from "./assistant-bubble/assistantBubbleUtils"; /** * Defensive shape filter for rendering todos straight from streaming tool-call @@ -22,12 +24,24 @@ export function sanitizeTodoItems(value: unknown): TodoItem[] { }); } -function TodoRow(props: { todo: TodoItem }) { - const { todo } = props; +export function shouldRenderTodoInline(item: ToolTraceItem): boolean { + return item.toolCall.name === "TodoWrite" && !item.toolResult?.isError; +} + +export function resolveTodoItems(item: ToolTraceItem): TodoItem[] { + const result = item.toolResult; + if (result && !result.isError && getBuiltinResultKind(result) === "todo_write") { + return sanitizeTodoItems((result.details as TodoWriteResultDetails).todos); + } + return sanitizeTodoItems(item.toolCall.arguments?.todos); +} + +function TodoRow(props: { todo: TodoItem; className?: string }) { + const { todo, className } = props; const label = todo.status === "in_progress" ? todo.activeForm : todo.content; return ( -
  • +
  • {todo.status === "completed" ? ( @@ -72,3 +86,40 @@ export function TodoListView(props: { todos: TodoItem[] }) { ); } + +/** A settled or streaming TodoWrite rendered directly in the assistant reply flow. */ +export function TodoListBlock({ item }: { item: ToolTraceItem }) { + const { t } = useLocale(); + const todos = resolveTodoItems(item); + + // Avoid flashing an empty frame while streaming arguments are incomplete. + if (!item.toolResult && todos.length === 0) return null; + + return ( +
    +
    +
    + +
    + + {t("chat.tool.todoTitle")} + +
    + {todos.length === 0 ? ( +
    + {t("chat.tool.todoEmpty")} +
    + ) : ( +
      + {todos.map((todo, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: todos are a full-replace snapshot with no stable id + + ))} +
    + )} +
    + ); +} diff --git a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx index 33c47ddda..eb23dfbef 100644 --- a/crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx +++ b/crates/agent-gateway/web/src/pages/chat/assistant-bubble/RoundContent.tsx @@ -4,6 +4,7 @@ import { Markdown } from "../../../components/Markdown"; import { useLocale } from "../../../i18n"; import { normalizeLiveToolStatus, VIBING_STATUS } from "../../../lib/chat/chatPageHelpers"; import type { ToolTraceItem, UiRound } from "../../../lib/chat/uiMessages"; +import { shouldRenderTodoInline, TodoListBlock } from "../TodoListView"; import { groupRoundBlocks, isBuiltinShareToolName } from "./assistantBubbleUtils"; import { HostedSearchGroupView } from "./HostedSearchGroupView"; import { LazyCollapse } from "./LazyCollapse"; @@ -214,6 +215,10 @@ export const RoundContent = memo(function RoundContent(props: { return null; } + if (!isRedactedToolContent && shouldRenderTodoInline(block.item)) { + return ; + } + return ( { + assert.equal(shouldRenderTodoInline(item()), true); + assert.equal( + shouldRenderTodoInline( + item({ result: { isError: false, details: { kind: "todo_write", todos } } }), + ), + true, + ); +}); + +test("failed TodoWrite and other tools keep the regular tool card", () => { + assert.equal(shouldRenderTodoInline(item({ result: { isError: true } })), false); + assert.equal(shouldRenderTodoInline(item({ name: "Read" })), false); +}); + +test("settled result todos take precedence over streaming arguments", () => { + const settled = [{ content: "Done", status: "completed", activeForm: "Doing" }]; + assert.deepEqual( + resolveTodoItems( + item({ result: { isError: false, details: { kind: "todo_write", todos: settled } } }), + ), + settled, + ); +}); diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx index 553afbc3b..a80d24cde 100644 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx +++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/RoundContent.tsx @@ -9,6 +9,7 @@ import { groupRoundBlocks } from "./assistantBubbleUtils"; import { HostedSearchGroupView } from "./HostedSearchGroupView"; import { LazyCollapse } from "./LazyCollapse"; import { AssistantStatus, CompactingText, VibingText } from "./StatusText"; +import { shouldRenderTodoInline, TodoListBlock } from "./TodoListView"; import { MemoToolCallItem } from "./ToolCallItem"; import { getNativeDisplayImagePayload, NativeDisplayImageBlock } from "./ToolImages"; import { ToolTraceGroup } from "./ToolTraceGroup"; @@ -197,6 +198,10 @@ export const RoundContent = memo(function RoundContent(props: { return null; } + if (shouldRenderTodoInline(block.item)) { + return ; + } + return ( +
  • {todo.status === "completed" ? ( @@ -72,3 +86,40 @@ export function TodoListView(props: { todos: TodoItem[] }) { ); } + +/** A settled or streaming TodoWrite rendered directly in the assistant reply flow. */ +export function TodoListBlock({ item }: { item: ToolTraceItem }) { + const { t } = useLocale(); + const todos = resolveTodoItems(item); + + // Avoid flashing an empty frame while streaming arguments are incomplete. + if (!item.toolResult && todos.length === 0) return null; + + return ( +
    +
    +
    + +
    + + {t("chat.tool.todoTitle")} + +
    + {todos.length === 0 ? ( +
    + {t("chat.tool.todoEmpty")} +
    + ) : ( +
      + {todos.map((todo, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: todos are a full-replace snapshot with no stable id + + ))} +
    + )} +
    + ); +} diff --git a/crates/agent-gui/test/chat/todo-list-view.test.mjs b/crates/agent-gui/test/chat/todo-list-view.test.mjs new file mode 100644 index 000000000..78cc9198f --- /dev/null +++ b/crates/agent-gui/test/chat/todo-list-view.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const { resolveTodoItems, shouldRenderTodoInline } = loader.loadModule( + "src/pages/chat/components/assistant-bubble/TodoListView.tsx", +); + +const todos = [ + { content: "Inspect", status: "completed", activeForm: "Inspecting" }, + { content: "Fix", status: "in_progress", activeForm: "Fixing" }, +]; + +function item({ name = "TodoWrite", result } = {}) { + return { + toolCall: { type: "toolCall", id: "todo-1", name, arguments: { todos } }, + ...(result ? { toolResult: result } : {}), + }; +} + +test("TodoWrite renders inline while streaming and after success", () => { + assert.equal(shouldRenderTodoInline(item()), true); + assert.equal( + shouldRenderTodoInline( + item({ result: { isError: false, details: { kind: "todo_write", todos } } }), + ), + true, + ); +}); + +test("failed TodoWrite and other tools keep the regular tool card", () => { + assert.equal(shouldRenderTodoInline(item({ result: { isError: true } })), false); + assert.equal(shouldRenderTodoInline(item({ name: "Read" })), false); +}); + +test("settled result todos take precedence over streaming arguments", () => { + const settled = [{ content: "Done", status: "completed", activeForm: "Doing" }]; + assert.deepEqual( + resolveTodoItems( + item({ result: { isError: false, details: { kind: "todo_write", todos: settled } } }), + ), + settled, + ); +});