Skip to content
Merged
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
21 changes: 10 additions & 11 deletions apps/desktop/src/components/TodoDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand All @@ -65,15 +61,18 @@ export function TodoDock({ sessionId }: { sessionId: string }) {
</Button>
<div className="todo-dock-content" aria-hidden={!expanded}>
<div className="todo-dock-clip">
<div className="todo-dock-list" role="list">
{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. */}
<div
className="todo-dock-list"
role="list"
tabIndex={expanded ? 0 : -1}
>
{snapshot.todos.map((todo, index) => (
<TodoRow key={`${index}:${todo.content}`} todo={todo} />
))}
{remaining > 0 ? (
<div className="todo-dock-more" role="status">
{t("chat.todo.more", { count: remaining })}
</div>
) : null}
</div>
</div>
</div>
Expand Down
21 changes: 16 additions & 5 deletions apps/desktop/src/styles/composer.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 13 additions & 1 deletion apps/desktop/test/todo-dock-rendering.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"><div class="todo-dock-clip">/);
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(), {
Expand Down Expand Up @@ -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/);
});
9 changes: 6 additions & 3 deletions docs/adr/0312-session-scoped-todo-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
24 changes: 14 additions & 10 deletions docs/spec/04-ux/08-component-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -9388,15 +9388,18 @@ 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.
- **Expected**: Host SQLite is authoritative; each successful full replacement
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.
Expand All @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion docs/zh-CN/spec/04-ux/08-component-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 状态
Expand Down
6 changes: 3 additions & 3 deletions docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 可追溯性矩阵
Expand Down
1 change: 0 additions & 1 deletion packages/i18n/src/locales/de/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 0 additions & 1 deletion packages/i18n/src/locales/en/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 0 additions & 1 deletion packages/i18n/src/locales/es/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 0 additions & 1 deletion packages/i18n/src/locales/fr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 0 additions & 1 deletion packages/i18n/src/locales/ko/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "취소됨" },
Expand Down
1 change: 0 additions & 1 deletion packages/i18n/src/locales/pt-BR/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
1 change: 0 additions & 1 deletion packages/i18n/src/locales/tr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
1 change: 0 additions & 1 deletion packages/i18n/src/locales/zh-CN/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "已取消" },
Expand Down
1 change: 0 additions & 1 deletion packages/i18n/src/locales/zh-TW/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "已取消" },
Expand Down
Loading
Loading