From 73f8e89991447409a0bcfaf777333db471ce85f4 Mon Sep 17 00:00:00 2001 From: tendtoyj Date: Tue, 10 Feb 2026 22:33:56 +0900 Subject: [PATCH 1/4] feat(frontend): migrate write_todos to step-row todo block --- .../components/ai-elements/todo-checkbox.tsx | 51 +++++++++++++ .../ai-elements/todoCheckboxModel.ts | 15 ++++ .../chat/renderers/ToolRenderer.tsx | 76 +++++++++++-------- .../__tests__/todoCheckboxModel.test.ts | 19 +++++ .../__tests__/toolTodoViewModel.test.ts | 30 ++++++++ .../chat/renderers/toolTodoViewModel.ts | 28 +++++++ 6 files changed, 188 insertions(+), 31 deletions(-) create mode 100644 frontend/pluto_duck_frontend/components/ai-elements/todo-checkbox.tsx create mode 100644 frontend/pluto_duck_frontend/components/ai-elements/todoCheckboxModel.ts create mode 100644 frontend/pluto_duck_frontend/components/chat/renderers/__tests__/todoCheckboxModel.test.ts create mode 100644 frontend/pluto_duck_frontend/components/chat/renderers/__tests__/toolTodoViewModel.test.ts create mode 100644 frontend/pluto_duck_frontend/components/chat/renderers/toolTodoViewModel.ts diff --git a/frontend/pluto_duck_frontend/components/ai-elements/todo-checkbox.tsx b/frontend/pluto_duck_frontend/components/ai-elements/todo-checkbox.tsx new file mode 100644 index 00000000..62a4c46c --- /dev/null +++ b/frontend/pluto_duck_frontend/components/ai-elements/todo-checkbox.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { memo, type ComponentProps } from "react"; +import { + getTodoCheckboxContainerClass, + IN_PROGRESS_TODO_GLYPH, + type TodoCheckboxStatus, +} from "./todoCheckboxModel"; + +export type TodoCheckboxProps = ComponentProps<"span"> & { + status?: TodoCheckboxStatus; +}; + +export const TodoCheckbox = memo( + ({ status = "pending", className, ...props }: TodoCheckboxProps) => ( + + {status === "in_progress" ? ( + + {IN_PROGRESS_TODO_GLYPH} + + ) : null} + {status === "completed" ? ( + + + + ) : null} + + ) +); + +TodoCheckbox.displayName = "TodoCheckbox"; diff --git a/frontend/pluto_duck_frontend/components/ai-elements/todoCheckboxModel.ts b/frontend/pluto_duck_frontend/components/ai-elements/todoCheckboxModel.ts new file mode 100644 index 00000000..f00892e2 --- /dev/null +++ b/frontend/pluto_duck_frontend/components/ai-elements/todoCheckboxModel.ts @@ -0,0 +1,15 @@ +export type TodoCheckboxStatus = "pending" | "in_progress" | "completed"; + +export const IN_PROGRESS_TODO_GLYPH = "\u2734\uFE0E"; + +export function getTodoCheckboxContainerClass( + status: TodoCheckboxStatus +): string { + if (status === "completed") { + return "border-0 bg-muted-foreground/60 text-white"; + } + if (status === "in_progress") { + return "border-[1.5px] border-muted-foreground/50 bg-transparent"; + } + return "border-[1.5px] border-muted-foreground/30 bg-transparent"; +} diff --git a/frontend/pluto_duck_frontend/components/chat/renderers/ToolRenderer.tsx b/frontend/pluto_duck_frontend/components/chat/renderers/ToolRenderer.tsx index ebd36e30..741b49ca 100644 --- a/frontend/pluto_duck_frontend/components/chat/renderers/ToolRenderer.tsx +++ b/frontend/pluto_duck_frontend/components/chat/renderers/ToolRenderer.tsx @@ -1,6 +1,7 @@ 'use client'; import { memo } from 'react'; +import { ChevronDownIcon } from 'lucide-react'; import { Tool, ToolHeader, @@ -8,15 +9,20 @@ import { ToolInput, ToolOutput, } from '../../ai-elements/tool'; +import { StepDot } from '../../ai-elements/step-dot'; +import { TodoCheckbox } from '../../ai-elements/todo-checkbox'; import { - Queue, - QueueList, - QueueItem, - QueueItemIndicator, - QueueItemContent, -} from '../../ai-elements/queue'; + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '../../ui/collapsible'; import type { ToolItem } from '../../../types/chatRenderItem'; import { parseTodosFromToolPayload } from './toolTodoParser'; +import { + getToolTodoStepPhase, + getToolTodoTextClass, + shouldShowToolTodoChevron, +} from './toolTodoViewModel'; /** * Convert snake_case or camelCase to Title Case @@ -154,38 +160,46 @@ export const ToolRenderer = memo(function ToolRenderer({ // Special handling for write_todos tool if (item.toolName === 'write_todos') { const todos = parseTodosFromToolPayload(item.input, item.output); - const completedCount = todos.filter(t => t.status === 'completed').length; - const isLoading = item.state === 'pending'; + const showChevron = shouldShowToolTodoChevron(item.state); return ( - -
- - {isLoading ? 'Updating todos...' : `Tasks (${completedCount}/${todos.length})`} + + + + + Update Todos -
+ {showChevron ? ( + + ) : ( + + )} + {todos.length > 0 && ( - - {todos.map((todo) => ( - - - - {todo.title} - - - ))} - + +
+ {todos.map((todo) => ( +
+ + + {todo.title} + +
+ ))} +
+
)} {item.error && ( -
{item.error}
+
+ {item.error} +
)} -
+ ); } diff --git a/frontend/pluto_duck_frontend/components/chat/renderers/__tests__/todoCheckboxModel.test.ts b/frontend/pluto_duck_frontend/components/chat/renderers/__tests__/todoCheckboxModel.test.ts new file mode 100644 index 00000000..e6fd0672 --- /dev/null +++ b/frontend/pluto_duck_frontend/components/chat/renderers/__tests__/todoCheckboxModel.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + getTodoCheckboxContainerClass, + IN_PROGRESS_TODO_GLYPH, +} from '../../../ai-elements/todoCheckboxModel.ts'; + +test('completed todo checkbox has no border class', () => { + const cls = getTodoCheckboxContainerClass('completed'); + assert.equal(cls.includes('border-0'), true); + assert.equal(cls.includes('border-[1.5px]'), false); +}); + +test('in-progress todo checkbox uses border + eight pointed black star', () => { + const cls = getTodoCheckboxContainerClass('in_progress'); + assert.equal(cls.includes('border-[1.5px]'), true); + assert.equal(IN_PROGRESS_TODO_GLYPH, '✴︎'); +}); diff --git a/frontend/pluto_duck_frontend/components/chat/renderers/__tests__/toolTodoViewModel.test.ts b/frontend/pluto_duck_frontend/components/chat/renderers/__tests__/toolTodoViewModel.test.ts new file mode 100644 index 00000000..f6d5a7fe --- /dev/null +++ b/frontend/pluto_duck_frontend/components/chat/renderers/__tests__/toolTodoViewModel.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + getToolTodoStepPhase, + getToolTodoTextClass, + shouldShowToolTodoChevron, +} from '../toolTodoViewModel.ts'; + +test('maps tool todo state to step dot phase', () => { + assert.equal(getToolTodoStepPhase('pending'), 'running'); + assert.equal(getToolTodoStepPhase('completed'), 'complete'); + assert.equal(getToolTodoStepPhase('error'), 'error'); +}); + +test('shows chevron only when tool todo state is completed', () => { + assert.equal(shouldShowToolTodoChevron('pending'), false); + assert.equal(shouldShowToolTodoChevron('completed'), true); + assert.equal(shouldShowToolTodoChevron('error'), false); +}); + +test('returns text classes by todo item status', () => { + assert.equal(getToolTodoTextClass('pending'), 'text-muted-foreground'); + assert.equal(getToolTodoTextClass('in_progress'), 'text-foreground'); + assert.equal( + getToolTodoTextClass('completed'), + 'text-muted-foreground line-through' + ); + assert.equal(getToolTodoTextClass(undefined), 'text-muted-foreground'); +}); diff --git a/frontend/pluto_duck_frontend/components/chat/renderers/toolTodoViewModel.ts b/frontend/pluto_duck_frontend/components/chat/renderers/toolTodoViewModel.ts new file mode 100644 index 00000000..d0adbe66 --- /dev/null +++ b/frontend/pluto_duck_frontend/components/chat/renderers/toolTodoViewModel.ts @@ -0,0 +1,28 @@ +export type ToolTodoState = "pending" | "completed" | "error"; +export type ToolTodoStatus = "pending" | "in_progress" | "completed"; + +export function getToolTodoStepPhase( + state: ToolTodoState +): "running" | "complete" | "error" { + if (state === "error") { + return "error"; + } + if (state === "pending") { + return "running"; + } + return "complete"; +} + +export function shouldShowToolTodoChevron(state: ToolTodoState): boolean { + return state === "completed"; +} + +export function getToolTodoTextClass(status: ToolTodoStatus | undefined): string { + if (status === "completed") { + return "text-muted-foreground line-through"; + } + if (status === "in_progress") { + return "text-foreground"; + } + return "text-muted-foreground"; +} From db82421cf1bd1faf58977a3eb771cf281f405f5a Mon Sep 17 00:00:00 2001 From: tendtoyj Date: Tue, 10 Feb 2026 22:38:07 +0900 Subject: [PATCH 2/4] feat(frontend): style approval card with prototype motion --- frontend/pluto_duck_frontend/app/globals.css | 21 +++++++++++++++++++ .../chat/renderers/ApprovalRenderer.tsx | 12 ++++++----- .../__tests__/chatItemPadding.test.ts | 2 +- .../chat/renderers/chatItemPadding.ts | 2 +- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/frontend/pluto_duck_frontend/app/globals.css b/frontend/pluto_duck_frontend/app/globals.css index c510cac1..de2fef08 100644 --- a/frontend/pluto_duck_frontend/app/globals.css +++ b/frontend/pluto_duck_frontend/app/globals.css @@ -274,6 +274,27 @@ animation: stepIn 0.4s cubic-bezier(0.23, 1, 0.32, 1) forwards; } +@keyframes cardIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-card-in { + animation: cardIn 0.5s cubic-bezier(0.23, 1, 0.32, 1) forwards; +} + +@media (prefers-reduced-motion: reduce) { + .animate-card-in { + animation: none !important; + } +} + .animate-text-glow { animation: textGlow 2.2s ease-in-out infinite; } diff --git a/frontend/pluto_duck_frontend/components/chat/renderers/ApprovalRenderer.tsx b/frontend/pluto_duck_frontend/components/chat/renderers/ApprovalRenderer.tsx index 83d75eb1..cdea7993 100644 --- a/frontend/pluto_duck_frontend/components/chat/renderers/ApprovalRenderer.tsx +++ b/frontend/pluto_duck_frontend/components/chat/renderers/ApprovalRenderer.tsx @@ -24,9 +24,9 @@ export const ApprovalRenderer = memo(function ApprovalRenderer({ const isPending = item.decision === 'pending'; return ( -
+
- Approval + Approval
-

{summary}

+

+ {summary} +

{isPending && (