From 62e2551edd69f3277dd07ce92fb0f59840a3e257 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 27 Jul 2026 16:01:01 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20darkmode=20=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E6=9D=A1=E9=A2=9C=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/client/ui-layout/README.md | 2 +- packages/client/ui-layout/README.zh.md | 2 +- .../ui-layout/src/client/theme-presenter.ts | 27 +++++++++++-------- packages/client/ui-layout/tests/apply.spec.ts | 6 ++++- .../ui-layout/tests/theme-presenter.spec.ts | 17 +++++++----- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- 7 files changed, 36 insertions(+), 22 deletions(-) diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 6aeda0e04b..26e909b964 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto `document.body` (`data-ds-dark-theme` from the active color scheme plus the theme's alias tokens as inline variables). +Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body). AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 9fbc2ed839..2e5799fd32 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 `document.body`(依据当前配色方案设置 `data-ds-dark-theme`,并将主题的别名 token 设为内联变量)。 +外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。 AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,页面局部的 `SessionListState.intent` 选择空白编辑器,已连接 Session 则通过 `SessionProvider` 渲染。会话及空状态的 owner share 为空;每个注册方通过标准 hook 获取业务数据,并从自身的 inject 表层获取操作。侧边栏 owner share 只包含 `collapsed` 和 `width`;导航操作属于侧边栏自身注入的服务表层。 diff --git a/packages/client/ui-layout/src/client/theme-presenter.ts b/packages/client/ui-layout/src/client/theme-presenter.ts index 958f2fd93e..07dc663c54 100644 --- a/packages/client/ui-layout/src/client/theme-presenter.ts +++ b/packages/client/ui-layout/src/client/theme-presenter.ts @@ -1,29 +1,33 @@ /** - * Global theme DOM applier: projects the resolved ThemeSnapshot onto - * document.body — the `data-ds-dark-theme` palette switch plus the active - * theme's alias-token overrides as inline CSS variables. Pure DOM writes, no - * React involvement; the presenter only ever retracts what it wrote itself, - * so foreign body attributes and inline styles survive apply/dispose. + * Global theme DOM applier: projects the resolved ThemeSnapshot onto the + * document — `html { color-scheme }` for native UA chrome (scrollbars, form + * controls), `body[data-ds-dark-theme]` for the token palette, and the active + * theme's alias-token overrides as inline CSS variables on body. Pure DOM + * writes, no React involvement; the presenter only ever retracts what it wrote + * itself, so foreign attributes and inline styles survive apply/dispose. */ import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' /** Body attribute selecting the dark base palette in the token stylesheets. */ export const DARK_ATTRIBUTE = 'data-ds-dark-theme' -/** Applies theme snapshots to document.body; one instance per plugin fiber. */ +/** Applies theme snapshots to the document; one instance per plugin fiber. */ export class ThemePresenter { /** Token names this presenter wrote in the last apply (its retraction set). */ private appliedTokens: string[] = [] /** - * Project a snapshot onto the body: switch the palette attribute from - * `active.colorScheme` (never the id — `system` is resolved upstream) and - * replace the previously applied token variables with `active.tokens`. + * Project a snapshot onto the document: set root `color-scheme` and the body + * palette attribute from `active.colorScheme` (never the id — `system` is + * resolved upstream), then replace the previously applied token variables + * with `active.tokens`. * @param snapshot - resolved theme snapshot from ctx.theme. */ apply(snapshot: ThemeSnapshot): void { + const scheme = snapshot.active.colorScheme + document.documentElement.style.colorScheme = scheme const body = document.body - if (snapshot.active.colorScheme === 'dark') body.setAttribute(DARK_ATTRIBUTE, '') + if (scheme === 'dark') body.setAttribute(DARK_ATTRIBUTE, '') else body.removeAttribute(DARK_ATTRIBUTE) for (const name of this.appliedTokens) body.style.removeProperty(name) this.appliedTokens = [] @@ -33,8 +37,9 @@ export class ThemePresenter { } } - /** Retract everything this presenter wrote: the palette attribute and all applied token variables. */ + /** Retract everything this presenter wrote: root color-scheme, the palette attribute, and all applied token variables. */ dispose(): void { + document.documentElement.style.removeProperty('color-scheme') const body = document.body body.removeAttribute(DARK_ATTRIBUTE) for (const name of this.appliedTokens) body.style.removeProperty(name) diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index 1382f5160d..903591163c 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -63,15 +63,19 @@ describe('ui-layout client apply', () => { const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() // Initial getter application: jsdom has no matchMedia, system resolves light. + expect(document.documentElement.style.colorScheme).toBe('light') expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) const theme = ctx.get('theme') as ThemeService theme.setTheme('dark') + expect(document.documentElement.style.colorScheme).toBe('dark') expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(true) await fiber.dispose() + expect(document.documentElement.style.colorScheme).toBe('') expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) - // Listener is off: further theme changes no longer reach the body. + // Listener is off: further theme changes no longer reach the document. theme.setTheme('light') theme.setTheme('dark') + expect(document.documentElement.style.colorScheme).toBe('') expect(document.body.hasAttribute('data-ds-dark-theme')).toBe(false) }) diff --git a/packages/client/ui-layout/tests/theme-presenter.spec.ts b/packages/client/ui-layout/tests/theme-presenter.spec.ts index ced83a379e..a14d781e5f 100644 --- a/packages/client/ui-layout/tests/theme-presenter.spec.ts +++ b/packages/client/ui-layout/tests/theme-presenter.spec.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom -// ThemePresenter behavior account: the palette attribute follows -// active.colorScheme only, token variables replace the previous apply's set, -// and dispose retracts everything the presenter wrote. +// ThemePresenter behavior account: root color-scheme and the palette attribute +// follow active.colorScheme only, token variables replace the previous apply's +// set, and dispose retracts everything the presenter wrote. import { beforeEach, describe, expect, it } from 'vitest' import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' @@ -14,22 +14,26 @@ function snapshot(colorScheme: 'light' | 'dark', tokens: Record } beforeEach(() => { + document.documentElement.style.removeProperty('color-scheme') document.body.removeAttribute(DARK_ATTRIBUTE) document.body.removeAttribute('style') }) describe('ThemePresenter', () => { - it('light scheme leaves the dark attribute absent', () => { + it('light scheme sets root color-scheme and leaves the dark attribute absent', () => { const presenter = new ThemePresenter() presenter.apply(snapshot('light')) + expect(document.documentElement.style.colorScheme).toBe('light') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) }) - it('dark scheme sets the attribute; switching back to light removes it', () => { + it('dark scheme sets root color-scheme and the attribute; switching to light clears both', () => { const presenter = new ThemePresenter() presenter.apply(snapshot('dark')) + expect(document.documentElement.style.colorScheme).toBe('dark') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(true) presenter.apply(snapshot('light')) + expect(document.documentElement.style.colorScheme).toBe('light') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) }) @@ -44,11 +48,12 @@ describe('ThemePresenter', () => { expect(document.body.style.getPropertyValue('--dsw-alias-fg')).toBe('') }) - it('dispose removes the attribute and every applied variable, sparing foreign inline styles', () => { + it('dispose removes color-scheme, the attribute, and every applied variable, sparing foreign inline styles', () => { document.body.style.setProperty('--foreign', 'kept') const presenter = new ThemePresenter() presenter.apply(snapshot('dark', { '--dsw-alias-bg': '#111' })) presenter.dispose() + expect(document.documentElement.style.colorScheme).toBe('') expect(document.body.hasAttribute(DARK_ATTRIBUTE)).toBe(false) expect(document.body.style.getPropertyValue('--dsw-alias-bg')).toBe('') expect(document.body.style.getPropertyValue('--foreign')).toBe('kept') diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 5c9794d4f5..1227df357c 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`body[data-ds-dark-theme]` + inline alias tokens). Contract: api-contracts v3 §8. +Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the theme preference (`light`/`dark`/`system`, persisted under `dsh.theme`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 2e0f76133e..cd87ede726 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为内联变量)。契约:api-contracts v3 §8。 +主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有主题偏好(`light`/`dark`/`system`,以 `dsh.theme` 为键持久化),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。契约:api-contracts v3 §8。 ## 模型体验 From 900e45b365ffaaa03b4bef0cf1ace5717a5e0bc5 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 27 Jul 2026 16:24:30 +0800 Subject: [PATCH 2/4] feat: optimize todo tool ui --- .../2026-07-23-web-todo-display.i18n.yaml | 4 +- .../feature/2026-07-23-web-todo-display.md | 2 +- .../feature/2026-07-23-web-todo-display.zh.md | 2 +- apps/web/tests/todo-display.snapshot.ts | 12 +- .../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/InputBar.module.css | 5 +- .../src/client/skeleton/TodoPanel.module.css | 102 ++++++++-------- .../src/client/skeleton/TodoPanel.tsx | 109 +++++++++++++----- .../ui-conversation/tests/todo-panel.spec.tsx | 26 +++-- 11 files changed, 162 insertions(+), 108 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index 4bccfa396e..5c8530da70 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.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 .agents/notes/implemented/feature/2026-07-23-web-todo-display.md -2026-07-23-web-todo-display.md: 830f55c86c893c4942a1a9d3b8529395d5f5e38b -2026-07-23-web-todo-display.zh.md: e68928d7eddaaa92ac831722a738ee2002342b38 +2026-07-23-web-todo-display.md: 5fe08cc40c1d23ff3a9b8c6d766fea6d3694c30d +2026-07-23-web-todo-display.zh.md: c121ffc27e3d0a93707c2c22b2f180023ebae5be diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md index 830f55c86c..5fe08cc40c 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -18,7 +18,7 @@ Consume `todo/write` as a Session side effect, not a surface node, and render it ### TodoPanel: the durable list as a persistent strip -The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, the QueueDock posture: `inject: ['slots', 'conversation']` as the load-order seam, `order: -1` above the queue rows), hidden while empty, collapsible with the in-progress item as the collapsed one-line hint; ✓/●/○ glyphs mirror the TUI plan panel. It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper. +The panel mounts through the `conversation.input.dock` slot (a plain registrant plugin, `todoDockEntry`, the QueueDock posture: `inject: ['slots', 'conversation']` as the load-order seam, `order: -1` above the queue rows), hidden while empty, collapsible to a header of title + `"/ tasks · in progress"` (no in-progress content hint when collapsed). Status glyphs are the figma todo set (green check ring / blue fading ring / dashed pending ring) on a tip-surface card (`--dsw-specific-tip`, 14px radius, `width: calc(100% - 88px)` / `max-width: 776px` centered; InputBar top pad 6px is the gap to the composer card). It reads `snapshot.todos` via the standard-kit `useSession` hook the dock entry receives — no store, no service, no ctx. The inner component stays props-complete and framework-free; the dock adapter is a one-line wrapper. ### TodoRow: the per-call row through the keyed toolview slot diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md index e68928d7ed..c121ffc27e 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -18,7 +18,7 @@ Status: implemented ### TodoPanel:长驻列表作为一条常驻横条 -面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry`,QueueDock 同款姿势:`inject: ['slots', 'conversation']` 载序 seam,`order: -1` 排在队列条上方),空列表时隐藏,可折叠——折叠态以进行中项作为单行提示;✓/●/○ 字形与 TUI plan 面板一致。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。 +面板经 `conversation.input.dock` slot 挂载(普通注册者插件 `todoDockEntry`,QueueDock 同款姿势:`inject: ['slots', 'conversation']` 载序 seam,`order: -1` 排在队列条上方),空列表时隐藏,可折叠为标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(折叠态不再附带进行中条目正文)。状态图标为 figma todo 套件(绿色勾选环/蓝色渐隐环/虚线未开始环),卡片使用 tip 表面(`--dsw-specific-tip`、14px 圆角、`width: calc(100% - 88px)`/`max-width: 776px` 居中;InputBar 顶部 6px 内边距是到输入卡的间距)。它经 dock entry 收到的标准件 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。内部组件保持 props 完备且框架无关;dock 适配件只是一行包装。 ### TodoRow:经 keyed toolview slot 的逐调用行 diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts index 3116bf4242..f916b64b5b 100644 --- a/apps/web/tests/todo-display.snapshot.ts +++ b/apps/web/tests/todo-display.snapshot.ts @@ -143,19 +143,19 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn })), }).toMatchInlineSnapshot(` { - "panelHeader": "Plan1/3", + "panelHeader": "To-dos1/3 tasks · 1 in progress", "panelItems": [ { "status": "completed", - "text": "✓梳理需求", + "text": "梳理需求", }, { "status": "in_progress", - "text": "●实现 fixture 样本", + "text": "实现 fixture 样本", }, { "status": "pending", - "text": "○浏览器验收", + "text": "浏览器验收", }, ], "row": "☰更新任务清单1/3 已完成 · 实现 fixture 样本", @@ -164,7 +164,7 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn `) }) -it('collapses the plan strip to the in-progress hint and restores it', async () => { +it('collapses the plan strip to the count summary and restores it', async () => { boot() await openFixtureSession() @@ -179,7 +179,7 @@ it('collapses the plan strip to the in-progress hint and restores it', async () listGone: panel.querySelector('ul') === null, }).toMatchInlineSnapshot(` { - "collapsedHeader": "Plan1/3实现 fixture 样本", + "collapsedHeader": "To-dos1/3 tasks · 1 in progress", "listGone": true, } `) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2ffa02d313..2f67e9eac3 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: b242812411d513931ecd2767622f9e23fb0aaa34 -README.zh.md: 77f68e02d8d9161c413ae7d224121bc53547ba12 +README.md: f6b7326916122545fc87d289cb422e644c7bae6c +README.zh.md: 55d4a743709695ba04814b4529aebac235a202d9 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b242812411..f6b7326916 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`/`openDetails`) 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 durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a one-line header carrying the in-progress item. The dock adapter owns the selection so the panel stays a pure function of its props; the persistent 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 durable plan strip: it selects `todos` off the session snapshot 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 persistent 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 (selection, ordinary composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. The frontend Session Intent comes from the Session list projection; after publication, any retained prompt comes from that Session's conversation snapshot. Components are pure — the framework standard kit (`useSession`/`sessionId` when session-scoped, plus global `useSessions`/`useWorkspaces`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; inject factories contribute plain data and callbacks for runtime Session actions, send/stop, tabs, details, and paging. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 77f68e02d8..55d4a74370 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`/`openDetails`),`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(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成携带进行中条目的单行表头。选取由 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(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store(`stores.ts` `createChatStore`)中:apply 构造一个 handle,并将其传给会话、聊天视图和详情注册,因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹:框架标准工具包(Session scope 下的 `useSession`/`sessionId`,以及全局 `useSessions`/`useWorkspaces`)和 store 表层(`useStore`/`actions`)会从注册声明自动到达;inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。 diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index b17027a153..63614e3c13 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -21,8 +21,9 @@ flex-direction: column; align-items: center; /* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by - the chat scroller. Top 8 hosts the error strip's breathing room. */ - padding: 8px 32px 12px; + the chat scroller. Top 6 is the gap under the dock todo strip (12px todo + margin + 6px here); error/status strips still carry their own margin. */ + padding: 6px 32px 12px; } .hero { diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 17c9c890a7..5ac38c1c67 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -1,55 +1,53 @@ -/* Plan strip pinned above the composer: bordered card on the composer card's - axis (776px column inside 32px side padding). Colors resolve through - --dsw-alias-* tokens only; the active row rides the business blue, done - rows fade to tertiary. */ +/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419): + tip surface, 14px radius, status icons + secondary item labels. Column is + calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */ .root { flex: none; overflow: hidden; - margin: 8px auto 0; - width: calc(100% - 64px); + margin: 0 auto; + width: calc(100% - 88px); max-width: 776px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 12px; - background: var(--dsw-alias-bg-base); + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 14px; + background: var(--dsw-specific-tip); +} + +.body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px 16px; } .header { display: flex; align-items: center; - gap: 8px; + gap: 10px; width: 100%; - padding: 8px 12px; + padding: 0; border: none; background: transparent; text-align: left; cursor: pointer; } -.header:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - .title { - font-size: 13px; - line-height: 16px; - font-weight: 510; + flex: none; + font-size: 14px; + line-height: 24px; + font-weight: 500; color: var(--dsw-alias-label-primary); } .progress { - font-size: 12px; - line-height: 16px; - color: var(--dsw-alias-label-tertiary); -} - -.activeHint { - flex: 1; + flex: 1 1 auto; min-width: 0; overflow: hidden; - font-size: 12px; - line-height: 16px; - color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 20px; + font-weight: 400; + color: var(--dsw-alias-label-tertiary); text-overflow: ellipsis; white-space: nowrap; } @@ -58,13 +56,15 @@ display: grid; flex: none; place-items: center; - margin-left: auto; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-tertiary); } .list { + display: flex; + flex-direction: column; + gap: 8px; margin: 0; - padding: 0 12px 8px; + padding: 0; list-style: none; max-height: 180px; overflow-y: auto; @@ -72,40 +72,44 @@ .item { display: flex; - align-items: baseline; - gap: 8px; - padding: 2px 0; + align-items: center; + gap: 10px; + min-width: 0; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-secondary); } .glyph { + display: grid; flex: none; - width: 14px; - text-align: center; - color: var(--dsw-alias-label-tertiary); + place-items: center; + width: 16px; + height: 16px; } -.item[data-status='completed'] .content { - color: var(--dsw-alias-label-tertiary); - text-decoration: line-through; -} - -.item[data-status='completed'] .glyph { +.glyphCompleted { color: var(--dsw-alias-state-success-primary); } -.item[data-status='in_progress'] .content { - font-weight: 510; - color: var(--dsw-alias-label-primary); +.glyphProgress { + color: var(--dsw-alias-state-business-primary); + animation: todo-progress-spin 1s linear infinite; } -.item[data-status='in_progress'] .glyph { - color: var(--dsw-alias-state-business-primary); +.glyphPending { + color: var(--dsw-alias-label-caption); +} + +@keyframes todo-progress-spin { + to { + transform: rotate(360deg); + } } .content { min-width: 0; - overflow-wrap: anywhere; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index 283eeb3e5e..24764088ab 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -3,8 +3,9 @@ // no data of its own, hidden while the list is empty. Mounted through the // 'conversation.input.dock' slot (QueueDock posture): the dock adapter does // the selecting, so the panel takes the plain list and stays framework-free. +// Visual: figma 772:51905 (states) / 772:52972 (collapsed) / 772:53419 (expanded). -import { useState } from 'react' +import { useId, useState } from 'react' import type { Context } from 'cordis' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client' @@ -16,45 +17,89 @@ export interface TodoPanelProps { todos: readonly TodoItem[] } -/** Status glyphs mirror the TUI plan panel (✓ done / ● active / ○ pending). */ -const STATUS_GLYPHS: Record = { - completed: '✓', in_progress: '●', pending: '○', +/** Completed: green check ring (figma ic_ds_check_16). */ +function CompletedGlyph() { + return ( + + ) +} + +/** In-progress: business-blue ring fading out; CSS spins the svg. */ +function ProgressGlyph() { + const gradientId = useId() + return ( + + ) +} + +/** Pending: dashed unstarted ring (figma 14px, dash 2.4 2.4). */ +function PendingGlyph() { + return ( + + ) +} + +function StatusGlyph({ status }: { status: TodoItem['status'] }) { + switch (status) { + case 'completed': return + case 'in_progress': return + case 'pending': return + } +} + +/** Header summary: "/ tasks · in progress". */ +function progressLabel(todos: readonly TodoItem[]): string { + const done = todos.filter(t => t.status === 'completed').length + const active = todos.filter(t => t.status === 'in_progress').length + return `${done}/${todos.length} tasks · ${active} in progress` } export function TodoPanel({ todos }: TodoPanelProps) { const [collapsed, setCollapsed] = useState(false) if (todos.length === 0) return null - const done = todos.filter(t => t.status === 'completed').length - const active = todos.find(t => t.status === 'in_progress') - return ( -
- + {!collapsed && ( +
    + {todos.map(item => ( +
  • + + {item.content} +
  • + ))} +
)} - - {collapsed ? : } - - - {!collapsed && ( -
    - {todos.map(item => ( -
  • - {STATUS_GLYPHS[item.status]} - {item.content} -
  • - ))} -
- )} +
) } diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 5bc3aa5c3d..8888fd5659 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -1,9 +1,9 @@ // @vitest-environment jsdom /** * Todo display acceptance: the TodoPanel plan strip (empty-hidden, status - * rows, collapse with active hint), its TodoDock adapter (selects the plan off - * the session snapshot and follows changes), and the todo_write toolview row - * (progress summary from args, generic fallback on malformed JSON, error badge, + * rows, collapse), its TodoDock adapter (selects the plan off the session + * snapshot and follows changes), and the todo_write toolview row (progress + * summary from args, generic fallback on malformed JSON, error badge, * keyboard activation). */ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' @@ -31,32 +31,36 @@ describe('TodoPanel', () => { expect(container.innerHTML).toBe('') }) - it('shows progress, one row per item with its status, and strikes done items', () => { + it('shows progress, one row per item with its status glyph', () => { render() expect(screen.getByTestId('todo-panel')).toBeTruthy() - expect(screen.getByText('1/3')).toBeTruthy() + expect(screen.getByText('To-dos')).toBeTruthy() + expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy() const items = screen.getAllByRole('listitem') expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending']) expect(screen.getByText('搭骨架')).toBeTruthy() expect(screen.getByText('写组件')).toBeTruthy() + // Each status row carries an SVG glyph (not a text bullet). + expect(items.every(li => li.querySelector('svg') !== null)).toBe(true) }) - it('collapse hides the list and surfaces the active item in the header; expand restores', () => { + it('collapse hides the list; expand restores; header keeps the count summary', () => { render() const header = screen.getByRole('button', { expanded: true }) fireEvent.click(header) expect(screen.queryByRole('list')).toBeNull() - // Collapsed header carries the in-progress content as the one-line hint. - expect(screen.getByText('写组件')).toBeTruthy() + // Collapsed header is title + progress only (no in-progress content hint). + expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy() + expect(screen.queryByText('写组件')).toBeNull() fireEvent.click(screen.getByRole('button', { expanded: false })) expect(screen.getAllByRole('listitem')).toHaveLength(3) }) - it('collapsed header omits the hint when nothing is in progress', () => { + it('collapsed header still shows zero in-progress when nothing is active', () => { render() fireEvent.click(screen.getByRole('button', { expanded: true })) expect(screen.queryByText('都完了')).toBeNull() - expect(screen.getByText('1/1')).toBeTruthy() + expect(screen.getByText('1/1 tasks · 0 in progress')).toBeTruthy() }) }) @@ -71,7 +75,7 @@ describe('TodoDock', () => { render() expect(screen.queryByTestId('todo-panel')).toBeNull() act(() => { store.set({ todos: LIST }) }) - expect(screen.getByText('1/3')).toBeTruthy() + expect(screen.getByText('1/3 tasks · 1 in progress')).toBeTruthy() // A rollback to the empty list retires the strip (the panel owns no data). act(() => { store.set({ todos: [] }) }) expect(screen.queryByTestId('todo-panel')).toBeNull() From 16598f7159c3279d607e4f6f5dc76d29f8c8c316 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 27 Jul 2026 16:36:36 +0800 Subject: [PATCH 3/4] fix: cr --- apps/web/tests/todo-display.snapshot.ts | 2 ++ .../client/ui-conversation/README.i18n.yaml | 4 ++-- packages/client/ui-conversation/README.md | 1 + packages/client/ui-conversation/README.zh.md | 1 + .../src/client/skeleton/TodoPanel.module.css | 1 + .../src/client/skeleton/TodoPanel.tsx | 20 +++++++++++++------ 6 files changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/web/tests/todo-display.snapshot.ts b/apps/web/tests/todo-display.snapshot.ts index f916b64b5b..ef710129d8 100644 --- a/apps/web/tests/todo-display.snapshot.ts +++ b/apps/web/tests/todo-display.snapshot.ts @@ -133,6 +133,8 @@ it('renders the todo_write turn: dedicated tool row + the dock plan strip', asyn const panel = document.querySelector('[data-testid="todo-panel"]') if (panel === null) throw new Error('todo panel missing from the input dock') + // Header spans are adjacent inline nodes; textContent joins "To-dos" + + // "1/3…" with no space (visual gap is CSS gap: 10px, not a text node). expect({ row: visibleText(row), rowState: row.getAttribute('data-state'), diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2f67e9eac3..56923eff77 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: f6b7326916122545fc87d289cb422e644c7bae6c -README.zh.md: 55d4a743709695ba04814b4529aebac235a202d9 +README.md: 453922dafd1eb7a617cb2d1c93ac1daa2e7273c6 +README.zh.md: 88992176165ab11050a30c7df381479796908ba2 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index f6b7326916..453922dafd 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -33,3 +33,4 @@ None; this package neither assembles nor sends a provider request. - **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **Approval cards are display-only placeholders** — question requests answer through the composer chain (ui-question), while web-side approval answering is the P-II approvals project. +- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 55d4a74370..8899217616 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -33,3 +33,4 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。 +- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 5ac38c1c67..8086cbfea7 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -107,6 +107,7 @@ } } +/* Figma strip is single-line; long items ellipsize with no inline expand. */ .content { min-width: 0; overflow: hidden; diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index 24764088ab..16edc423c0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -17,10 +17,16 @@ export interface TodoPanelProps { todos: readonly TodoItem[] } -/** Completed: green check ring (figma ic_ds_check_16). */ +/** Local exhaustiveness helper — client packages do not depend on `dsh-llm`. */ +/* v8 ignore next 3 -- closed-union backstop; only reached if status is forged */ +function assertNever(value: never): never { + throw new Error(`unreachable todo status: ${String(value)}`) +} + +/** Status glyphs share the figma 14×14 artboard; the 16×16 `.glyph` cell centers them. */ function CompletedGlyph() { return ( -