Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 56 additions & 5 deletions crates/agent-gateway/web/src/pages/chat/TodoListView.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
<li className="flex items-start gap-2 py-1 text-[13px] leading-5">
<li className={`flex items-start gap-2 text-[13px] leading-5 ${className ?? "py-1"}`}>
<span className="mt-0.5 shrink-0">
{todo.status === "completed" ? (
<CheckCircle2 className="h-3.5 w-3.5 text-[hsl(var(--chat-success))]" />
Expand Down Expand Up @@ -72,3 +86,40 @@ export function TodoListView(props: { todos: TodoItem[] }) {
</ul>
);
}

/** 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 (
<div className="tool-card-enter overflow-hidden rounded-[8px] border border-black/[0.06] bg-white/[0.72] shadow-sm backdrop-blur-xl dark:border-white/[0.1] dark:bg-white/[0.06] dark:shadow-none">
<div className="flex items-center gap-2 border-b border-black/[0.04] px-2.5 py-[7px] dark:border-white/[0.05]">
<div
className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-[6px]"
style={{ background: "hsl(var(--tool-list-accent) / 0.1)" }}
>
<ListChecks className="h-3 w-3" style={{ color: "hsl(var(--tool-list-accent))" }} />
</div>
<span className="font-sans text-[calc(12.5px*var(--zone-font-scale,1))] font-semibold text-foreground/90">
{t("chat.tool.todoTitle")}
</span>
</div>
{todos.length === 0 ? (
<div className="px-3 py-2 text-[13px] text-muted-foreground">
{t("chat.tool.todoEmpty")}
</div>
) : (
<ul className="divide-y divide-black/[0.06] dark:divide-white/[0.06]">
{todos.map((todo, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: todos are a full-replace snapshot with no stable id
<TodoRow className="px-3 py-1.5" key={index} todo={todo} />
))}
</ul>
)}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -214,6 +215,10 @@ export const RoundContent = memo(function RoundContent(props: {
return null;
}

if (!isRedactedToolContent && shouldRenderTodoInline(block.item)) {
return <TodoListBlock key={block.key} item={block.item} />;
}

return (
<MemoToolCallItem
key={block.key}
Expand Down
48 changes: 48 additions & 0 deletions crates/agent-gateway/web/test/todo-list-view.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fileURLToPath } from "node:url";

import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs";

const rootDir = fileURLToPath(new URL("../", import.meta.url));
const loader = createWebModuleLoader({ rootDir });
const { resolveTodoItems, shouldRenderTodoInline } = loader.loadModule(
"src/pages/chat/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,
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -197,6 +198,10 @@ export const RoundContent = memo(function RoundContent(props: {
return null;
}

if (shouldRenderTodoInline(block.item)) {
return <TodoListBlock key={block.key} item={block.item} />;
}

return (
<MemoToolCallItem
key={block.key}
Expand Down
Original file line number Diff line number Diff line change
@@ -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/messages/uiMessages";
import type { TodoItem, TodoWriteResultDetails } from "../../../../lib/tools/builtinTypes";
import { getBuiltinResultKind } from "./assistantBubbleUtils";

/**
* Defensive shape filter for rendering todos straight from streaming tool-call
Expand All @@ -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 (
<li className="flex items-start gap-2 py-1 text-[13px] leading-5">
<li className={`flex items-start gap-2 text-[13px] leading-5 ${className ?? "py-1"}`}>
<span className="mt-0.5 shrink-0">
{todo.status === "completed" ? (
<CheckCircle2 className="h-3.5 w-3.5 text-[hsl(var(--chat-success))]" />
Expand Down Expand Up @@ -72,3 +86,40 @@ export function TodoListView(props: { todos: TodoItem[] }) {
</ul>
);
}

/** 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 (
<div className="tool-card-enter overflow-hidden rounded-[8px] border border-black/[0.06] bg-white/[0.72] shadow-sm backdrop-blur-xl dark:border-white/[0.1] dark:bg-white/[0.06] dark:shadow-none">
<div className="flex items-center gap-2 border-b border-black/[0.04] px-2.5 py-[7px] dark:border-white/[0.05]">
<div
className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-[6px]"
style={{ background: "hsl(var(--tool-list-accent) / 0.1)" }}
>
<ListChecks className="h-3 w-3" style={{ color: "hsl(var(--tool-list-accent))" }} />
</div>
<span className="font-sans text-[calc(12.5px*var(--zone-font-scale,1))] font-semibold text-foreground/90">
{t("chat.tool.todoTitle")}
</span>
</div>
{todos.length === 0 ? (
<div className="px-3 py-2 text-[13px] text-muted-foreground">
{t("chat.tool.todoEmpty")}
</div>
) : (
<ul className="divide-y divide-black/[0.06] dark:divide-white/[0.06]">
{todos.map((todo, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: todos are a full-replace snapshot with no stable id
<TodoRow className="px-3 py-1.5" key={index} todo={todo} />
))}
</ul>
)}
</div>
);
}
46 changes: 46 additions & 0 deletions crates/agent-gui/test/chat/todo-list-view.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
);
});
Loading