diff --git a/apps/desktop/src/components/TodoDock.tsx b/apps/desktop/src/components/TodoDock.tsx index 63cfc561fd..905316d361 100644 --- a/apps/desktop/src/components/TodoDock.tsx +++ b/apps/desktop/src/components/TodoDock.tsx @@ -6,8 +6,6 @@ import { IconCheck, IconChevronDown, IconChevronUp } from "./icons"; import { useSessionTodosRecovery } from "../features/chat/todos/useSessionTodosRecovery"; import { useAppStore } from "../stores/app-store"; -const VISIBLE_LIMIT = 8; - function statusSymbol(status: TodoStatus): string { switch (status) { case "in_progress": @@ -38,8 +36,6 @@ export function TodoDock({ sessionId }: { sessionId: string }) { const finished = snapshot.todos.every( (todo) => todo.status === "completed" || todo.status === "cancelled", ); - const visible = snapshot.todos.slice(0, VISIBLE_LIMIT); - const remaining = Math.max(0, snapshot.todos.length - VISIBLE_LIMIT); const statusLabel = allCancelled ? t("chat.todo.status.cancelled") : finished @@ -65,15 +61,18 @@ export function TodoDock({ sessionId }: { sessionId: string }) {
-
- {visible.map((todo, index) => ( + {/* The expanded dock lists every row (#1319): the list scrolls + inside its own box instead of truncating to a static "N more" + line, and a keyboard user can reach that scrollport only while + the disclosure is open. */} +
+ {snapshot.todos.map((todo, index) => ( ))} - {remaining > 0 ? ( -
- {t("chat.todo.more", { count: remaining })} -
- ) : null}
diff --git a/apps/desktop/src/styles/composer.css b/apps/desktop/src/styles/composer.css index d707048540..51ac04000f 100644 --- a/apps/desktop/src/styles/composer.css +++ b/apps/desktop/src/styles/composer.css @@ -833,6 +833,22 @@ padding: 0 10px 10px; } +/* The expanded dock lists every row and scrolls inside its own box (#1319): + the Composer keeps its footprint, and reaching the list's end never scrolls + the transcript behind it. */ +.todo-dock.is-expanded .todo-dock-list { + max-height: min(280px, 30dvh); + overflow-y: auto; + overscroll-behavior: contain; +} + +/* The scrollport is keyboard reachable while it is on screen; the inset ring + stays inside the clip layer that carries the disclosure animation. */ +.todo-dock-list:focus-visible { + outline: 2px solid color-mix(in oklab, var(--ds-accent) 72%, transparent); + outline-offset: -2px; +} + .todo-dock-row { display: flex; min-width: 0; @@ -876,11 +892,6 @@ white-space: nowrap; } -.todo-dock-more { - color: var(--ds-text-muted); - font-size: var(--text-2xs); -} - @media (prefers-reduced-motion: reduce) { .composer-mode-chip[data-planning="true"] svg { animation: none; diff --git a/apps/desktop/test/todo-dock-rendering.test.mjs b/apps/desktop/test/todo-dock-rendering.test.mjs index 66d91d4557..adaae6d55a 100644 --- a/apps/desktop/test/todo-dock-rendering.test.mjs +++ b/apps/desktop/test/todo-dock-rendering.test.mjs @@ -54,7 +54,11 @@ test("TodoDock renders bounded session progress and cancelled state", async () = assert.match(html, /class="todo-dock-content" aria-hidden="true"/); assert.match(html, /class="todo-dock-content" aria-hidden="true">
/); assert.match(html, /extra-0/); - assert.match(html, /4 more items/); + // The expanded dock lists every row (#1319): the whole list is mounted, and + // the dock scrolls inside instead of printing a static overflow line. + assert.equal((html.match(/role="listitem"/g) ?? []).length, 12, "every ordered row renders"); + assert.doesNotMatch(html, /more items/); + assert.match(html, /class="todo-dock-list" role="list" tabindex="-1"/); assert.match(html, /lucide-check/); Object.assign(useAppStore.getInitialState(), { @@ -83,5 +87,13 @@ test("TodoDock uses a quiet disclosure animation and state surfaces", async () = assert.match(css, /\.todo-dock-content\s*\{[\s\S]*?grid-template-rows:\s*0fr;[\s\S]*?opacity:\s*0;/); assert.match(css, /\.todo-dock\.is-expanded \.todo-dock-content\s*\{[\s\S]*?grid-template-rows:\s*1fr;/); assert.match(css, /\.todo-dock-row-completed\s*\{[\s\S]*?var\(--ds-success\)/); + // The expanded list is the scrollport (#1319): bounded height, internal + // scroll, no chaining into the transcript, and reachable from the keyboard. + assert.match( + css, + /\.todo-dock\.is-expanded \.todo-dock-list\s*\{[\s\S]*?max-height:[\s\S]*?overflow-y:\s*auto;[\s\S]*?overscroll-behavior:\s*contain;/, + ); + assert.match(css, /\.todo-dock-list:focus-visible\s*\{[\s\S]*?outline:/); + assert.doesNotMatch(css, /\.todo-dock-more\s*\{/); assert.match(css, /@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?\.todo-dock-content/); }); diff --git a/docs/adr/0312-session-scoped-todo-checklist.md b/docs/adr/0312-session-scoped-todo-checklist.md index 2ddaca72b8..5dfec9635e 100644 --- a/docs/adr/0312-session-scoped-todo-checklist.md +++ b/docs/adr/0312-session-scoped-todo-checklist.md @@ -27,8 +27,10 @@ forwards the notification through the existing IPC bridge, and the renderer keeps snapshots keyed by session id while rejecting stale revisions. The Composer TodoDock is a non-focusing, session-aware presentation surface. -It shows bounded progress and at most eight ordered rows. Remote RACP sessions -remain local-only for this vertical slice because RACP v1 has no Todo snapshot +It shows bounded progress and the complete ordered list: the expanded dock +scrolls inside its own box instead of truncating, so a long checklist stays +readable without growing the Composer stack. Remote RACP sessions remain +local-only for this vertical slice because RACP v1 has no Todo snapshot operation; the renderer skips local recovery for those session ids rather than reading the local database. @@ -46,4 +48,5 @@ reading the local database. Host-core tests cover migration, validation, transaction rollback, restart, revision ordering, fork isolation, cascade deletion, and RPC authorization. Renderer type checks and TodoDock interaction tests cover revision filtering, -remote-session degradation, session switching, expansion, and bounded display. +remote-session degradation, session switching, expansion, and full-list display +through the dock's own scrollport. diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index a83fc27084..b2646cc9c0 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -2883,16 +2883,20 @@ reasoning-level control. - The collapsed header shows completed/active progress and the current `in_progress` content. A checklist whose items are all cancelled has a clear cancelled label instead of a misleading `0/0 completed` count. -- The disclosure is keyboard accessible, does not take focus on updates, resets - closed when the active session changes, and shows at most eight ordered rows. - The list stays mounted while collapsed so opening and closing can animate with - a bounded height/opacity transition; collapsed content is `aria-hidden` and - reduced-motion users receive an immediate state change. A collapsed dock - reserves only its header row: the list's inset is clipped, never laid out - below the header. Completed rows use a success-tinted tile with a check - icon, in-progress rows use the accent tint, and cancelled rows are muted; - each status symbol has a localized accessible - name and each row renders plain text. +- The disclosure is keyboard accessible, does not take focus on updates, and + resets closed when the active session changes. The expanded dock lists every + ordered row and scrolls inside its own box (`max-height: min(280px, 30dvh)`, + `overflow-y: auto`, `overscroll-behavior: contain`), so a long checklist stays + readable without growing the Composer stack and reaching the list's end never + scrolls the transcript behind it; that scrollport is keyboard reachable only + while the disclosure is open. The list stays mounted while collapsed so + opening and closing can animate with a bounded height/opacity transition; + collapsed content is `aria-hidden` and reduced-motion users receive an + immediate state change. A collapsed dock reserves only its header row: the + list's inset is clipped, never laid out below the header. + Completed rows use a success-tinted tile with a check icon, in-progress rows + use the accent tint, and cancelled rows are muted. Each status symbol has a + localized accessible name, and each row renders plain text. - Renderer snapshots are keyed by session id. A `todos.changed` event with an older or equal revision is ignored. Session activation and host recovery re-read the authoritative snapshot, including already cached checklists; a diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index e7161ece62..be11b14972 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -9388,8 +9388,10 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. - **Steps**: Start a multi-step Agent turn that calls `TodoWrite` with ordered pending and `in_progress` items. Observe the TodoDock above the Composer, expand it, switch sessions, and confirm the checklist stays session-scoped. - Complete and cancel items, confirm the bounded eight-row display and the - all-cancelled label, then clear the checklist and reload/restart the host. + Expand a checklist longer than the dock's height cap: every row must render + and the list must scroll inside the dock without growing the Composer stack. + Complete and cancel items, confirm the all-cancelled label, then clear the + checklist and reload/restart the host. Deliver an out-of-order older `todos.changed` event and confirm it cannot replace the newer snapshot. Exercise invalid payload, Plan/Goal, delegated, and remote-session paths. @@ -9397,6 +9399,7 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. advances revision, including clear, and emits one committed `todos.changed` snapshot. Invalid or unauthorized writes do not mutate or emit. TodoDock renders plain text, does not take focus, resets expansion on session changes, + lists every ordered row with the dock's own list as the scrollport, keeps a collapsed dock to its header height, rejects stale events, and skips local recovery for `remote:` sessions because RACP v1 has no Todo snapshot operation. @@ -9410,8 +9413,9 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. the production renderer, and a real host/SQLite profile. Only the external model stream and preload transport are fixtures; no live provider or user profile is used. The scenario includes Unicode truncation with warning replay, - single-active-item normalization, a - failed initial read followed by host recovery without changing sessions, + single-active-item normalization, full-list rendering with the dock's + internal scroll, a failed initial read followed by host recovery without + changing sessions, cached-snapshot reconciliation, and stale-event rejection. Runtime `runtime-todos.test.ts` exercises Agent tool validation, overlong content normalization, and continuation through a deterministic provider. diff --git a/docs/zh-CN/spec/04-ux/08-component-spec.md b/docs/zh-CN/spec/04-ux/08-component-spec.md index 61b4eb7c51..c6c58f33ab 100644 --- a/docs/zh-CN/spec/04-ux/08-component-spec.md +++ b/docs/zh-CN/spec/04-ux/08-component-spec.md @@ -2013,7 +2013,8 @@ MainChat 底部的输入区域,用于撰写和发送提示。支持多行输 - 当活动会话拥有非空的主机清单时,TodoDock 位于 Composer 栈中并显示在 Plan/Goal 批准表面上方;空清单不占布局空间。 - 收起标题显示完成/活动进度和当前 `in_progress` 内容;全部取消时显示明确的取消状态,不显示误导性的 `0/0 completed`。 -列表在收起时仍保持挂载,但收起状态只占标题行高度(列表内边距被裁剪,不会在标题下方留白),通过有界的高度/透明度过渡播放展开和收起动画; +- 展开后列出全部条目,并在自身范围内滚动(`max-height: min(280px, 30dvh)`、`overflow-y: auto`、`overscroll-behavior: contain`):长清单因此保持可读,既不撑高 Composer 栈,滚到底也不会带动背后的转写;该滚动区仅在展开时可被键盘聚焦。 +- 列表在收起时仍保持挂载,但收起状态只占标题行高度(列表内边距被裁剪,不会在标题下方留白),通过有界的高度/透明度过渡播放展开和收起动画。 - 渲染器按 session id 保存快照并忽略旧或相同 revision。激活会话和主机恢复时重新读取权威快照,包括已缓存清单;首次读取失败不能导致主机恢复后清单持续隐藏。`remote:` 和 `native-pi:` 会话不拥有本地 Desktop 清单,跳过本地 `todos.get` 恢复;远程 Todo 对等能力留待新增 RACP 契约。 ### 11.4 状态 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index fad21938fe..10a1e5641b 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -5555,12 +5555,12 @@ eleven-tool-round desktop paths are verified by **E2E-CHAT-session-todo-checklist:TodoWrite 到按会话显示的 TodoDock** - **前提:** 隔离的本地 Electron 配置、确定性的 Agent/Host fixture、两个 Desktop 会话,不使用真实 Provider 或付费 API。 -- **步骤:** 启动调用 `TodoWrite` 的多步骤 Agent 回合,观察 Composer 上方的 TodoDock,展开后切换会话并确认清单隔离。完成和取消条目,确认最多显示八条以及全部取消状态;清空清单后重载/重启 Host。发送乱序旧 `todos.changed` 事件,确认它不能覆盖新快照;再覆盖非法参数、Plan/Goal、委托和远程会话路径。 -切换会话时收起、收起状态只占标题行高度、拒绝旧事件 +- **步骤:** 启动调用 `TodoWrite` 的多步骤 Agent 回合,观察 Composer 上方的 TodoDock,展开后切换会话并确认清单隔离。展开一个超过 dock 高度上限的清单,确认渲染全部条目、列表在 dock 内滚动而不撑高 Composer 栈;完成和取消条目,确认全部取消状态;清空清单后重载/重启 Host。发送乱序旧 `todos.changed` 事件,确认它不能覆盖新快照;再覆盖非法参数、Plan/Goal、委托和远程会话路径。 +- **预期:** 切换会话时收起、收起状态只占标题行高度、列表渲染全部条目并以自身为滚动容器、拒绝旧事件。 - **链接规格:** `03-runtime/03-tools-and-permissions.md`、`03-runtime/04-data-storage.md`、`03-runtime/06-host-rpc-protocol.md`、`04-ux/08-component-spec.md`、ADR 0312。 - **验收:** C / E / F / Quality / Security。 - **里程碑:** M6+。 -- **自动化:** `pnpm test:e2e:todos` 从生产 Agent 的 ToolSearch/TodoWrite 路径进入,使用生产渲染器、真实 Host/SQLite 和隔离 Electron 配置验证清单旅程;仅替换外部模型流和 preload 传输,不使用真实 Provider 或用户配置。覆盖 Unicode 截断及警告重放、单一活动项归一化、首次读取失败后不切换会话的主机恢复、已缓存快照重新同步及旧事件拒绝。运行时 `runtime-todos.test.ts` 使用确定性 Provider 验证 Agent 工具校验、超长内容归一化和续跑。 +- **自动化:** `pnpm test:e2e:todos` 从生产 Agent 的 ToolSearch/TodoWrite 路径进入,使用生产渲染器、真实 Host/SQLite 和隔离 Electron 配置验证清单旅程;仅替换外部模型流和 preload 传输,不使用真实 Provider 或用户配置。覆盖 Unicode 截断及警告重放、单一活动项归一化、全量清单渲染与 dock 内滚动、首次读取失败后不切换会话的主机恢复、已缓存快照重新同步及旧事件拒绝。运行时 `runtime-todos.test.ts` 使用确定性 Provider 验证 Agent 工具校验、超长内容归一化和续跑。 - **状态:** 构建 Desktop 和 Host 后,在确切的请求候选中执行。Host-core 和渲染器定向测试是辅助检查,不能替代 Electron 用户旅程。 ## 8. 可追溯性矩阵 diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts index e81a8344eb..5e5cedde74 100644 --- a/packages/i18n/src/locales/de/index.ts +++ b/packages/i18n/src/locales/de/index.ts @@ -284,7 +284,6 @@ export const de = { "progress": "{{completed}}/{{total}} erledigt", "current": "{{completed}}/{{total}} · Aktuell: {{content}}", "completed": "{{completed}}/{{total}} erledigt", - "more": "{{count}} weitere Elemente", "updated": "Checkliste aktualisiert", "updating": "Checkliste wird aktualisiert", "status": { "pending": "Offen", "in_progress": "In Bearbeitung", "completed": "Erledigt", "cancelled": "Abgebrochen" } diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts index b36374fee1..fff8171ff9 100644 --- a/packages/i18n/src/locales/en/index.ts +++ b/packages/i18n/src/locales/en/index.ts @@ -291,7 +291,6 @@ export const en = { progress: "{{completed}}/{{total}} completed", current: "{{completed}}/{{total}} · Current: {{content}}", completed: "{{completed}}/{{total}} completed", - more: "{{count}} more items", updated: "Checklist updated", updating: "Updating checklist", status: { diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts index 66fa3c9995..88ecbd6c81 100644 --- a/packages/i18n/src/locales/es/index.ts +++ b/packages/i18n/src/locales/es/index.ts @@ -284,7 +284,6 @@ export const es = { "progress": "{{completed}}/{{total}} completadas", "current": "{{completed}}/{{total}} · Actual: {{content}}", "completed": "{{completed}}/{{total}} completadas", - "more": "{{count}} elementos más", "updated": "Lista actualizada", "updating": "Actualizando lista", "status": { "pending": "Pendiente", "in_progress": "En curso", "completed": "Completada", "cancelled": "Cancelada" } diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts index e218a265a4..e7296977cf 100644 --- a/packages/i18n/src/locales/fr/index.ts +++ b/packages/i18n/src/locales/fr/index.ts @@ -284,7 +284,6 @@ export const fr = { "progress": "{{completed}}/{{total}} terminées", "current": "{{completed}}/{{total}} · Actuelle : {{content}}", "completed": "{{completed}}/{{total}} terminées", - "more": "{{count}} éléments supplémentaires", "updated": "Liste mise à jour", "updating": "Mise à jour de la liste", "status": { "pending": "En attente", "in_progress": "En cours", "completed": "Terminée", "cancelled": "Annulée" } diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts index 5674b9ba37..46df6c31aa 100644 --- a/packages/i18n/src/locales/ko/index.ts +++ b/packages/i18n/src/locales/ko/index.ts @@ -293,7 +293,6 @@ export const ko = { progress: "{{completed}}/{{total}} 완료", current: "{{completed}}/{{total}} · 현재: {{content}}", completed: "{{completed}}/{{total}} 완료", - more: "{{count}}개 더 있음", updated: "체크리스트 업데이트됨", updating: "체크리스트 업데이트 중", status: { pending: "대기", in_progress: "진행 중", completed: "완료", cancelled: "취소됨" }, diff --git a/packages/i18n/src/locales/pt-BR/index.ts b/packages/i18n/src/locales/pt-BR/index.ts index 479d407a6b..4e1e0050c9 100644 --- a/packages/i18n/src/locales/pt-BR/index.ts +++ b/packages/i18n/src/locales/pt-BR/index.ts @@ -283,7 +283,6 @@ export const ptBR = { progress: "{{completed}}/{{total}} concluídos", current: "{{completed}}/{{total}} · Atual: {{content}}", completed: "{{completed}}/{{total}} concluídos", - more: "Mais {{count}} itens", updated: "Checklist atualizado", updating: "Atualizando checklist", status: { pending: "Pendente", in_progress: "Em andamento", completed: "Concluído", cancelled: "Cancelado" }, diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts index c10e713717..9e289f0d30 100644 --- a/packages/i18n/src/locales/tr/index.ts +++ b/packages/i18n/src/locales/tr/index.ts @@ -293,7 +293,6 @@ export const tr = { progress: "{{completed}}/{{total}} tamamlandı", current: "{{completed}}/{{total}} · Geçerli: {{content}}", completed: "{{completed}}/{{total}} tamamlandı", - more: "{{count}} öğe daha", updated: "Kontrol listesi güncellendi", updating: "Kontrol listesi güncelleniyor", status: { pending: "Bekliyor", in_progress: "Devam ediyor", completed: "Tamamlandı", cancelled: "İptal edildi" }, diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts index c1249d6c84..4592e6edd3 100644 --- a/packages/i18n/src/locales/zh-CN/index.ts +++ b/packages/i18n/src/locales/zh-CN/index.ts @@ -286,7 +286,6 @@ export const zhCN = { progress: "已完成 {{completed}}/{{total}}", current: "{{completed}}/{{total}} · 当前:{{content}}", completed: "已完成 {{completed}}/{{total}}", - more: "还有 {{count}} 项", updated: "清单已更新", updating: "正在更新清单", status: { pending: "待办", in_progress: "进行中", completed: "已完成", cancelled: "已取消" }, diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts index 5c6a787505..89f35eca03 100644 --- a/packages/i18n/src/locales/zh-TW/index.ts +++ b/packages/i18n/src/locales/zh-TW/index.ts @@ -286,7 +286,6 @@ export const zhTW = { progress: "已完成 {{completed}}/{{total}}", current: "{{completed}}/{{total}} · 目前:{{content}}", completed: "已完成 {{completed}}/{{total}}", - more: "還有 {{count}} 項", updated: "清單已更新", updating: "正在更新清單", status: { pending: "待辦", in_progress: "進行中", completed: "已完成", cancelled: "已取消" }, diff --git a/scripts/e2e/todo-checklist.tsx b/scripts/e2e/todo-checklist.tsx index ef6f579d9a..1aadd4d029 100644 --- a/scripts/e2e/todo-checklist.tsx +++ b/scripts/e2e/todo-checklist.tsx @@ -72,11 +72,21 @@ window.todoChecklistProbe = async () => { checks.push("collapsed-dock-reserves-no-blank-height"); flushSync(() => header()!.click()); await until(() => header()?.getAttribute("aria-expanded") === "true", "Click must expand dock"); - assert(document.querySelectorAll(".todo-dock-row").length === 8, "Expanded list is capped at eight rows"); + // The expanded dock lists every row and scrolls inside its own box (#1319). + assert(document.querySelectorAll(".todo-dock-row").length === 10, "Expanded list renders every row"); + const list = document.querySelector(".todo-dock-list")!; + assert(getComputedStyle(list).overflowY === "auto", "The expanded list is the scroll container"); + assert(list.scrollHeight > list.clientHeight, "A long checklist scrolls inside the dock instead of growing it"); + assert(Math.round(list.clientHeight) <= 280, `The dock list keeps a bounded height (${list.clientHeight}px)`); + assert(getComputedStyle(list).overscrollBehaviorY === "contain", + "Reaching the list's end must not scroll the transcript"); + assert(list.tabIndex === 0, "The expanded list is keyboard reachable"); await until(() => Math.round(box(".todo-dock-clip").height) === Math.round(box(".todo-dock-list").height), "Expanded clip must match the listed rows once the disclosure transition settles"); - assert(document.querySelector(".todo-dock-more")?.textContent?.includes("2"), "Overflow count must render"); - checks.push("agent-prompt-tool-discovery-host-write-event-dock-expand-bounded-list"); + assert(!document.querySelector(".todo-dock-more"), "No static overflow line remains"); + assert(Math.round(box(".todo-dock").height) < 400, + `The dock keeps a bounded footprint (${dockGeometry()})`); + checks.push("agent-prompt-tool-discovery-host-write-event-dock-expand-full-scrolling-list"); const normalized = await write(first, [ { content: "😀".repeat(501), status: "in_progress", priority: "high" }, { content: "Second active item", status: "in_progress", priority: "medium" },