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,
+ );
+});