From 9e45736298c96ea4122632b353e83326b8adb33c Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:52:08 -0700 Subject: [PATCH] fix(gui): default todo panel to collapsed --- apps/web/tests/todo-display.snapshot.ts | 25 +++++++------------ .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/skeleton/TodoPanel.tsx | 2 +- .../ui-conversation/tests/todo-panel.spec.tsx | 14 ++++++++--- 6 files changed, 25 insertions(+), 24 deletions(-) diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts index 2ae5f0e402..8d6f7833b2 100644 --- a/apps/web/tests/todo-display.snapshot.ts +++ b/apps/web/tests/todo-display.snapshot.ts @@ -147,27 +147,14 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn }).toMatchInlineSnapshot(` { "panelHeader": "To-dos1/3 tasks · 1 in progress", - "panelItems": [ - { - "status": "completed", - "text": "梳理需求", - }, - { - "status": "in_progress", - "text": "实现 fixture 样本", - }, - { - "status": "pending", - "text": "浏览器验收", - }, - ], + "panelItems": [], "row": "更新任务清单1/3 已完成 · 实现 fixture 样本", "rowState": "ok", } `) }) -it('collapses the plan strip to the count summary and restores it', async () => { +it('expands the default-collapsed plan strip and restores its folded state', async () => { boot() await openFixtureSession() @@ -176,19 +163,25 @@ it('collapses the plan strip to the count summary and restores it', async () => const header = panel.querySelector('button') if (header === null) throw new Error('todo panel header missing') - fireEvent.click(header) expect({ collapsedHeader: visibleText(header), + expanded: header.getAttribute('aria-expanded'), listGone: panel.querySelector('ul') === null, }).toMatchInlineSnapshot(` { "collapsedHeader": "To-dos1/3 tasks · 1 in progress", + "expanded": "false", "listGone": true, } `) fireEvent.click(header) expect(panel.querySelectorAll('li')).toHaveLength(3) + expect(header.getAttribute('aria-expanded')).toBe('true') + + fireEvent.click(header) + expect(panel.querySelector('ul')).toBeNull() + expect(header.getAttribute('aria-expanded')).toBe('false') }) it('hides the plan strip when the next turn starts', async () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 49e43861f3..9efd57ee1b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 85cf040a48cf43b6ee6a8978ad7110ecdffb4051 -README.zh.md: 305258e2861fb17966050e295a5b980067a59a2d +README.md: 908b8c4136cd7823b7d8fdf2da3749c26f9f1563 +README.zh.md: 8ddf04255629d44226f4a99b8174c46665f932cc diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 85cf040a48..908b8c4136 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -12,7 +12,7 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). -The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. +The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 305258e286..8ddf042556 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,7 @@ 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 -todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 +todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index dd116ed3c8..22f5786ab1 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -83,7 +83,7 @@ function progressLabel(todos: readonly TodoItem[]): string { } export function TodoPanel({ todos }: TodoPanelProps) { - const [collapsed, setCollapsed] = useState(false) + const [collapsed, setCollapsed] = useState(true) if (todos.length === 0) return null return ( diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 8cf1f45f52..65146160da 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -31,11 +31,18 @@ describe('TodoPanel', () => { expect(container.innerHTML).toBe('') }) - it('shows progress, one row per item with its status glyph', () => { + it('starts collapsed with the progress summary visible', () => { render() expect(screen.getByTestId('todo-panel')).toBeTruthy() expect(screen.getByText('To-dos')).toBeTruthy() expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy() + expect(screen.getByRole('button', { expanded: false })).toBeTruthy() + expect(screen.queryByRole('list')).toBeNull() + }) + + it('expands to show one row per item with its status glyph', () => { + render() + fireEvent.click(screen.getByRole('button', { expanded: false })) const items = screen.getAllByRole('listitem') expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending']) expect(screen.getByText('搭骨架')).toBeTruthy() @@ -44,8 +51,9 @@ describe('TodoPanel', () => { expect(items.every(li => li.querySelector('svg') !== null)).toBe(true) }) - it('collapse hides the list; expand restores; header keeps the count summary', () => { + it('collapse hides an expanded list; expand restores; header keeps the count summary', () => { render() + fireEvent.click(screen.getByRole('button', { expanded: false })) const header = screen.getByRole('button', { expanded: true }) fireEvent.click(header) expect(screen.queryByRole('list')).toBeNull() @@ -58,7 +66,7 @@ describe('TodoPanel', () => { it('collapsed header still shows zero in-progress when nothing is active', () => { render() - fireEvent.click(screen.getByRole('button', { expanded: true })) + expect(screen.getByRole('button', { expanded: false })).toBeTruthy() expect(screen.queryByText('都完了')).toBeNull() expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy() })