From a0a07e73c819aa2bea4e27ba7a334502bd35d0f9 Mon Sep 17 00:00:00 2001 From: Nonoroazoro Date: Thu, 30 Jul 2026 17:52:49 +0800 Subject: [PATCH] fix: stabilize todo_write rendering while streaming Keep incomplete todo items out of the preview, preserve cancelled states, and isolate parsing from generic tool-call rendering. --- .../message/message-list-item.tsx | 1 + .../message/todo-write-input.ts | 63 ++++++++ .../message/todo-write-view.tsx | 146 +++++++----------- .../message/tool-call-input-view.tsx | 17 +- .../message/tool-call-list-item.tsx | 7 +- .../message/todo-write-input.test.ts | 74 +++++++++ .../message/tool-call-input-view.test.tsx | 61 ++++++++ 7 files changed, 270 insertions(+), 99 deletions(-) create mode 100644 packages/ui/src/components/thread-playground/message/todo-write-input.ts create mode 100644 packages/ui/tests/components/thread-playground/message/todo-write-input.test.ts create mode 100644 packages/ui/tests/components/thread-playground/message/tool-call-input-view.test.tsx diff --git a/packages/ui/src/components/thread-playground/message/message-list-item.tsx b/packages/ui/src/components/thread-playground/message/message-list-item.tsx index 4c89f822..0bd501a7 100644 --- a/packages/ui/src/components/thread-playground/message/message-list-item.tsx +++ b/packages/ui/src/components/thread-playground/message/message-list-item.tsx @@ -278,6 +278,7 @@ function _MessageListItem({ canContinue={toolCallSummary?.canContinue ?? false} onContinue={handleContinue} readonly={readonly} + streaming={streaming === true} toolCall={toolCall} /> ))} diff --git a/packages/ui/src/components/thread-playground/message/todo-write-input.ts b/packages/ui/src/components/thread-playground/message/todo-write-input.ts new file mode 100644 index 00000000..b4b6435c --- /dev/null +++ b/packages/ui/src/components/thread-playground/message/todo-write-input.ts @@ -0,0 +1,63 @@ +import type { ToolCallInput } from "@llm-space/core"; + +type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled"; + +export interface TodoItem { + readonly content: string; + readonly status: TodoStatus; +} + +/** + * Parse a todo_write input for its current rendering phase. + * + * Streaming input keeps the specialized view mounted and omits incomplete + * items. Static malformed input returns null so callers can use the generic + * tool input view. + */ +export function parseTodoWriteInput( + input: ToolCallInput, + streaming: boolean +): readonly TodoItem[] | null { + if (input.name !== "todo_write") { + return null; + } + const rawTodos = (input.arguments as Record)?.todos; + if (!Array.isArray(rawTodos)) { + return streaming ? [] : null; + } + + const todos: TodoItem[] = []; + for (const rawTodo of rawTodos) { + const todo = _parseTodo(rawTodo); + if (todo === null) { + if (streaming) { + continue; + } + return null; + } + todos.push(todo); + } + if (todos.length === 0 && !streaming) { + return null; + } + return todos; +} + +function _parseTodo(rawTodo: unknown): TodoItem | null { + if (typeof rawTodo !== "object" || rawTodo === null) { + return null; + } + const todo = rawTodo as Record; + if (typeof todo.content !== "string" || todo.content === "") { + return null; + } + if ( + todo.status !== "pending" && + todo.status !== "in_progress" && + todo.status !== "completed" && + todo.status !== "cancelled" + ) { + return null; + } + return { content: todo.content, status: todo.status }; +} diff --git a/packages/ui/src/components/thread-playground/message/todo-write-view.tsx b/packages/ui/src/components/thread-playground/message/todo-write-view.tsx index 3e8da8ef..bd77cc96 100644 --- a/packages/ui/src/components/thread-playground/message/todo-write-view.tsx +++ b/packages/ui/src/components/thread-playground/message/todo-write-view.tsx @@ -1,130 +1,69 @@ -import { type ToolCallInput } from "@llm-space/core"; -import { CheckIcon, EyeIcon } from "lucide-react"; -import { memo, useMemo, useState } from "react"; +import { CheckIcon, XIcon } from "lucide-react"; +import { memo } from "react"; -import { PreviewDialog } from "@llm-space/ui/components/preview-dialog-lazy"; -import { Tooltip } from "@llm-space/ui/components/tooltip"; import { cn } from "@llm-space/ui/lib/utils"; -import { Button } from "@llm-space/ui/ui/button"; +import type { TodoItem } from "./todo-write-input"; -type TodoStatus = "pending" | "in_progress" | "completed"; - -interface TodoItem { - content: string; - status: TodoStatus; -} +type TodoStatus = TodoItem["status"]; /** - * Validate a tool call's input against the `todo_write` shape and, on success, - * return the normalized todo list. Returns `null` for any other tool or a - * malformed payload, so the caller falls back to the default input view. + * A read-only, card-style rendering of a `todo_write` call. */ -export function parseTodoWriteInput(input: ToolCallInput): TodoItem[] | null { - if (input.name !== "todo_write") { - return null; - } - const rawTodos = (input.arguments as Record)?.todos; - if (!Array.isArray(rawTodos) || rawTodos.length === 0) { - return null; - } - - const todos: TodoItem[] = []; - for (const rawTodo of rawTodos) { - if (typeof rawTodo !== "object" || rawTodo === null) { - return null; - } - const t = rawTodo as Record; - if (typeof t.content !== "string" || t.content === "") { - return null; - } - todos.push({ - content: t.content, - status: _normalizeStatus(t.status), - }); - } - return todos; -} - -function _normalizeStatus(value: unknown): TodoStatus { - return value === "completed" || value === "in_progress" ? value : "pending"; -} - -/** - * A read-only, card-style rendering of a `todo_write` call: each todo is a row - * with a leading status affordance — an empty circle (pending), a spinner - * (in_progress), or a filled check (completed, struck through and dimmed). - */ -function _TodoWriteView({ - todos, - input, -}: { - todos: TodoItem[]; - input: ToolCallInput; -}) { - const [previewOpen, setPreviewOpen] = useState(false); - const previewValue = useMemo( - () => JSON.stringify(input.arguments, null, 2) ?? "", - [input.arguments] - ); +function _TodoWriteView({ todos }: { todos: readonly TodoItem[] }) { return ( -
-
+
+
todo_write () -
- - - -
-
    {todos.map((todo, index) => ( - + ))}
); } -export const TodoWriteView = memo(_TodoWriteView); +export const TodoWriteView = memo(_TodoWriteView, (previous, next) => + _areTodosEqual(previous.todos, next.todos) +); -function TodoRow({ todo }: { todo: TodoItem }) { - const completed = todo.status === "completed"; - const inProgress = todo.status === "in_progress"; +function _TodoRow({ + content, + status, +}: { + content: string; + status: TodoStatus; +}) { + const completed = status === "completed"; + const cancelled = status === "cancelled"; + const inProgress = status === "in_progress"; return (
  • - + <_TodoStatusIcon status={status} /> - {todo.content} + {content}
  • ); } +const TodoRow = memo(_TodoRow); -function TodoStatusIcon({ status }: { status: TodoStatus }) { +function _TodoStatusIcon({ status }: { status: TodoStatus }) { if (status === "completed") { return ( @@ -139,7 +78,28 @@ function TodoStatusIcon({ status }: { status: TodoStatus }) { ); } + if (status === "cancelled") { + return ( + + + + ); + } return ( ); } + +function _areTodosEqual( + previous: readonly TodoItem[], + next: readonly TodoItem[] +): boolean { + return ( + previous.length === next.length && + previous.every( + (todo, index) => + todo.content === next[index]?.content && + todo.status === next[index]?.status + ) + ); +} diff --git a/packages/ui/src/components/thread-playground/message/tool-call-input-view.tsx b/packages/ui/src/components/thread-playground/message/tool-call-input-view.tsx index 4bb7ab38..2d87302e 100644 --- a/packages/ui/src/components/thread-playground/message/tool-call-input-view.tsx +++ b/packages/ui/src/components/thread-playground/message/tool-call-input-view.tsx @@ -17,7 +17,8 @@ import { } from "@llm-space/ui/ui/dropdown-menu"; -import { parseTodoWriteInput, TodoWriteView } from "./todo-write-view"; +import { parseTodoWriteInput } from "./todo-write-input"; +import { TodoWriteView } from "./todo-write-view"; /** * Built-in `fs` tools whose `path` argument is an absolute on-disk path worth @@ -88,10 +89,16 @@ function _linkKindFor( return undefined; } -function _ToolCallInputView({ input }: { input: ToolCallInput }) { - const todos = parseTodoWriteInput(input); - if (todos) { - return ; +function _ToolCallInputView({ + input, + streaming, +}: { + input: ToolCallInput; + streaming: boolean; +}) { + const todos = parseTodoWriteInput(input, streaming); + if (todos !== null) { + return ; } const args = input.arguments as Record; diff --git a/packages/ui/src/components/thread-playground/message/tool-call-list-item.tsx b/packages/ui/src/components/thread-playground/message/tool-call-list-item.tsx index 35713f78..df062618 100644 --- a/packages/ui/src/components/thread-playground/message/tool-call-list-item.tsx +++ b/packages/ui/src/components/thread-playground/message/tool-call-list-item.tsx @@ -50,6 +50,7 @@ function _ToolCallListItem({ canContinue, onContinue, readonly = false, + streaming, }: { context?: ThreadContext; messageId: string; @@ -57,6 +58,7 @@ function _ToolCallListItem({ canContinue: boolean; onContinue: () => void; readonly?: boolean; + streaming: boolean; }) { const { fidelity } = useRenderingFidelity(); const { presentational } = useHostServices(); @@ -139,7 +141,10 @@ function _ToolCallListItem({ return (
    - +