Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
This commit is contained in:
creatixchu
2026-07-30 17:16:28 +08:00
68 changed files with 1288 additions and 653 deletions

View File

@@ -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/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
2026-07-25-web-client-session-scope-and-provide-channel.md: 4496e3786ed4adb6e60dfd5cfad72e989657f649
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 768dada95aacdb358115d496f45e7fc0eece0151
2026-07-25-web-client-session-scope-and-provide-channel.md: d19b256b834110d3cbb540cc0e039e61c693e98c
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 1f88dd2065eaba7282ae3ed9e82d6872fdfb8497

View File

@@ -90,10 +90,10 @@ The sole provisioning path by which session slot components fetch their own sess
Slot scope is the closed set `root | session-maybe | session`:
- `root` receives only the global standard kit, with no session identity or provisioning.
- `session-maybe` follows the current session, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session.
- `session-maybe` follows the current session with ADOPTION identity (the only behavior — there is no hold-identity-forever mode): an incarnation born session-less keeps its React instance across the arrival of the FIRST session (the blank shell adopts it — no remount, the DOM survives), and from then on behaves exactly like a strict session entry — switching to a different session remounts, and dropping back to no-session remounts into a fresh blank incarnation that will adopt again. Component-local per-session state therefore clears by construction; state that must survive a switch belongs in session-bound sources (machine, store, hooks). With no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session; the per-entry adoption bookkeeping (incarnation-counter key) lives in the renderer's `SessionMaybeEntry`.
- `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store.
`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view, while the composer and every input slot also stay strict `session`. With no session, the composer stack places the presentation-only `DisabledInputBar` directly; once a session appears, the input body is swapped for the strictly bound InputBar; the textarea may be rebuilt, while the Hero and the layout skeleton are not. The blank → engaging/active transition stays inside the same strict-session subtree, and the InputBar is never rebuilt on a phase flip.
`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session it renders inert (machine faces absent, `disabled` owner prop), and the same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip.
- The runtime's first built-in entry: the `'session'` hook — `useSession` itself rides the same mechanism, no special-casing.
- Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders).

View File

@@ -90,10 +90,10 @@ session slot 组件「自己拿 session 数据」的唯一供数路径。插件
slot scope 是闭集 `root | session-maybe | session`
- `root` 只拿全局标准件,不接收 session 身份或供数。
- `session-maybe` 跟随 current session但组件实例不因 id 有无或切换而换 key无 session 时 `sessionId``useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动与 provider 名册变化经同一 source 发布current id 不变时的名册变化也会重发已挂载 bundle而不是把 entry 困在过期的 hook/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。
- `session-maybe` 以**收养adoption身份语义**跟随 current session唯一行为——不存在「永久保持实例」模式空态出生的化身在**第一个** session 到来时保持 React 实例空壳收养它——不重挂DOM 存活);此后行为与严格 session entry 完全一致——切到不同 session 重挂,跌回无 session 也重挂为崭新的空态化身(之后再次收养)。因此组件本地的 per-session 状态**由构造保证**随切换清零;需要活过切换的状态必须住 session 绑定的源machine、store、hooks无 session 时 `sessionId``useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动与 provider 名册变化经同一 source 发布current id 不变时的名册变化也会重发已挂载 bundle而不是把 entry 困在过期的 hook/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状;逐 entry 的收养记账(化身计数 key住在 renderer 的 `SessionMaybeEntry`
- `session` 保证 `sessionId`、所有 hook source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key切换 session 会重建该 entry 及其 session store。
`conversation``session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/viewcomposer 与各输入 slot 也保持严格 `session`无 session 时 composer stack 直接放纯展示的 `DisabledInputBar`session 出现后把输入体换成严格绑定的 InputBartextarea 允许重建Hero 与布局骨架不重建。blank → engaging/active 仍在同一严格 session subtree 内,InputBar 不因 phase 翻转而重建。
`conversation``session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/viewcomposer bar`conversation.composer.bar`)本身即为 `session-maybe`无 session 时以惰性态渲染machine face 缺席、`disabled` owner propsession 出现后同一实例(含 textarea转为 live其余输入 slot 保持严格 `session`在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。
- runtime 内建第一条:`'session'` hook——`useSession` 本身走同一机制,无特判。
- Concurrent 纪律:渲染平面只从 hooks 格读uSES 一致性保证props 格回调只在事件 handler 空间用;描述符解析 render-safe幂等缓存、废弃渲染残留由 prune 收尸)。

View File

@@ -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/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
2026-07-25-web-input-machine-and-slash-pipeline.md: a203216aaa41b479deb1d903ba48c96af99387db
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 3ec8e7646f25fe4171c73894e5b695c62e53d7e0
2026-07-25-web-input-machine-and-slash-pipeline.md: 885771efdd1885878688f50f3b2ec4a9ffc7550f
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: a2ccc967ce955d4210ca10f469e53d70450ec0e3

View File

@@ -70,7 +70,7 @@ A trigger/menu/pick pipeline with zero knowledge of "commands":
- The hub (trigger/decoration registries + send orchestration) takes the slash/command services as optional `ctx.get()` dependencies: without ui-slash or the command surfaces, input still sends and receives normally — graceful degradation.
- Each materialized Session has exactly one `SessionInputShell` (the facade), created and torn down with the session scope; with no session, no input machine is built. `ConversationRoot` is itself the `session-maybe` resident shell, holding HeroShell, the Workspace picker, the composer stack, and the chain-fallback frame.
- With no session the shell renders the presentation-only `DisabledInputBar`; once `connectWorkspace` returns a blank session, only the input body is swapped for the strict-session InputBar. The textarea may be rebuilt here, while `ConversationRoot`, the Hero, and the layout skeleton hold; blank → engaging/active stays the same session-bound InputBar, with the textarea never rebuilt on a phase flip.
- The composer bar is one `session-maybe` slot entry rendered unconditionally: with no session the same InputBar renders inert (machine faces absent, `disabled` owner prop), and once `connectWorkspace` returns a blank session the same instance goes live — the textarea DOM survives the no-session → blank transition and every later phase flip; `ConversationRoot`, the Hero, and the layout skeleton hold throughout.
- ConversationRoot's Hero criterion is `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`. The first submit enters engaging synchronously, and a failure keeps the composer and the error context rather than falling back to the blank Hero; the sidebar's blank bit flips false only after a prompt is successfully accepted.
- Sending unifies in the hub defaultSink: after an optimistic draft and submitted-image clear it goes only through `session.prompt {mode:'queue'|'steer'}`; a failure restores the submitted image identifiers and backfills text only when the live draft is still empty, so text or images added during flight are never overwritten. No Draft materialize or attach transaction exists.
- When the blank Hero re-picks the Workspace, the shell calls `connectWorkspace`; if the target session differs, the non-empty draft and ordered image identifiers move from the current shell to the target shell before the new id is opened, and the old blank session survives but is no longer current.

View File

@@ -70,7 +70,7 @@ occurrence 表与 chip 三投影:
- hubtrigger/decoration 注册表 + 发送编排)对 slash/command 服务是可选 `ctx.get()` 依赖:无 ui-slash/命令面时输入正常收发,优雅降级。
- 每个实体 Session 只有一个 `SessionInputShell`facade随 session scope 创建和拆除;无 session 时不造 input machine。`ConversationRoot` 自身是 `session-maybe` 常驻外壳,持有 HeroShell、Workspace picker、composer stack 与 chain fallback 外框。
- 无 session 时外壳渲染纯展示的 `DisabledInputBar``connectWorkspace` 返回 blank session 后,仅输入体换成严格 session 的 InputBar。这里允许 textarea 重建`ConversationRoot`、Hero 与布局骨架保持blank → engaging/active 仍是同一 session-bound InputBartextarea 不因 phase 翻转而重建
- composer bar 是一个无条件渲染的 `session-maybe` slot entry无 session 时同一个 InputBar 以惰性态渲染machine face 缺席、`disabled` owner prop`connectWorkspace` 返回 blank session 后同一实例转为 live——textarea DOM 在无 session → blank 切换及其后每次 phase 翻转中都不重建`ConversationRoot`、Hero 与布局骨架全程保持。
- ConversationRoot 的 Hero 判据是 `sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))`。首次 submit 同步进入 engaging失败也保留 composer 与错误上下文,不退回 blank Herosidebar 的 blank 位只在 prompt 成功受理后翻 false。
- 发送统一在 hub defaultSink乐观清除 draft 和已提交图片后只走 `session.prompt {mode:'queue'|'steer'}`;失败时恢复已提交图片标识符,仅当 live draft 仍为空时才回填文本,因此不会覆盖飞行期间新增的文本或图片。不存在 Draft materialize 或 attach 事务。
- blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标 session 不同时把非空 draft 和有序图片标识符从当前 shell 搬到目标 shell再 open 新 id旧 blank session 留存但不再 current。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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-29-ask-question-web-presentation.md
2026-07-29-ask-question-web-presentation.md: 90eeb3cdcc1a851b7d5e184c0f31cbccd82cbf55
2026-07-29-ask-question-web-presentation.zh.md: 5bb19d3a68dc0510ea766d7a22abdc1cff9c326a

View File

@@ -0,0 +1,45 @@
# Agent Note: Ask-question Web presentation
Status: implemented
English | [中文](2026-07-29-ask-question-web-presentation.zh.md)
## Problem
The Web GUI could already collect answers through the `QuestionComposer` composer takeover, but the transcript around it was wrong on three counts. A pending question rendered twice: once as the composer takeover and once as the read-only `PendingCard` placeholder that predates the takeover. A settled `ask_user_question` call rendered as the generic "Tool call" row dumping raw args JSON, so the two composer verdicts — the user dismissing the whole set (`ASK_CANCELLED`) and a turn interrupt landing while the question was pending (`ASK_ABORTED`) — both read as anonymous red-dot failures. And the composer's own chrome copy (pager, buttons, placeholders, validation feedback) was hardcoded Chinese while the surrounding client is bilingual through `dsh-client-locale`.
Separately, the composer visuals had drifted from the current design: an expand-to-open custom answer entry, no multi-select affordance beyond a trailing check, header-mounted paging, and a `(可多选)` title-suffix convention parsed out of model text.
## Decision
A pending question owns exactly two surfaces: the composer takeover collects the answers, and a dedicated `ask_user_question` toolview row in the transcript names the interaction outcome. The row registers into the keyed `conversation.chat.toolview` hole exactly like `todo_write` and composes the shared `ToolRow` (chrome, running sweep, leading expansion). Its summary is the interaction verdict rather than args: `waiting` while running, `N/M answered` from the result JSON once settled (a skipped answer — empty `selected`, no `custom` — stays out of the count), `cancelled` for `ASK_CANCELLED`, and `interrupted` with the shared amber stopped semantics for `ASK_ABORTED`. Malformed or truncated results fall back to the generic summary. `PendingCard` narrows to `PendingWait<'approval'>` and `ChatView` filters the pending list to approval waits, so the placeholder card now exists only for the approval takeover still on the roadmap.
The composer redesign moves paging into the footer next to the actions, renders multi-select options with explicit checkboxes, keeps single-select numbered rows, and replaces the expand-to-open custom entry with an always-visible custom input row (textarea for optionless questions). The `parseQuestionTitle` multi-select suffix convention is deleted; `multi_select` is already structured metadata, so the title renders verbatim.
Composer chrome copy becomes bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry a namespace-bound translator plus the locale snapshot as a hooks-compartment source through the slot inject face, so a locale flip re-renders a mounted composer. Validation feedback is stored as a dictionary key and re-translated on flip; carrier failure messages and all model-authored question/option text render verbatim.
Two adjacent fixes ride along. All generic toolview leading icons (and the hover chevron) now inherit the single tertiary label color — the others-variant secondary override and the separate chevron color rule are deleted, leaving only the intentional cordis business-primary accent. And the client dev-watch bundler registers each CSS module with `addWatchFile`, because the virtual-module indirection previously hid css-only edits from the watcher.
## Alternatives considered
**Keep rendering questions through `PendingCard`.** Rejected: the card was a read-only placeholder from before the takeover existed, so a pending question showed the same content twice with one copy not answerable. The toolview row plus takeover covers both the transcript record and the collection surface.
**Show the questions or answers inline in the transcript row.** Rejected: the composer takeover owns question rendering and answer collection, and the row convention (`todo_write`) is one line with details in the panel. The row therefore reports only the outcome, mirroring how the todo row reports counts while the panel owns the list.
**Render `ASK_CANCELLED`/`ASK_ABORTED` through the generic error shape.** Rejected: dismissal is the user's own deliberate action and an interrupt is the shared stop gesture; both are expected outcomes, not tool failures. Naming the verdict (and keeping amber stopped semantics for the abort) matches how interrupted tool calls read elsewhere.
**Translate the row verdicts now.** Deferred by explicit product decision: the row's `waiting`/`answered`/`cancelled`/`interrupted` strings stay English for this change; the composer chrome i18n landed because its Chinese-only copy was already wrong for the en locale.
**Keep the title-suffix multi-select convention.** Rejected: `multi_select` is structured request metadata and the checkbox affordance now carries the signal, so parsing `(可多选)` out of model text was a fragile duplicate channel.
## Consequences
`ask_user_question` and `todo_write` now demonstrate the intended toolview pattern: compose `ToolRow`, summarize from call args or result JSON with shape-checked fallbacks, and register through the keyed slot. The bespoke `todo-row.module.css` is gone.
The row verdict strings are the one remaining hardcoded-English surface of the question flow; localizing them is deferred follow-up. `PendingCard` remains a visible-but-not-answerable approval placeholder until the approval composer takeover ships.
`ui-question` gains a `dsh-client-locale` dependency and an inject face where it previously had none; its contract (`QuestionComposerInjected`) lives with the consumer in `contract/slots.ts`.
## Verification
`ui-conversation` tests pin the row's waiting/answered/skipped/cancelled/interrupted/fallback matrix, the approval-only pending filter, and the slot registration; `ui-question` tests pin the redesigned composer (checkbox multi-select, always-visible custom row, footer pager, dictionary-key feedback re-translation, IME-safe Enter) and the plugin's dictionary registration plus inject face; `ui-primitives` tests pin the icon set. The assembled Web GUI was exercised against a live session covering answer, cancel, and turn-interrupt paths.

View File

@@ -0,0 +1,45 @@
# Agent NoteAsk-question Web 呈现
Status: implemented
[English](2026-07-29-ask-question-web-presentation.md) | 中文
## 问题
Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答,但其周边的会话记录呈现在三个方面是错的。待回答的问题会渲染两次:一次是输入区接管,一次是早于接管存在的只读 `PendingCard` 占位卡片。已结算的 `ask_user_question` 调用渲染为通用 "Tool call" 行并直接倾倒原始 args JSON因此两种输入区裁决 —— 用户放弃整组问题(`ASK_CANCELLED`)与问题待回答期间轮次被打断(`ASK_ABORTED`)—— 都显示为无名的红点失败。而且输入区自身的界面文案(分页、按钮、占位符、校验反馈)是硬编码中文,而周边客户端已通过 `dsh-client-locale` 实现双语。
另外,输入区视觉也偏离了当前设计:自定义回答需展开才能输入、多选除尾部对勾外没有可见标识、分页挂在头部、还有从模型文本里解析 `(可多选)` 标题后缀的约定。
## 决定
一个待回答的问题恰好拥有两个界面:输入区接管收集回答,会话记录中一个专门的 `ask_user_question` toolview 行陈述交互结果。该行与 `todo_write` 完全一样注册进带 key 的 `conversation.chat.toolview` 槽位,并复用共享的 `ToolRow`(外观、运行扫光、前导展开)。其摘要是交互裁决而非参数:运行中显示 `waiting`,结算后从结果 JSON 得出 `N/M answered`(被跳过的回答 —— `selected` 为空且无 `custom` —— 不计入),`ASK_CANCELLED` 显示 `cancelled``ASK_ABORTED` 显示 `interrupted` 并沿用共享的琥珀色 stopped 语义。畸形或截断的结果回退到通用摘要。`PendingCard` 收窄为 `PendingWait<'approval'>``ChatView` 将待处理列表过滤为仅审批等待,占位卡片从此只服务于仍在路线图上的审批接管。
输入区重设计将分页移到底部操作区旁,多选选项渲染显式复选框,单选保留编号行,并用始终可见的自定义输入行取代展开式自定义入口(无选项问题用多行文本框)。删除 `parseQuestionTitle` 的多选后缀约定;`multi_select` 已是结构化元数据,标题原样渲染。
输入区界面文案实现双语:插件在 `dsh-client-locale``question` 命名空间下注册中英词典,并通过槽位 inject face 向条目提供绑定命名空间的翻译器和作为 hooks 舱源的 locale 快照,语言切换时已挂载的输入区会重新渲染。校验反馈以词典 key 存储、切换时重新翻译;载体失败消息与所有模型撰写的问题/选项文本原样渲染。
两个相邻修复随行。所有通用 toolview 前导图标(含悬停箭头)现在统一继承三级标签色 —— 删除了 others 变体的二级色覆盖和独立的箭头颜色规则,只保留有意为之的 cordis 业务主色强调。客户端 dev-watch 打包器用 `addWatchFile` 注册每个 CSS 模块,因为虚拟模块间接层此前使仅改 CSS 的编辑对 watcher 不可见。
## 曾考虑的替代方案
**继续通过 `PendingCard` 渲染问题。** 否决该卡片是接管存在之前的只读占位导致同一内容显示两份且其中一份不可作答。toolview 行加接管同时覆盖了记录与收集两个面。
**在会话记录行内联显示问题或回答。** 否决:输入区接管拥有问题渲染与回答收集,而行的约定(`todo_write`)是单行、详情在面板。因此行只报告结果,正如 todo 行报告计数而面板拥有列表。
**用通用错误形态渲染 `ASK_CANCELLED`/`ASK_ABORTED`。** 否决:放弃是用户自己的主动操作,打断是共享的停止手势;两者都是预期结果而非工具失败。命名裁决(且中止保持琥珀色 stopped 语义)与其他被打断的工具调用的呈现一致。
**现在就翻译行内裁决文案。** 依明确的产品决定推迟:本次改动中行的 `waiting`/`answered`/`cancelled`/`interrupted` 字符串保持英文;输入区界面文案的国际化落地是因为其仅中文的文案在 en 语言下本就是错的。
**保留标题后缀的多选约定。** 否决:`multi_select` 是结构化请求元数据且复选框标识已承载该信号,从模型文本解析 `(可多选)` 是脆弱的重复通道。
## 后果
`ask_user_question``todo_write` 现在共同示范预期的 toolview 模式:复用 `ToolRow`、从调用参数或结果 JSON 做带形状校验回退的摘要、通过带 key 的槽位注册。专用的 `todo-row.module.css` 已删除。
行内裁决字符串是问题流程仅剩的硬编码英文面;将其本地化是推迟的后续工作。在审批输入区接管交付之前,`PendingCard` 仍是可见但不可操作的审批占位。
`ui-question` 新增 `dsh-client-locale` 依赖和此前没有的 inject face其契约`QuestionComposerInjected`)与消费者一起放在 `contract/slots.ts`
## 验证
`ui-conversation` 测试钉住行的 waiting/answered/skipped/cancelled/interrupted/回退矩阵、仅审批的待处理过滤和槽位注册;`ui-question` 测试钉住重设计的输入区(复选框多选、始终可见的自定义行、底部分页、词典 key 反馈重翻译、IME 安全的 Enter以及插件的词典注册与 inject face`ui-primitives` 测试钉住图标集。组装后的 Web GUI 在真实会话中演练了回答、取消与轮次打断路径。

View File

@@ -36,6 +36,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -50,6 +50,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -33,6 +33,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -29,6 +29,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -25,6 +25,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -22,6 +22,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -15,6 +15,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -25,6 +25,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -36,6 +36,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img

View File

@@ -18,7 +18,7 @@
- button:
- img
- img
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"
- text: Ask question 1/1 answered
- button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.":
- img
- img
@@ -33,6 +33,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -1,23 +1,17 @@
- region "Which color do you prefer?":
- text: Pick one
- heading "Which color do you prefer?" [level=2]
- text: 1 / 1
- button "Previous question" [disabled]:
- img
- button "Next question" [disabled]:
- img
- button "Dismiss all questions":
- img
- radiogroup:
- radio "Blue":
- text: 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- img
- radio "Green":
- text: 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
- img
- button "Other — enter a custom answer":
- img
- text: Other — enter a custom answer
- radio "Blue": 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.
- radio "Green": 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.
- textbox "Type your answer"
- button "Previous question" [disabled]:
- img
- text: 1 / 1
- button "Next question" [disabled]:
- img
- status
- button "Skip this question"
- button "Submit" [disabled]

View File

@@ -29,6 +29,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -23,6 +23,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -35,6 +35,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img

View File

@@ -18,27 +18,21 @@
- button:
- img
- img
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]}"
- text: Ask question waiting
- region "Ready to continue?":
- text: Checkpoint
- heading "Ready to continue?" [level=2]
- text: 1 / 1
- button "Previous question" [disabled]:
- img
- button "Next question" [disabled]:
- img
- button "Dismiss all questions":
- img
- radiogroup:
- radio "Yes":
- text: 1 Yes
- img
- radio "No":
- text: 2 No
- img
- button "Other — enter a custom answer":
- img
- text: Other — enter a custom answer
- radio "Yes": 1 Yes
- radio "No": 2 No
- textbox "Type your answer"
- button "Previous question" [disabled]:
- img
- text: 1 / 1
- button "Next question" [disabled]:
- img
- status
- button "Skip this question"
- button "Submit" [disabled]

View File

@@ -18,7 +18,7 @@
- button:
- img
- img
- text: "Tool call ask_user_question · {\"questions\": [{\"id\": \"checkpoint\", \"question\": \"Ready to continue?\", \"header\": \"Checkpoint\", \"options\": [{\"label\": \"Yes\"}, {\"label\": \"No\"}]}]} 插话 Interjection: include the word BANANA in your final reply."
- text: "Ask question 1/1 answered 插话 Interjection: include the word BANANA in your final reply."
- button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
- img
- img
@@ -33,6 +33,7 @@
- button "Add attachment":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img

View File

@@ -180,7 +180,9 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
// current selection while it moves into Ungrouped.
const groupRow = page.locator('[role="treeitem"]').filter({ hasText: workspace.title }).first()
await groupRow.waitFor({ timeout: 10_000 })
const groupSection = groupRow.locator('..')
// The header row is wrapped by its HoverCard anchor span, so the section
// is the nearest groupSection ancestor, not the immediate parent.
const groupSection = groupRow.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]')
if (await groupSection.locator('[role="treeitem"]').count() < 2) await groupRow.click()
await expect.poll(
() => groupSection.locator('[role="treeitem"]').count(),

View File

@@ -25,6 +25,9 @@
"project": [
"scripts/**/*.ts",
"scripts/**/*.mjs"
],
"ignoreDependencies": [
"playwright"
]
},
"examples": {

View File

@@ -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: c72183f3b323eda702ee9ebfb8722e3302b0f88d
README.zh.md: 989c20a8fe6e50a6db021a5af610bfd044d7b210
README.md: 999782d88621da3afb8390b117aa87a77bde3ed7
README.zh.md: 14f26d3137ae007ec244aaab7c62a9685b9bb17e

View File

@@ -20,7 +20,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p
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.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
Image drafts keep only ordered `DraftAttachmentId` values in that store. `ConversationService` owns the corresponding browser `File` and object URLs, rejects unsupported declared image media types before allocating previews, and releases draft URLs on removal or send plus historical URLs when their rendered session unmounts. A historical read that completes after its rendered session or the service is disposed rejects before allocating an object URL. Paste and drop share the same validation path; mixed clipboard text remains native textarea input.

View File

@@ -20,7 +20,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent智能体仍能收到回答没有待处理交互时活跃会话的 composer 归 Chat 所有。composer bar 坑位本身为 `session-maybe`:没有当前会话时,同一个 bar 以惰性态渲染machine face 缺席、`disabled` owner prop而不是换入一棵平行的 disabled 树,因此 textarea DOM 在选定 workspace 的切换中得以存活;严格会话作用域的控件 seat 在会话存在之前保持为空
图片草稿在该 store 中只保留有序的 `DraftAttachmentId` 值。`ConversationService` 持有对应的浏览器 `File` 和对象 URL会在分配预览前拒绝声明媒体类型不受支持的图片并在图片移除或发送时释放草稿 URL在所渲染的会话卸载时释放历史 URL。一项历史读取如果在其所渲染的会话卸载或该服务释放后才完成会在分配对象 URL 前被拒绝。粘贴与拖放共用同一校验路径;混合剪贴板文本仍由 textarea 原生输入。

View File

@@ -10,6 +10,7 @@ import type {
ApprovalWait, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ConversationSessionInjected, DetailsInjected,
} from './contract/slots.ts'
import type { InputNotice } from './input/contract.ts'
import { resolveToolPath } from './contract/tool-call-model.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
@@ -21,6 +22,7 @@ import { StatsLine } from './chat/StatsLine.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
import { askQuestionToolview } from './toolviews/ask-question-row.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { queueDockEntry } from './queue/QueueDock.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
@@ -30,6 +32,19 @@ import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
// Static no-session sources for the composer-bar hooks compartment: module
// constants so the render side's per-source hook cache (observableHook) keeps
// one identity across every no-session render.
const ABSENT_NOTICES = {
getSnapshot: (): InputNotice | null => null,
subscribe: () => () => {},
}
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
const ABSENT_LEXICON = {
getSnapshot: () => EMPTY_LEXICON,
subscribe: () => () => {},
}
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
const scoped = sessions.scope(id)
@@ -127,7 +142,7 @@ export function apply(ctx: Context): void {
children: {
'conversation.session': { kind: 'single', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
'conversation.composer.bar': { kind: 'single', scope: 'session' },
'conversation.composer.bar': { kind: 'single', scope: 'session-maybe' },
'conversation.input.overlay': { kind: 'list', scope: 'session' },
'conversation.input.dock': { kind: 'list', scope: 'session' },
'conversation.composer.dock': { kind: 'list', scope: 'session' },
@@ -184,6 +199,9 @@ export function apply(ctx: Context): void {
// chain's fallback (decision 20). Public machine surface arrives via the
// provide channel above; the keyboard command face and the stop/retry
// verbs ride this inject (package-internal — hub and bar are one plugin).
// Session-maybe: with no current session the machine faces are absent and
// the hooks compartment binds static empty sources (module constants, so
// observableHook caching and hook order stay stable across transitions).
slots.register({
name: 'conversation.composer.bar',
// The two named control seats in the bar's tool row (plan beside the
@@ -193,7 +211,19 @@ export function apply(ctx: Context): void {
'conversation.input.plan': { kind: 'single', scope: 'session' },
'conversation.input.model': { kind: 'single', scope: 'session' },
},
inject: (sessionId: SessionId): ComposerBarInjected => {
inject: (sessionId: SessionId | undefined): ComposerBarInjected => {
if (sessionId === undefined) {
return {
keyboard: undefined,
addImages: undefined,
removeImage: undefined,
draftImages: undefined,
stop: undefined,
command: undefined,
translateHint,
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON },
}
}
const conversation = concreteConversation(ctx)
const shell = inputHub.shell(sessionId)
return {
@@ -298,6 +328,9 @@ export function apply(ctx: Context): void {
// The todo_write row rides the same seam (a product registration, not a sample).
ctx.plugin(todoToolview)
// The ask_user_question row: waiting/answered/cancelled interaction outcome.
ctx.plugin(askQuestionToolview)
// The plan strip rides the input dock above the queue rows (same posture).
ctx.plugin(todoDockEntry)

View File

@@ -61,12 +61,6 @@
color: var(--dsw-alias-label-tertiary);
}
/* The others-variant sparkle glyph is one gray step darker than the icon
family in the source design. */
.root[data-variant='others'] .leading {
color: var(--dsw-alias-label-secondary);
}
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
shared product accent and tool-owned action title. */
.root[data-tool^='cordis_'] .leading,
@@ -86,10 +80,6 @@ button.leading {
cursor: pointer;
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
/* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
into a down chevron before the row is opened. The chevron overlays the
icon cell absolutely so both can stay mounted for the opacity transition. */

View File

@@ -9,7 +9,6 @@
// through the host; the row itself is not a details-panel control.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
@@ -106,12 +105,12 @@ export function ToolRow({
? (
<>
<span className={css.iconIdle}>{icon}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
<IconChevronDownOutline14 className={css.chevronHover} />
</>
)
: icon
const leading = open
? <IconChevronDownOutline14 className={css.chevron} />
? <IconChevronDownOutline14 />
: leadingFor(state, collapsedIcon)
return (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>

View File

@@ -86,11 +86,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* The default composer body: a single slot rendered as the composer
* chain's fallback (decision 20 — a real entry, not a chain rider, so a
* takeover election hides rather than unmounts it and the textarea DOM
* survives). InputBar registers here from this package's apply; its
* machine state arrives through the standard provide channel (useInput +
* inputActions), the keyboard command face through its own inject.
* survives). Session-maybe: the bar stays mounted across the
* no-session/session transition — the no-workspace hero renders the SAME
* textarea DOM disabled instead of a parallel inert tree — with the
* machine hooks absent until a session is current. InputBar registers
* here from this package's apply; its machine state arrives through the
* standard provide channel (useInput + inputActions), the keyboard
* command face through its own inject.
*/
'conversation.composer.bar': { kind: 'single'; scope: 'session'; owner: ComposerBarOwnerProps }
'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps }
/**
* The Plan-mode status seat in the composer tool row (left group,
* right of the access-mode control). Declared by the composer-bar
@@ -255,6 +259,12 @@ export interface ConversationSessionInjected {
export interface ComposerBarOwnerProps {
/** Hero = empty-state centered card; composer = resident bottom bar. */
variant: 'hero' | 'composer'
/**
* Inert no-workspace state: the bar renders its normal DOM fully disabled
* (textarea, add, send) so the workspace pick transitions in place instead
* of swapping component trees.
*/
disabled?: boolean
placeholder?: string
/** Optional content rendered above the textarea. */
accessory?: ReactNode
@@ -272,25 +282,30 @@ export interface ComposerBarOwnerProps {
/** Injected share of the composer-bar entry (package-internal faces). */
export interface ComposerBarInjected {
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane). */
keyboard: ComposerKeyboard
/** Create browser previews and append their ids to the session input state. */
addImages: (files: readonly File[]) => string | null
/** Release one browser preview and remove its id from the session input state. */
removeImage: (id: DraftAttachmentId) => void
/** Resolve ordered input-state ids to browser-owned draft attachments. */
draftImages: (ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]
/** Cancel the in-flight turn. */
stop: () => void
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
keyboard: ComposerKeyboard | undefined
/** Create browser previews and append their ids to session input; absent with the session. */
addImages: ((files: readonly File[]) => string | null) | undefined
/** Release one browser preview and remove its id from session input; absent with the session. */
removeImage: ((id: DraftAttachmentId) => void) | undefined
/** Resolve ordered input-state ids to browser-owned draft attachments; absent with the session. */
draftImages: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined
/** Cancel the in-flight turn; absent with the session. */
stop: (() => void) | undefined
/**
* Submit one slash-command line against this session's agent (the chrome
* controls' write path — the permission chip submits `/permission <preset>`).
* controls' write path — the permission chip submits `/permission <preset>`);
* absent with the session.
* Resolves admission: false = rejected/unmatched/transport failure.
*/
command: (line: string) => Promise<boolean>
/** Locale-aware hint translator for claimed command placeholders. */
command: ((line: string) => Promise<boolean>) | undefined
/** Locale-aware hint translator for claimed command placeholders (session-independent — always present). */
translateHint: (key: string) => string
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
/**
* Registrant hooks compartment: the renderer binds these to
* useNotices/useLexicon (static absent sources without a session — hook
* order stays constant).
*/
hooks: {
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
notices: ObservableSnapshot<InputNotice | null>

View File

@@ -1,13 +1,12 @@
// Resident conversation skeleton. Hero chrome, composer positioning, and the
// chain stay mounted across no-session/session transitions. Only the inert
// input body swaps for the strict session InputBar.
// Resident conversation skeleton. Hero chrome, composer positioning, the
// chain, AND the composer bar (session-maybe slot) stay mounted across
// no-session/session transitions — the bar renders inert via owner props.
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
import { DisabledInputBar } from './DisabledInputBar.tsx'
import css from './ConversationRoot.module.css'
/** Full props composed from the slot contract. */
@@ -113,20 +112,23 @@ export function ConversationRoot({
)
// The placeholder chip ("Choose workspace") and the inert input travel
// together: a blank session whose workspace vanished (deleted from the
// sidebar) reverts to the same disabled bar as the initial no-session state.
const inputBar = sessionId === undefined || (hero && chipTitle === undefined)
? <DisabledInputBar />
: renderSlot('conversation.composer.bar', {
variant: hero ? 'hero' : 'composer',
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
overlay: renderSlot('conversation.input.overlay', {}),
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
// Stats band under the card, inside the bar's width column so both
// share one constraint (composer.dock = stats-line family).
footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null,
})
// together: no workspace picked yet (cold start, no session at all), or a
// blank session whose workspace vanished (deleted from the sidebar). The
// bar is ONE session-maybe slot rendered unconditionally — inert is a prop,
// not a different tree, so the textarea DOM survives the transition.
const inert = sessionId === undefined || (hero && chipTitle === undefined)
const inputBar = renderSlot('conversation.composer.bar', {
variant: hero ? 'hero' : 'composer',
...(inert
? { disabled: true, placeholder: 'Choose a workspace to start' }
: hero ? { placeholder: 'Describe what you want to build' } : {}),
overlay: renderSlot('conversation.input.overlay', {}),
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
// Stats band under the card, inside the bar's width column so both
// share one constraint (composer.dock = stats-line family).
footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null,
})
const composerBar = (
<div className={clsx(css.composerStack, hero && css.composerHero)}>
@@ -176,7 +178,7 @@ export function ConversationRoot({
'conversation.session',
{ wrapActiveBody },
)}
{sessionId === undefined ? composerSeat : null}
{sessionId === undefined ? wrapActiveBody(null) : null}
</div>
)
}

View File

@@ -1,40 +0,0 @@
/** Inert no-session input body; the resident Hero shell renders around it. */
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './InputBar.module.css'
/** Disabled visual twin of the session-bound InputBar. */
export function DisabledInputBar() {
return (
<div className={clsx(css.root, css.hero)}>
<div className={css.card}>
<div className={css.grow}>
<textarea
className={css.input}
value=""
disabled
placeholder="Choose a workspace to start"
rows={2}
readOnly
/>
<div aria-hidden className={css.mirror}>{'\n'}</div>
</div>
<div className={css.row}>
<div className={css.tools}>
<button type="button" className={css.add} aria-label="Add attachment" disabled>
<IconPlusOutline16 size={14} />
</button>
</div>
<div className={css.trailing}>
<button type="button" className={css.primary} aria-label="Send message" disabled>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
</svg>
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -7,7 +7,7 @@
* (running/removed/promptError) are self-selected via useSession. */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ChangeEvent, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: the `plan` projection key merge (the TodoDock posture — the
@@ -17,10 +17,14 @@ import type {} from '@deepseek-ai/dsh-plan-mode/client'
import type {} from '@deepseek-ai/dsh-goal/client'
import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import type { DraftDecorations } from '../input/decorations.ts'
import { ImageLightbox } from './ImageLightbox.tsx'
import { PermissionSelect } from './PermissionSelect.tsx'
import css from './InputBar.module.css'
/** Decoration product of the no-session state (no machine, empty draft). */
const INERT_DECORATIONS: DraftDecorations = { token: null, chips: [], textRefs: [], hint: null }
/** Prompt failure surface (derived from promptError). */
export interface InputBarError {
op: 'send' | 'stop'
@@ -32,14 +36,15 @@ export type InputBarProps = ComposerBarProps
export function InputBar({
useSession, useInput, inputActions, keyboard, addImages, removeImage, draftImages,
stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection,
variant, placeholder, accessory, overlay, leftItems, rightItems, footer, onAdd, addLabel = 'Add attachment',
sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
const notice = useNotices(s => s)
const lexicon = useLexicon(s => s)
const promptError = useSession(s => s.promptError)
const running = useSession(s => s.running)
const disabled = useSession(s => s.removed)
const promptError = useSession(s => s.promptError) ?? null
const running = useSession(s => s.running) ?? false
const removed = useSession(s => s.removed) ?? false
// Plan mode swaps the textarea placeholder (the projection is the folded
// host value; owner-prop placeholders — hero, session-unavailable — win).
const planActive = useProjection('plan', plan => plan !== undefined && (plan.pending ? !plan.active : plan.active))
@@ -51,8 +56,14 @@ export function InputBar({
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
const draft = input.draft
const attachments = useMemo(() => draftImages(input.imageIds), [draftImages, input.imageIds])
// Session-maybe: the machine faces are absent together while no session is
// current; the bar renders the same DOM inert instead of a parallel tree.
const live = input !== undefined && keyboard !== undefined && inputActions !== undefined
const draft = input?.draft ?? ''
const attachments = useMemo(
() => input === undefined || draftImages === undefined ? [] : draftImages(input.imageIds),
[draftImages, input?.imageIds],
)
const empty = draft.trim() === '' && attachments.length === 0
const [preview, setPreview] = useState<ComposerAttachment | null>(null)
const [dragActive, setDragActive] = useState(false)
@@ -75,17 +86,20 @@ export function InputBar({
// (undefined = capability absent → the chip renders nothing).
const permissions = useProjection('permissions')
// Queue cut 1: running input stays free; locked = session disabled only.
// The transient machine locks (adjudicating pending / submitting) render
// Queue cut 1: running input stays free; locked = session removed, the
// inert no-workspace state, or the machine faces absent (no session). The
// transient machine locks (adjudicating pending / submitting) render
// read-only — the draft stays visible and focused, keystrokes drop.
const disabled = removed || inert || !live
const locked = disabled
const machineBusy = input.phase === 'adjudicating' || input.phase === 'submitting'
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
useEffect(() => {
if (input === undefined || inputActions === undefined) return
if (attachments.length !== input.imageIds.length) {
inputActions.pruneImages(attachments.map(attachment => attachment.id))
}
}, [attachments, input.imageIds, inputActions])
}, [attachments, input?.imageIds, inputActions])
useEffect(() => {
if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null)
@@ -94,7 +108,7 @@ export function InputBar({
// Unlock (mount / session switch) returns focus to the box.
useEffect(() => {
if (!locked) inputRef.current?.focus()
}, [locked])
}, [locked, sessionId])
// Active conversation scrollport: chain the wheel. While the textarea (capped
// at 14 lines with overflow-y:auto) can still move in this direction, keep
@@ -118,6 +132,9 @@ export function InputBar({
}, [])
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
// Absent machine (no session): the textarea is disabled so events cannot
// fire; the guard narrows the faces for the paths below.
if (keyboard === undefined || inputActions === undefined) return
// Shift+Enter is the native newline UNCONDITIONALLY — decided before the
// IME guard so a composition-closing Shift+Enter still breaks the line.
if (e.key === 'Enter' && e.shiftKey) return
@@ -179,6 +196,7 @@ export function InputBar({
}
const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
if (keyboard === undefined) return // absent machine: disabled textarea, no events
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
const next = e.target.value
keyboard.setDraft(next)
@@ -204,6 +222,7 @@ export function InputBar({
/* oxlint-enable typescript/no-unnecessary-condition */
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
if (input === undefined || keyboard === undefined) return // absent machine: disabled textarea, no events
const el = e.currentTarget
const { start, end } = selectionOf(el)
if (start === end) return
@@ -227,13 +246,14 @@ export function InputBar({
void slice
}
const onPaste = (e: ClipboardEvent<HTMLTextAreaElement>): void => {
const onPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>): void => {
if (keyboard === undefined) return // absent machine: disabled textarea, no events
if (machineBusy || locked) return
const files = [...e.clipboardData.items]
.filter(item => item.kind === 'file')
.map(item => item.getAsFile())
.filter((file): file is File => file !== null)
if (files.length > 0) {
if (files.length > 0 && addImages !== undefined) {
setDropError(addImages(files))
}
const text = e.clipboardData.getData('text/plain')
@@ -257,14 +277,14 @@ export function InputBar({
const onSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>): void => {
// Any caret/selection gesture ends a live paste attempt (the machine
// cannot observe DOM selection). Cheap no-op when none is live.
if (keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste()
if (keyboard !== undefined && keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste()
void e
}
const onDragEnter = (event: DragEvent<HTMLDivElement>): void => {
if (!event.dataTransfer.types.includes('Files')) return
event.preventDefault()
if (locked || machineBusy) return
if (locked || machineBusy || addImages === undefined) return
dragDepthRef.current += 1
setDropError(null)
setDragActive(true)
@@ -303,6 +323,7 @@ export function InputBar({
const primaryLabel = running ? 'Stop generating' : 'Send message'
const onPrimary = (): void => {
if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled
if (running) {
stop()
return
@@ -312,16 +333,17 @@ export function InputBar({
}
// The Access seat: the projection-fed permission chip (renders nothing
// while the permissions key is absent — permission-less host or Draft).
const accessSelect: ReactNode = (
<PermissionSelect value={permissions} locked={locked} command={command} />
)
// while the permissions key is absent — permission-less host or Draft
// or while the command face is absent with the session).
const accessSelect: ReactNode = command === undefined
? null
: <PermissionSelect value={permissions} locked={locked} command={command} />
// Mirror-layer decorations: a visible backdrop with transparent text. The
// claim token highlights through behind the textarea glyphs; each U+FFFC
// placeholder renders as a chip (the textarea's own glyph is invisible, the
// backdrop chip supplies the visual); the claim hint is ghost text.
const deco = deriveDecorations(input, lexicon)
const deco = input === undefined ? INERT_DECORATIONS : deriveDecorations(input, lexicon)
const backdrop: ReactNode[] = []
{
// Segment boundaries: the token range end, every chip offset, and every
@@ -383,7 +405,7 @@ export function InputBar({
pushPlain(draft.length)
if (deco.hint !== null) {
// Claim tokens are shaped `/name ` (trailing space); trim to the bare name.
const commandName = input.claim?.token.slice(1).trim() ?? ''
const commandName = input?.claim?.token.slice(1).trim() ?? ''
const hintKey = commandName === 'goal' && hasGoal ? 'goal.active' : commandName
const translated = translateHint(hintKey)
const displayHint = translated !== hintKey ? translated : deco.hint
@@ -433,7 +455,7 @@ export function InputBar({
aria-label={`移除图片 ${attachment.file.name || ''}`}
onClick={() => {
setDropError(null)
removeImage(attachment.id)
removeImage?.(attachment.id)
}}
>×</button>
</div>
@@ -451,7 +473,7 @@ export function InputBar({
value={draft}
disabled={locked}
readOnly={machineBusy}
data-phase={input.phase}
data-phase={input?.phase ?? 'inert'}
placeholder={placeholder ?? (disabled
? 'Session unavailable'
: planActive ? translateHint('placeholder.plan') : translateHint('placeholder.default'))}

View File

@@ -0,0 +1,92 @@
// ask_user_question toolview: question-flavored summary row replacing the
// generic "Tool call" card, registered into the keyed
// 'conversation.chat.toolview' hole like todo-row. The row composes ToolRow
// (chrome, running sweep, leading expansion) and swaps in the interaction
// outcome — `waiting` while pending, answered-count once settled, `cancelled`
// when the user dismissed the whole set — because the questions themselves
// render in the composer takeover.
import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from 'cordis'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { ToolRow } from '../chat/ToolRow.tsx'
/** One parsed answer entry, shape-checked (result JSON crosses the wire). */
interface AnswerEntry { selected?: unknown; custom?: unknown }
function isAnswer(value: unknown): value is AnswerEntry {
return typeof value === 'object' && value !== null
}
/** `${answered}/${total} answered` off the result JSON (a skipped question has
* empty `selected` and no `custom`); null on unexpected shape (generic fallback). */
function answeredSummary(text: string): string | null {
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
return null
}
if (typeof parsed !== 'object' || parsed === null) return null
const answers = (parsed as { answers?: unknown }).answers
if (!Array.isArray(answers) || !answers.every(isAnswer)) return null
const answered = answers.filter(a =>
(Array.isArray(a.selected) && a.selected.length > 0)
|| (typeof a.custom === 'string' && a.custom !== '')).length
return `${answered}/${answers.length} answered`
}
/** One-line question-interaction row (leading toggle expands the raw args). */
export function AskQuestionRow({ toolName, block }: ToolRowProps) {
const model = toolRowModel(toolName, block)
// Composer verdicts settle the call as specific UserInteractionErrors
// (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own
// dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the
// question was pending. Both name their verdict instead of the generic
// failed shape, and the abort keeps the shared stopped (amber) semantics of
// any other interrupted tool call.
const code = 'kind' in block ? block.error?.code : undefined
let summary = model.summary
let state = model.state
if (code === 'ASK_CANCELLED') {
summary = 'cancelled'
} else if (code === 'ASK_ABORTED') {
summary = 'interrupted'
state = 'stopped'
} else if (model.state === 'running') {
summary = 'waiting'
} else if ('kind' in block && model.state === 'ok') {
const text = block.content.filter(b => b.type === 'text').map(b => b.text).join('')
summary = answeredSummary(text) ?? model.summary
}
return (
<ToolRow
variant={model.variant}
toolName={toolName}
icon={<IconQuestionOutline14 />}
title="Ask question"
summary={summary}
body={model.body}
state={state}
/>
)
}
/**
* The ask-question row as a plain registrant plugin, riding the same
* load-order seam as todo-toolview: `inject: ['conversation']` guarantees the
* chat entry (and with it the 'conversation.chat.toolview' declaration) is on
* the ledger.
*/
export const askQuestionToolview = {
name: 'ask-question-toolview',
inject: ['slots', 'conversation'],
/**
* Register the ask-question row into the chat view's keyed toolview hole.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
},
}

View File

@@ -1,56 +0,0 @@
/* todo_write plan-update row: ToolRow chrome (figma 780:53675) —
[16 checklist] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */
.row {
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
font-weight: 500; /* figma wt510, rendered 500 */
color: var(--dsw-alias-label-primary-dimmed);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
.err {
flex: none;
margin-left: 8px;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
line-height: 16px;
}

View File

@@ -1,15 +1,16 @@
// todo_write toolview: plan-flavored summary row replacing the generic
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
// hole like the bash sample (a product registration, not a sample). The row
// summarizes the written list (counts + active item) from the call args; the
// composes ToolRow (chrome, running sweep, leading expansion) and swaps in a
// summary of the written list (counts + active item) from the call args; the
// durable list itself renders in the TodoPanel above the composer, so the
// row stays one line. Chrome matches ToolRow (figma 780:53675).
// row stays one line.
import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from 'cordis'
import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './todo-row.module.css'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { ToolRow } from '../chat/ToolRow.tsx'
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
interface TodoWriteItem { content?: unknown; status?: unknown }
@@ -39,37 +40,23 @@ function summarize(argsRaw: string): string | null {
: head
}
/** Leading-slot state substitution matches ToolRow / bash: icon yields to the
* state semantic while running or failed; ok keeps the checklist glyph. */
function leadingFor(state: ToolRowState) {
switch (state) {
case 'running': return <StateDot state="ongoing" />
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconChecklistOutline16 />
}
}
/** One-line plan update row. Non-ok execution states keep the generic row's
* dot semantics — a cancelled call wrote no todo/write, so it must not read
* as a completed update. */
/** One-line plan update row (leading toggle expands the raw args). Non-ok
* execution states keep the shared row's dot semantics — a cancelled call
* wrote no todo/write, so it must not read as a completed update. */
export function TodoRow({ toolName, block }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const summary = summarize(argsRaw) ?? model.summary
return (
<div
className={css.row}
data-sample="todo-row"
data-state={model.state}
>
<span className={css.leading} aria-hidden>{leadingFor(model.state)}</span>
<span className={css.title}></span>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{summary}</span>
{model.state === 'error' && <span className={css.err}>failed</span>}
{model.state === 'stopped' && <span className={css.err}></span>}
</div>
<ToolRow
variant={model.variant}
toolName={toolName}
icon={<IconChecklistOutline14 />}
title="更新任务清单"
summary={summary}
body={model.body}
state={model.state}
/>
)
}

View File

@@ -163,7 +163,7 @@ describe('conversation slot inject surface', () => {
unbind()
// Stop failure is swallowed (promptError owns the surface).
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
b.composerSurface(ROOT).stop()
b.composerSurface(ROOT).stop!()
await new Promise(r => setTimeout(r, 0))
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
await b.runtime.dispose()
@@ -172,12 +172,19 @@ describe('conversation slot inject surface', () => {
it('inject fails loud when the session resolves no binding or the scope lacks the service', async () => {
const b = await bench()
const entry = b.entryOf('conversation.composer.bar')
const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
const injectFn = entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected
// Unknown session: the keyboard face's binding resolution answers nothing.
expect(() => { injectFn('ghost' as SessionId).stop() }).toThrow(/resolved no binding/)
expect(() => { injectFn('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/)
// No session (session-maybe absent side): machine faces absent, static
// hooks compartment still present so the render side's hook order holds.
const absent = injectFn(undefined)
expect(absent.keyboard).toBeUndefined()
expect(absent.stop).toBeUndefined()
expect(absent.hooks.notices.getSnapshot()).toBeNull()
expect(absent.hooks.lexicon.getSnapshot().size).toBe(0)
// A scope whose service tree lost 'conversation' (the feature fiber
// unloaded while a retained inject closure re-runs): fails loud too.
const stop = injectFn(ROOT).stop
const stop = injectFn(ROOT).stop!
await b.feature.dispose()
expect(() => { stop() }).toThrow(/unavailable through the session scope/)
await b.runtime.dispose()

View File

@@ -0,0 +1,129 @@
// @vitest-environment jsdom
/**
* ask_user_question toolview acceptance: `waiting` summary while running,
* answered-count from the result JSON once settled (skipped answers
* excluded), the cancelled/interrupted verdicts off ASK_CANCELLED and
* ASK_ABORTED, shared ToolRow state
* semantics for interrupted/failed calls, and generic fallbacks on
* malformed results.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { AskQuestionRow, askQuestionToolview } from '../src/client/toolviews/ask-question-row.tsx'
afterEach(cleanup)
const ARGS = JSON.stringify({ questions: [{ id: 'a' }, { id: 'b' }, { id: 'c' }] })
const resultNode = (argsRaw: string, resultText: string | null, over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
call: { name: 'ask_user_question', argsRaw },
content: resultText === null ? [] : [{ type: 'text', text: resultText }],
isError: false, callView: null, resultView: null, ...over,
})
const runningCall = (argsRaw: string) =>
({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, callView: null })
function rowProps(block: unknown): ToolRowProps {
return {
callId: 'c1', toolName: 'ask_user_question', block,
openFile: vi.fn(),
sessionId: 's1',
useSessions: () => undefined,
} as unknown as ToolRowProps
}
const answers = (entries: unknown[]): string => JSON.stringify({ answers: entries })
describe('AskQuestionRow', () => {
it('running call reads waiting (args-independent: the composer takeover shows the questions)', () => {
const view = render(<AskQuestionRow {...rowProps(runningCall(ARGS))} />)
expect(screen.getByText('Ask question')).toBeTruthy()
expect(screen.getByText('waiting')).toBeTruthy()
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
})
it('settled result counts answered entries (selected choices or custom text)', () => {
render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([
{ id: 'a', selected: ['x'] },
{ id: 'b', selected: [], custom: 'freeform' },
{ id: 'c', selected: ['y', 'z'], custom: '' },
])))} />)
expect(screen.getByText('3/3 answered')).toBeTruthy()
})
it('skipped questions (no selection, no custom) stay out of the answered count', () => {
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([
{ id: 'a', selected: ['x'] },
{ id: 'b', selected: [], custom: '' },
{ id: 'c' },
])))} />)
expect(screen.getByText('1/3 answered')).toBeTruthy()
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it.each([
{ label: 'non-JSON result text', text: 'oops' },
{ label: 'non-object result root', text: '"str"' },
{ label: 'null result root', text: 'null' },
{ label: 'missing answers array', text: '{"other":1}' },
{ label: 'null answer entries', text: '{"answers":[null]}' },
{ label: 'empty result content', text: null },
])('settled result falls back to the generic summary on $label', ({ text }) => {
render(<AskQuestionRow {...rowProps(resultNode(ARGS, text))} />)
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
})
it('user cancellation names the verdict instead of the generic failed shape', () => {
// ASK_CANCELLED: the apiproxy ask_user_question handler's cancel error.
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
{ isError: true, error: { name: 'UserInteractionError', code: 'ASK_CANCELLED' } }))} />)
expect(screen.getByText('cancelled')).toBeTruthy()
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
})
it('a turn abort while pending reads interrupted with stopped semantics', () => {
// ASK_ABORTED: the apiproxy ask handler's turn-abort settlement.
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
{ isError: true, error: { name: 'UserInteractionError', code: 'ASK_ABORTED' } }))} />)
expect(screen.getByText('interrupted')).toBeTruthy()
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
})
it('an interrupted turn reads as stopped, not cancelled', () => {
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null,
{ isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
expect(screen.queryByText('cancelled')).toBeNull()
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
})
it('other tool errors keep the generic summary with the error state', () => {
const view = render(<AskQuestionRow {...rowProps(resultNode(ARGS, null, { isError: true }))} />)
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy()
})
it('window-truncated result (call head lost) falls back to the callId summary', () => {
render(<AskQuestionRow {...rowProps(resultNode('', null, { call: null }))} />)
expect(screen.getByText('ask_user_question · c1')).toBeTruthy()
})
it('leading toggle expands the raw args body', () => {
render(<AskQuestionRow {...rowProps(resultNode(ARGS, answers([])))} />)
fireEvent.click(screen.getByRole('button', { expanded: false }))
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
})
it('askQuestionToolview is a plain registrant riding the conversation load-order seam', () => {
expect(askQuestionToolview.name).toBe('ask-question-toolview')
expect(askQuestionToolview.inject).toEqual(['slots', 'conversation'])
const register = vi.fn()
askQuestionToolview.apply({ slots: { register } } as never)
expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'ask_user_question' }, AskQuestionRow)
})
})

View File

@@ -108,7 +108,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
const view = runtime.renderRoot()
// Keyed toolview registration took the row (summary derived from args).
const row = view.container.querySelector('[data-sample="todo-row"]')
const row = view.container.querySelector('[data-tool="todo_write"]')
expect(row).not.toBeNull()
expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本')
@@ -128,7 +128,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
await waitFor(() => {
expect(view.container.querySelector('[data-testid="todo-panel"]')).toBeNull()
})
expect(view.container.querySelector('[data-sample="todo-row"]')).not.toBeNull()
expect(view.container.querySelector('[data-tool="todo_write"]')).not.toBeNull()
await runtime.dispose()
})
})

View File

@@ -80,12 +80,12 @@ describe('apply wiring', () => {
await b.runtime.dispose()
})
it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => {
it('mounts the bash sample and the product rows as keyed entries through the load-order seam', async () => {
const b = await bench()
// Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write', 'ask_user_question'])
// Stats stick with the composer (not inside ChatView).
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
await b.runtime.dispose()

View File

@@ -1,7 +1,7 @@
// @vitest-environment jsdom
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
// bash sample state dots, the node-half empty
// apply, and AssistantMarkdown reasoning/unknown block arms.
// bash sample state dots, the node-half empty apply, and AssistantMarkdown
// reasoning/unknown block arms.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'

View File

@@ -3,8 +3,8 @@
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status
* 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).
* summary from args, generic fallback on malformed JSON, shared ToolRow
* state dots and leading expansion).
*/
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -131,8 +131,8 @@ describe('TodoRow', () => {
expect(screen.getByText('1/1 已完成')).toBeTruthy()
})
it('keeps the non-ok execution states visible: running dot, interrupted marker', () => {
// A running call (no result yet) shows the ongoing dot, never the ok badge.
it('keeps the non-ok execution states visible through the shared row states', () => {
// A running call (no result yet) carries the running state (row sweep).
const args = JSON.stringify({ todos: LIST })
const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
@@ -141,20 +141,26 @@ describe('TodoRow', () => {
// A cancelled call wrote no todo/write: the row must not read as a completed update.
const stopped = render(<TodoRow {...rowProps(resultNode(args, { isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
expect(stopped.container.querySelector('[data-state="stopped"]')).not.toBeNull()
expect(stopped.getByText('已中断')).toBeTruthy()
})
it('falls back to the generic summary on malformed args and flags errors', () => {
render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
expect(screen.getByText('failed')).toBeTruthy()
it('falls back to the generic summary on malformed args and marks the error state', () => {
const view = render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
// Generic others summary: "<tool> · <raw>".
expect(screen.getByText('todo_write · not json')).toBeTruthy()
})
it('falls back when parsed args carry no todos array and stays non-interactive', () => {
it('falls back when parsed args carry no todos array', () => {
render(<TodoRow {...rowProps(resultNode('{"other":1}'))} />)
expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
expect(screen.queryByRole('button')).toBeNull()
})
it('leading toggle expands the raw args body', () => {
render(<TodoRow {...rowProps(resultNode(ARGS))} />)
fireEvent.click(screen.getByRole('button', { expanded: false }))
expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
// The expanded body is the pretty-printed args, not the tool output.
expect(screen.getByText(/搭骨架/)).toBeTruthy()
})
it.each([

View File

@@ -1,5 +1,5 @@
/* Read-only plan status badge: quiet chip; the × affordance appears on
hover/focus and the whole chip is the /plan off button. */
/* Plan-mode toggle chip: quiet while off; the pressed state takes the
business accent pair (same token pairing as the trajectory user badge). */
.wrap {
display: inline-flex;
@@ -10,8 +10,7 @@
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
padding: 4px 8px;
border: none;
border-radius: 8px;
background: transparent;
@@ -25,6 +24,14 @@
background: var(--dsw-alias-interactive-bg-hover);
}
/* Hovering keeps the pressed accent: the higher-specificity hover rule above
would otherwise swap it back to the neutral hover wash. */
.chip[aria-pressed='true'],
.chip[aria-pressed='true']:hover:not(:disabled) {
color: var(--dsw-alias-state-business-primary);
background: var(--dsw-alias-state-business-tertiary);
}
.chip:focus-visible {
outline: 2px solid var(--dsw-alias-label-secondary);
outline-offset: 2px;
@@ -35,17 +42,6 @@
cursor: default;
}
.close {
display: inline-flex;
align-items: center;
color: var(--dsw-alias-label-caption);
}
.chip:hover .close,
.chip:focus-visible .close {
color: var(--dsw-alias-label-secondary);
}
.error {
color: var(--dsw-alias-state-error-primary);
font-size: 12px;

View File

@@ -11,16 +11,16 @@ export type PlanChipProps =
PropsRuntime<'conversation.input.plan'> & InjectFace<PlanChipInjected>
/**
* Read-only status badge over the host-computed `plan` projection. Plan mode
* is entered through the /plan command only; the chip appears while the
* effective target is plan mode and its hover × executes /plan off. The
* displayed state follows the target (`pending ? !active : active`) — a
* folded host value, not client optimism, so an arriving frame corrects it.
* Plan-mode toggle over the host-computed `plan` projection. The chip renders
* whenever the capability is present and reflects the effective target as its
* pressed state (`pending ? !active : active` — a folded host value, not
* client optimism, so an arriving frame corrects it). Clicking executes
* /plan or /plan off toward the opposite target.
*/
export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps) {
export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) {
const plan = useProjection('plan')
const [leaving, setLeaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<{ text: string; detail: string } | null>(null)
const aliveRef = useRef(true)
useEffect(() => {
@@ -30,24 +30,25 @@ export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps)
}
}, [])
// Absent capability (no plan-mode host plugin / no session yet) or the
// default mode: no seat content.
// Absent capability (no plan-mode host plugin / no session yet): no seat
// content — without the capability there is nothing to toggle.
if (plan === undefined) return null
const target = plan.pending ? !plan.active : plan.active
if (!target) return null
const off = (): void => {
// No leaving/locked guard: both disable the button, so no click arrives.
setLeaving(true)
const toggle = (): void => {
// No busy/locked guard: both disable the button, so no click arrives.
const on = !target
const failText = on ? '进入 plan mode 失败' : '退出 plan mode 失败'
setBusy(true)
setError(null)
void exitPlanMode().then((failure) => {
void setPlanMode(on).then((failure) => {
if (!aliveRef.current) return
setLeaving(false)
setError(failure)
setBusy(false)
setError(failure === null ? null : { text: failText, detail: failure })
}, (reason: unknown) => {
if (!aliveRef.current) return
setLeaving(false)
setError(reason instanceof Error ? reason.message : String(reason))
setBusy(false)
setError({ text: failText, detail: reason instanceof Error ? reason.message : String(reason) })
})
}
@@ -56,19 +57,17 @@ export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps)
<button
type="button"
className={css.chip}
aria-label="Plan mode on, press to turn off"
title="Plan mode on — click × to turn off (/plan off)"
disabled={locked || leaving}
onClick={off}
aria-pressed={target}
aria-label={target ? 'Plan mode on, press to turn off' : 'Plan mode off, press to turn on'}
title={target
? 'Plan mode on — click to turn off (/plan off)'
: 'Plan mode off — click to turn on (/plan)'}
disabled={locked || busy}
onClick={toggle}
>
Plan
<span className={css.close} aria-hidden>
<svg viewBox="0 0 12 12" width="10" height="10">
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" fill="none" />
</svg>
</span>
Plan { target ? 'on' : 'off' }
</button>
{error !== null && <span className={css.error} role="status" title={error}>退 plan mode </span>}
{error !== null && <span className={css.error} role="status" title={error.detail}>{error.text}</span>}
</span>
)
}

View File

@@ -1,11 +1,11 @@
/**
* Plan control plugin, browser half: occupies the composer's named
* `conversation.input.plan` seat with a read-only status chip. Plan mode is
* entered through the /plan command only; while the projection's effective
* target is plan mode the chip renders (hover × executes /plan off through
* `command.execute`), otherwise the seat stays empty. Reads ride the generic
* projection pair through the standard-kit `useProjection` (an absent key is
* capability absence); zero client-side plan state.
* `conversation.input.plan` seat with a plan-mode toggle chip. While the
* `plan` projection is present the chip renders in both states and executes
* /plan or /plan off through `command.execute` toward the opposite target;
* an absent projection (no capability) leaves the seat empty. Reads ride the
* generic projection pair through the standard-kit `useProjection` (an absent
* key is capability absence); zero client-side plan state.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -18,10 +18,11 @@ import { PlanChip } from './PlanModeControl.tsx'
/** Injected business face of the composer plan seat. */
export interface PlanChipInjected {
/**
* Leave plan mode by executing /plan off.
* Switch plan mode by executing /plan (on) or /plan off.
* @param on - desired target: true enters plan mode, false leaves it.
* @returns null on admitted execution; a user-visible failure line otherwise.
*/
exitPlanMode: () => Promise<string | null>
setPlanMode: (on: boolean) => Promise<string | null>
}
/**
@@ -38,11 +39,12 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.slots.register({
name: 'conversation.input.plan',
inject: (sessionId: SessionId): PlanChipInjected => ({
exitPlanMode: async () => {
setPlanMode: async (on) => {
const line = on ? '/plan' : '/plan off'
const connection = ctx.get('connection') as ConnectionHandle
const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' })
const { result } = await connection.api.commands.execute({ sessionId, line })
if (!result.ok) return `${result.error.message}${result.error.code}`
if (!result.value.matched) return '未知命令:/plan off'
if (!result.value.matched) return `未知命令:${line}`
return null
},
}),

View File

@@ -1,9 +1,9 @@
/**
* ui-plan browser half on a real SlotsService: the plugin occupies the
* conversation-declared `conversation.input.plan` single seat with the plan
* status chip; the injected face executes /plan off and folds admission
* outcomes into null (admitted) or a user-visible failure line; teardown
* empties the seat (HMR safety).
* toggle chip; the injected face executes /plan or /plan off by direction and
* folds admission outcomes into null (admitted) or a user-visible failure
* line; teardown empties the seat (HMR safety).
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
@@ -49,7 +49,7 @@ describe('ui-plan browser apply', () => {
.rejects.toThrow(/slot "conversation.input.plan" is not declared/)
})
it('registers the chip, executes /plan off, and unregisters on teardown', async () => {
it('registers the chip, executes /plan by direction, and unregisters on teardown', async () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
@@ -57,20 +57,22 @@ describe('ui-plan browser apply', () => {
expect(entry.component).toBe(PlanChip)
const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID)
await expect(injected.exitPlanMode()).resolves.toBeNull()
await expect(injected.setPlanMode(false)).resolves.toBeNull()
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' })
await expect(injected.setPlanMode(true)).resolves.toBeNull()
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' })
// Business failure folds to the composer-visible line.
b.execute.mockResolvedValueOnce({
result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } },
} as never)
await expect(injected.exitPlanMode()).resolves.toBe('gonesession-not-found')
await expect(injected.setPlanMode(false)).resolves.toBe('gonesession-not-found')
// Unmatched admission (plan-mode not composed host-side) is also a failure line.
b.execute.mockResolvedValueOnce({
result: { ok: true as const, value: { matched: false as const } },
} as never)
await expect(injected.exitPlanMode()).resolves.toBe('未知命令:/plan off')
await expect(injected.setPlanMode(true)).resolves.toBe('未知命令:/plan')
await fiber.dispose()
expect(b.slots.entries('conversation.input.plan')).toHaveLength(0)

View File

@@ -1,10 +1,11 @@
// @vitest-environment jsdom
/**
* PlanChip over the `plan` projection: nothing renders while the capability
* is absent or the effective target is the default mode; the chip renders
* while the target is plan mode (pending follows the target — /plan shows it
* immediately, /plan off hides it immediately); the chip button executes
* /plan off and surfaces failures without hiding until the projection says so.
* is absent; with the capability present the chip renders in both states with
* aria-pressed following the effective target (pending folds — /plan shows
* pressed immediately, /plan off unpressed immediately); clicking executes
* the command toward the opposite target and surfaces direction-specific
* failures while the projection still owns the displayed state.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
@@ -17,78 +18,98 @@ afterEach(cleanup)
function setup(
plan: PlanProjection | undefined,
exitPlanMode = vi.fn(() => Promise.resolve<string | null>(null)),
setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(null)),
locked = false,
) {
const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan })
const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
const props = { useProjection, locked, exitPlanMode } as unknown as PlanChipProps
const props = { useProjection, locked, setPlanMode } as unknown as PlanChipProps
const view = render(<PlanChip {...props} />)
return { store, exitPlanMode, view }
return { store, setPlanMode, view }
}
const chip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' })
const onChip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' })
const offChip = () => screen.getByRole('button', { name: 'Plan mode off, press to turn on' })
describe('PlanChip', () => {
it('renders nothing for absent capability or the default mode', () => {
it('renders nothing while the capability is absent', () => {
const absent = setup(undefined)
expect(absent.view.container.innerHTML).toBe('')
cleanup()
const inactive = setup({ active: false, pending: false })
expect(inactive.view.container.innerHTML).toBe('')
cleanup()
// Active with a pending exit: the target is default — chip already gone.
const leaving = setup({ active: true, pending: true })
expect(leaving.view.container.innerHTML).toBe('')
})
it('renders while the effective target is plan mode, including the pending entry window', () => {
it('reflects the effective target as the pressed state, folding pending', () => {
setup({ active: false, pending: false })
expect(offChip().getAttribute('aria-pressed')).toBe('false')
cleanup()
setup({ active: true, pending: false })
expect(chip()).toBeTruthy()
expect(onChip().getAttribute('aria-pressed')).toBe('true')
cleanup()
// /plan just ran (command/run folded, plan/mode not yet): target is plan.
setup({ active: false, pending: true })
expect(chip()).toBeTruthy()
expect(onChip().getAttribute('aria-pressed')).toBe('true')
cleanup()
// Active with a pending exit: the target is default — already unpressed.
setup({ active: true, pending: true })
expect(offChip().getAttribute('aria-pressed')).toBe('false')
})
it('the chip executes /plan off once and follows the projection down', async () => {
it('unpressed chip executes /plan (on) once and follows the projection up', async () => {
let resolve!: (value: string | null) => void
const exitPlanMode = vi.fn(() => new Promise<string | null>((done) => { resolve = done }))
const { store } = setup({ active: true, pending: false }, exitPlanMode)
fireEvent.click(chip())
expect(exitPlanMode).toHaveBeenCalledTimes(1)
const setPlanMode = vi.fn((_on: boolean) => new Promise<string | null>((done) => { resolve = done }))
const { store } = setup({ active: false, pending: false }, setPlanMode)
fireEvent.click(offChip())
expect(setPlanMode).toHaveBeenCalledTimes(1)
expect(setPlanMode).toHaveBeenLastCalledWith(true)
// Busy while its own call is in flight.
fireEvent.click(chip())
expect(exitPlanMode).toHaveBeenCalledTimes(1)
fireEvent.click(offChip())
expect(setPlanMode).toHaveBeenCalledTimes(1)
resolve(null)
// The off command's run record folds: target flips, the chip unmounts.
// The command's run record folds: target flips, the chip presses.
store.set({ value: { active: false, pending: true } })
await waitFor(() => {
expect(onChip().getAttribute('aria-pressed')).toBe('true')
})
})
it('pressed chip executes /plan off and follows the projection down', async () => {
const setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(null))
const { store } = setup({ active: true, pending: false }, setPlanMode)
fireEvent.click(onChip())
expect(setPlanMode).toHaveBeenLastCalledWith(false)
store.set({ value: { active: true, pending: true } })
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Plan mode on, press to turn off' })).toBeNull()
expect(offChip().getAttribute('aria-pressed')).toBe('false')
})
})
it('disables under the locked owner prop', () => {
setup({ active: true, pending: false }, vi.fn(), true)
expect((chip() as HTMLButtonElement).disabled).toBe(true)
expect((onChip() as HTMLButtonElement).disabled).toBe(true)
})
it('surfaces admission and transport failures while staying visible', async () => {
const exitPlanMode = vi.fn()
it('surfaces direction-specific admission and transport failures while staying visible', async () => {
const exitFailing = vi.fn()
.mockResolvedValueOnce('host said no')
.mockRejectedValueOnce(new Error('network down'))
.mockRejectedValueOnce('socket closed')
setup({ active: true, pending: false }, exitPlanMode)
fireEvent.click(chip())
setup({ active: true, pending: false }, exitFailing)
fireEvent.click(onChip())
expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no')
expect(chip()).toBeTruthy()
expect(onChip()).toBeTruthy()
fireEvent.click(chip())
fireEvent.click(onChip())
expect(await screen.findByTitle('network down')).toBeTruthy()
fireEvent.click(chip())
fireEvent.click(onChip())
expect(await screen.findByTitle('socket closed')).toBeTruthy()
cleanup()
const enterFailing = vi.fn().mockResolvedValueOnce('agent busy')
setup({ active: false, pending: false }, enterFailing)
fireEvent.click(offChip())
expect((await screen.findByText('进入 plan mode 失败')).getAttribute('title')).toBe('agent busy')
expect(offChip()).toBeTruthy()
})
it('ignores in-flight fulfillment and rejection after unmount', () => {
@@ -97,14 +118,14 @@ describe('PlanChip', () => {
{ active: true, pending: false },
vi.fn(() => new Promise<string | null>((done) => { resolve = done })),
)
fireEvent.click(chip())
fireEvent.click(onChip())
successful.view.unmount()
expect(() => { resolve(null) }).not.toThrow()
let reject!: (reason: unknown) => void
const exitPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
const { view } = setup({ active: true, pending: false }, exitPlanMode)
fireEvent.click(chip())
const setPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
const { view } = setup({ active: true, pending: false }, setPlanMode)
fireEvent.click(onChip())
view.unmount()
expect(() => { reject(new Error('late')) }).not.toThrow()
})

View File

@@ -123,6 +123,16 @@ export const IconCheckOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** ic_ds_check_outline_14 */
export const IconCheckOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11.5635 4.58984L7.61426 9.07715C7.35154 9.37561 7.11346 9.64812 6.89453 9.84668C6.66593 10.054 6.38519 10.2506 6.01465 10.3164C5.82079 10.3508 5.62207 10.3529 5.42773 10.3213C5.0561 10.2609 4.77266 10.0674 4.54102 9.86328C4.31926 9.66791 4.07752 9.39911 3.81055 9.10449L2.44531 7.59863L3.55664 6.59082L4.92188 8.09766C5.21256 8.41844 5.38878 8.61191 5.53223 8.73828C5.61022 8.80699 5.65253 8.83192 5.66895 8.83984C5.69648 8.84429 5.72449 8.84467 5.75195 8.83984C5.72657 8.84451 5.75564 8.85422 5.88672 8.73535C6.02833 8.60692 6.20225 8.41088 6.48828 8.08594L10.4385 3.59961L11.5635 4.58984Z"
fill="currentColor"
/>
</svg>
)
/** ic_ds_branch_outline_16 */
export const IconBranchOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -665,13 +675,13 @@ export const IconDataOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** ic_checklist_outline_16 (figma extract): two rings + two list bars. */
export const IconChecklistOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path transform="translate(1.736 2.0752)" d="M12.5279 8.64648V9.92617H6.48105V8.64648H12.5279Z" fill="currentColor" />
<path transform="translate(1.736 2.0752)" d="M12.5279 1.92275V3.20244H6.48105V1.92275H12.5279Z" fill="currentColor" />
<path transform="translate(1.736 2.0752)" d="M3.84531 9.28623C3.84525 8.57774 3.271 8.00342 2.5625 8.00342C1.85405 8.00348 1.27975 8.57778 1.27969 9.28623C1.27969 9.99474 1.85401 10.569 2.5625 10.569C3.27105 10.569 3.84531 9.99478 3.84531 9.28623ZM5.12578 9.28623C5.12578 10.7017 3.97797 11.8495 2.5625 11.8495C1.14709 11.8494 0 10.7017 0 9.28623C6.59755e-05 7.87086 1.14713 6.7238 2.5625 6.72373C3.97793 6.72373 5.12572 7.87082 5.12578 9.28623Z" fill="currentColor" />
<path transform="translate(1.736 2.0752)" d="M3.84551 2.5625C3.84549 1.85402 3.27118 1.27969 2.5627 1.27969C1.85422 1.2797 1.2799 1.85403 1.27988 2.5625C1.27988 3.27098 1.85422 3.8453 2.5627 3.84531C3.27119 3.84531 3.84551 3.27099 3.84551 2.5625ZM5.1252 2.5625C5.1252 3.97792 3.97811 5.125 2.5627 5.125C1.14729 5.12499 0.000195313 3.97791 0.000195313 2.5625C0.000208508 1.1471 1.1473 1.31957e-05 2.5627 0C3.9781 0 5.12518 1.1471 5.1252 2.5625Z" fill="currentColor" />
/** ic_checklist_outline_14 (figma extract): two rings + two list bars. */
export const IconChecklistOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.3277 9.69629V10.976H7.28086V9.69629H13.3277Z" fill="currentColor" />
<path d="M13.3277 2.97256V4.25225H7.28086V2.97256H13.3277Z" fill="currentColor" />
<path d="M4.64512 10.336C4.64505 9.62755 4.07081 9.05322 3.3623 9.05322C2.65386 9.05329 2.07956 9.62759 2.07949 10.336C2.07949 11.0445 2.65382 11.6188 3.3623 11.6188C4.07085 11.6188 4.64512 11.0446 4.64512 10.336ZM5.92559 10.336C5.92559 11.7515 4.77777 12.8993 3.3623 12.8993C1.94689 12.8993 0.799805 11.7515 0.799805 10.336C0.799871 8.92066 1.94693 7.7736 3.3623 7.77354C4.77773 7.77354 5.92552 8.92062 5.92559 10.336Z" fill="currentColor" />
<path d="M4.64531 3.6123C4.6453 2.90382 4.07098 2.32949 3.3625 2.32949C2.65403 2.32951 2.0797 2.90383 2.07969 3.6123C2.07969 4.32079 2.65402 4.8951 3.3625 4.89512C4.07099 4.89512 4.64531 4.3208 4.64531 3.6123ZM5.925 3.6123C5.925 5.02772 4.77792 6.1748 3.3625 6.1748C1.9471 6.17479 0.8 5.02771 0.8 3.6123C0.800013 2.19691 1.9471 1.04982 3.3625 1.0498C4.77791 1.0498 5.92499 2.1969 5.925 3.6123Z" fill="currentColor" />
</svg>
)
@@ -703,3 +713,18 @@ export const IconSparkle16 = ({ size = 16, className }: IconProps) => (
<path d="M12.5 9.4Q12.7 11.4 14.7 11.6Q12.7 11.8 12.5 13.8Q12.3 11.8 10.3 11.6Q12.3 11.4 12.5 9.4Z" fill="currentColor" />
</svg>
)
/** ic_ds_question_outline_14 (figma extract): ring + question glyph. */
export const IconQuestionOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.5757 7.00012C12.5757 3.92085 10.0794 1.42463 7.00012 1.42456C3.9208 1.42456 1.42456 3.9208 1.42456 7.00012C1.42463 10.0794 3.92085 12.5757 7.00012 12.5757C10.0793 12.5756 12.5756 10.0793 12.5757 7.00012ZM13.8002 7.00012C13.8001 10.7559 10.7559 13.8001 7.00012 13.8002C3.2443 13.8002 0.199291 10.7559 0.199219 7.00012C0.199219 3.24426 3.24426 0.199219 7.00012 0.199219C10.7559 0.199291 13.8002 3.2443 13.8002 7.00012Z"
fill="currentColor"
/>
<path
d="M6.18042 8.68184C6.18043 8.09153 6.32893 7.34655 6.92127 6.8481C7.28566 6.54148 7.76104 6.27318 8.0022 6.10811C8.28964 5.91137 8.42234 5.76562 8.48328 5.58944C8.57774 5.31609 8.53121 5.00904 8.34912 4.76741C8.17409 4.53522 7.83879 4.32222 7.28186 4.32222C5.99668 4.32225 5.46969 5.11832 5.46949 5.78939H4.24414C4.24436 4.39942 5.36327 3.09691 7.28186 3.09688C8.17773 3.09688 8.89489 3.45606 9.32752 4.02999C9.75287 4.59438 9.86938 5.32775 9.64026 5.99019C9.44847 6.5444 9.04722 6.87743 8.69434 7.11898C8.29506 7.39226 8.02318 7.52192 7.70996 7.78548C7.51943 7.94582 7.40577 8.24899 7.40577 8.68184V8.75533H6.18042V8.68184Z"
fill="currentColor"
/>
<path d="M7.39455 9.44026V10.8109H6.16921V9.44026H7.39455Z" fill="currentColor" />
</svg>
)

View File

@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full P-I set (44 deepsuite + 13 figma extracts + the hand-authored sparkle)', () => {
expect(iconNames.length).toBe(58)
it('exports the full P-I set (45 deepsuite + 14 figma extracts + the hand-authored sparkle)', () => {
expect(iconNames.length).toBe(60)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {

View File

@@ -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-question/README.md
README.md: 3a3cd639fc2834685230aca7c8087583e0a48c71
README.zh.md: 1330578577da7ed7d0890595f675fd272fd5ebc7
README.md: 0700375758774610fcd897b9a3e16484206a871d
README.zh.md: d9e5eb22cef13e16ab1ce2cebba9e563bd9d08d9

View File

@@ -8,6 +8,8 @@ The component renders one question at a time with progress navigation, single- a
Selection state is local to a component keyed by the request rpcId. A replay with the same id preserves a still-mounted draft, while `question/resolved` from the host removes the composer. The host remains authoritative: successful HTTP delivery does not remove pending state locally.
Composer chrome copy (pager, buttons, placeholders, validation feedback) is bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry its bound translator plus the locale snapshot source through the inject face, so a locale switch re-renders a mounted composer. Question and option text arrives from the model and renders verbatim; carrier failure messages also display untranslated.
## Model Experience
Indirectly, through `dsh-tool-ask-user`; that package owns the model-visible tool schema and structured result.

View File

@@ -8,6 +8,8 @@ Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧
选择状态只存在于以请求 rpcId 为 key 的组件本地。使用相同 id 回放时,只要组件仍挂载,就会保留草稿;主机发出的 `question/resolved` 则会移除编辑器。主机仍具有最终决定权HTTP 交付成功不会在本地移除待处理状态。
编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale``question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该配置项,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。
## 模型体验
通过 `dsh-tool-ask-user` 间接影响;该包拥有模型可见的工具 schema 和结构化结果。

View File

@@ -1,28 +1,33 @@
/* The takeover seats where the input card sits, so the frame mirrors the
InputBar geometry (side pad 32, card cap 800) to keep both edges flush. */
.frame {
display: flex;
justify-content: center;
padding: 6px 24px 10px;
padding: 6px 32px 10px;
}
/* Figma Input 973:36348 body over the 1019:36938 header: no banner strip —
the card keeps zero padding and sections carry their own insets. */
.card {
display: flex;
flex-direction: column;
width: 100%;
max-width: 720px;
max-width: 800px;
/* Composer seat sits in a fixed-height conversation column (overflow
hidden): cap the card against the viewport and scroll the option list
so header and footer actions stay reachable on long batches. */
max-height: min(60vh, 520px);
padding: 14px 16px 12px;
padding: 0 0 10px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 18px;
border-radius: 20px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv1-blur);
box-shadow: var(--dsw-shadow-lv2);
color: var(--dsw-alias-label-primary);
/* Elevated surface in dark, same as the menus: the option list inside scrolls
overflow: hidden;
/* Elevated surface in dark, same as the menus: the body inside scrolls
once the card hits the cap above, so the thumb takes the l2 pair. Declared
on the card because the elevation belongs to the surface, and the custom
properties inherit down to `.options` (see ui-theme styles/scrollbar.css
properties inherit down to `.body` (see ui-theme styles/scrollbar.css
for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
@@ -33,64 +38,63 @@
box-sizing: border-box;
}
/* Figma 1019:36938 header, user-tuned: heading block left, close right; the
pager sits in the footer to balance the card. */
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-shrink: 0;
margin-bottom: 8px;
padding: 20px 16px 0 24px;
}
.headingBlock {
min-width: 0;
padding: 1px 2px;
}
.eyebrow {
margin-bottom: 2px;
/* Eyebrow-to-title gap widened from the figma 2px (user-tuned). */
margin-bottom: 5px;
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 16px;
}
.title {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px;
margin: 0;
font-size: 16px;
line-height: 22px;
font-weight: 600;
}
.multiSelectHint {
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 20px;
font-weight: 400;
white-space: nowrap;
font-weight: 500;
}
.detail {
margin: 0 2px 8px;
}
.headerActions,
.footerActions {
display: flex;
align-items: center;
gap: 4px;
gap: 12px;
flex-shrink: 0;
}
.pager {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.progress {
padding: 0 6px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
padding: 0 4px;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 24px;
font-weight: 500;
white-space: nowrap;
/* Narrow the plain spaces around the slash without touching glyph tracking. */
word-spacing: -2px;
}
.iconButton {
@@ -128,21 +132,23 @@
.options {
display: flex;
flex-direction: column;
gap: 4px;
gap: 1px;
margin: 8px 0 0;
padding: 4px 12px;
}
.option {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
width: 100%;
min-height: 42px;
min-height: 40px;
/* Rows are the scroll content, never the slack absorber: a shrinkable row
collapses to min-height while its wrapped copy keeps the taller
intrinsic height, and centered content then paints outside the row box —
over the title and the next row. Overflow belongs to .options. */
flex-shrink: 0;
padding: 5px 8px;
padding: 6px 12px 6px 8px;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
@@ -161,25 +167,63 @@
border-color: var(--dsw-alias-border-l2);
}
.option:disabled,
.customTrigger:disabled {
.option:disabled {
cursor: default;
}
/* Leading indicator (figma 20×20, radius 6): single-select shows the option
number, multi-select swaps in a checkbox; the custom-answer row follows —
its checkbox mirrors the typed draft (styling only, exclusivity holds). */
.number {
display: grid;
place-items: center;
flex: 0 0 28px;
width: 28px;
height: 28px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-tertiary);
flex: 0 0 20px;
width: 20px;
height: 20px;
border-radius: 6px;
background: var(--dsw-alias-bg-overlay);
color: var(--dsw-alias-label-secondary);
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
/* Multi-select box (figma 1055:41594, user-tuned down to 14×14): a radius-4
box centered in the 20px indicator seat; the box itself is the ::before
layer so the check icon stacks over it in the same grid cell. */
.checkbox {
display: grid;
place-items: center;
flex: 0 0 20px;
width: 20px;
height: 20px;
}
.checkbox::before {
content: '';
grid-area: 1 / 1;
width: 14px;
height: 14px;
border: 1px solid var(--dsw-alias-border-l4);
border-radius: 4px;
transition: background-color 120ms ease, border-color 120ms ease;
}
.checkbox > svg {
grid-area: 1 / 1;
}
/* Checked: label-primary fill with a primary-foreground check — the pair
inverts with the theme (dark fill in light mode, light fill in dark mode). */
.checkboxChecked {
color: var(--dsw-alias-label-primary-foreground);
}
.checkboxChecked::before {
border-color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-label-primary);
}
.optionCopy {
min-width: 0;
flex: 1;
@@ -194,103 +238,101 @@
.optionLabel {
font-size: 14px;
line-height: 20px;
font-weight: 600;
line-height: 24px;
font-weight: 500;
}
.badge {
padding: 0 6px;
border-radius: 999px;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-secondary);
padding: 0 4px;
border-radius: 6px;
background: var(--dsw-specific-sidebar-nav-item-active-accent);
color: var(--dsw-alias-button-info-fill);
font-size: 11px;
line-height: 18px;
font-weight: 600;
}
.description {
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
line-height: 20px;
font-size: 14px;
line-height: 24px;
font-weight: 400;
}
.choiceIcon {
display: grid;
place-items: center;
width: 20px;
color: var(--dsw-alias-label-tertiary);
}
.custom {
/* Same reason as .option: the custom block is scroll content, and shrinking
it pushes its trigger row (and the open textarea) past the footer. */
flex-shrink: 0;
border: 1px solid transparent;
border-radius: 12px;
}
.customOpen {
border-color: var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-module-platform);
}
.customOptionless {
border: none;
background: transparent;
}
.customTrigger {
/* Custom answer row (figma 973:36427): an option-shaped row whose copy is an
inline text input; focus or a typed draft lifts it to the selected look. */
.customRow {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
width: 100%;
min-height: 42px;
padding: 5px 8px;
border: none;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 20px;
text-align: left;
cursor: pointer;
min-height: 40px;
/* Same reason as .option: the custom row is scroll content, and shrinking
it pushes the inline input past the footer. */
flex-shrink: 0;
padding: 6px 12px 6px 8px;
border: 1px solid transparent;
border-radius: 12px;
transition: background-color 120ms ease, border-color 120ms ease;
}
.customTrigger:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
.customRow:hover,
.customRow:focus-within,
.customRowActive {
background: var(--dsw-alias-interactive-bg-hover);
}
.customRow:focus-within,
.customRowActive {
border-color: var(--dsw-alias-border-l2);
}
.customInput {
display: block;
width: calc(100% - 20px);
min-height: 54px;
max-height: 140px;
margin: 0 10px 10px;
padding: 7px 10px;
resize: vertical;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
flex: 1;
min-width: 0;
padding: 0;
border: none;
outline: none;
background: var(--dsw-specific-input-major);
background: transparent;
color: var(--dsw-alias-label-primary);
caret-color: var(--dsw-alias-state-business-primary);
font: inherit;
font-size: 13px;
line-height: 20px;
}
.customInput:focus {
border-color: var(--dsw-alias-state-business-primary);
font-size: 14px;
line-height: 24px;
}
.customInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.customOptionless .customInput {
width: 100%;
min-height: 58px;
margin: 0;
/* Optionless question: the free-form answer is the whole body. The 12px side
margins add to the .options 12px padding so both edges align with the
title's 24px inset; type matches the option rows, no resize handle. */
.customTextarea {
display: block;
min-height: 64px;
max-height: 140px;
flex-shrink: 0;
margin: 0 12px;
padding: 8px 12px;
resize: none;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px;
outline: none;
background: var(--dsw-alias-bg-module-platform);
color: var(--dsw-alias-label-primary);
caret-color: var(--dsw-alias-state-business-primary);
font: inherit;
font-size: 14px;
line-height: 24px;
}
.customTextarea:focus {
border-color: var(--dsw-alias-state-business-primary);
}
.customTextarea::placeholder {
color: var(--dsw-alias-label-caption);
}
.footer {
@@ -299,15 +341,17 @@
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
margin-top: 8px;
padding: 0 2px;
margin-top: 12px;
padding: 0 10px 0 18px;
}
.feedback {
flex: 1;
min-height: 16px;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
line-height: 16px;
text-align: right;
}
@media (max-width: 720px) {
@@ -316,21 +360,15 @@
}
.card {
padding: 12px 10px 10px;
border-radius: 16px;
}
.header {
display: block;
padding: 10px 12px 0 18px;
}
.headerActions {
justify-content: flex-end;
margin-top: 8px;
}
.headingBlock {
padding: 0 2px;
.options {
padding: 4px 8px;
}
.title {
@@ -339,18 +377,15 @@
}
.option,
.customTrigger {
.customRow {
align-items: flex-start;
gap: 8px;
padding: 6px;
}
.choiceIcon {
margin-top: 3px;
}
.footer {
align-items: flex-end;
padding: 0 10px;
}
.footerActions {
@@ -359,7 +394,8 @@
}
@media (prefers-reduced-motion: reduce) {
.option {
.option,
.customRow {
transition: none;
}
}

View File

@@ -1,7 +1,7 @@
import { useMemo, useState, type KeyboardEvent } from 'react'
import { useMemo, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import clsx from 'clsx'
import {
Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
Button, IconCheckOutline14, IconChevronLeftOutline14, IconChevronRightOutline14,
IconCloseOutline16, IconEditOutline16, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import {
@@ -13,10 +13,17 @@ import css from './QuestionComposer.module.css'
interface DraftAnswer {
selected: string[]
custom: string
customOpen: boolean
skipped: boolean
}
/**
* Displayed feedback: validation feedback is stored as a dictionary KEY and
* translated at render, so already-shown feedback follows a locale switch;
* runtime failure messages (finished strings from the wire) pass through
* verbatim.
*/
type Feedback = { key: 'error.incomplete' | 'error.unanswered' } | { text: string }
/**
* Split the conventional recommendation suffix without changing the answer value.
* @param label - Original option label returned if selected.
@@ -29,17 +36,8 @@ export function parseRecommendedLabel(label: string): { label: string; recommend
: { label, recommended: false }
}
/**
* Remove a conventional multi-select suffix so the hint can be styled separately.
* @param title - Question title supplied by the interaction request.
* @returns Question title without a trailing multi-select marker.
*/
export function parseQuestionTitle(title: string): string {
return title.replace(/\s*[(]可多选[)]\s*$/, '')
}
/** Return whether a textarea key event belongs to an active IME composition. */
function isComposing(event: KeyboardEvent<HTMLTextAreaElement>): boolean {
/** Return whether a text-field key event belongs to an active IME composition. */
function isComposing(event: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>): boolean {
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// oxlint-disable-next-line typescript/no-deprecated
return event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229
@@ -61,14 +59,11 @@ export function QuestionComposer(props: QuestionComposerProps) {
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
const questions = pending.questions
const [index, setIndex] = useState(0)
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(question => ({
selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(() => ({
selected: [], custom: '', skipped: false,
})))
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
// Validation feedback is stored as a dictionary KEY and translated at
// render, so already-shown feedback follows a locale switch; runtime
// failure messages (finished strings from the wire) pass through verbatim.
const [error, setError] = useState<{ key: 'error.incomplete' | 'error.unanswered' } | { text: string } | null>(null)
const [error, setError] = useState<Feedback | null>(null)
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
// oxlint-disable-next-line typescript/no-non-null-assertion
const question = questions[index]!
@@ -97,17 +92,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
? current.selected.filter(item => item !== label)
: [...current.selected, label]
: [label]
return { selected, custom: '', customOpen: false, skipped: false }
return { selected, custom: '', skipped: false }
})
if (question.multiSelect !== true && index < questions.length - 1) {
setIndex(current => current + 1)
}
}
const openCustom = (): void => {
updateDraft(current => ({ ...current, selected: [], customOpen: true, skipped: false }))
}
const answered = (item: DraftAnswer): boolean =>
item.selected.length > 0 || item.custom.trim() !== ''
@@ -153,13 +144,26 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
submitDrafts(drafts)
}
// Shared by the inline custom input and the optionless textarea: typing a
// custom draft clears any selection, and Enter continues the flow
// (Shift+Enter stays a newline in the textarea; on the single-line input it
// is inert either way).
const draftCustom = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
const value = event.target.value
updateDraft(current => ({
...current, selected: [], custom: value, skipped: false,
}))
}
const continueFromCustom = (event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
if (event.key !== 'Enter' || event.shiftKey || isComposing(event)) return
event.preventDefault()
continueFlow()
}
const skipQuestion = (): void => {
const nextDrafts = drafts.map((item, itemIndex) => itemIndex === index
? {
selected: [], custom: '',
customOpen: (question.options?.length ?? 0) === 0,
skipped: true,
}
? { selected: [], custom: '', skipped: true }
: item)
setDrafts(nextDrafts)
setError(null)
@@ -177,38 +181,16 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
<div className={css.headingBlock}>
{question.header !== undefined && <div className={css.eyebrow}>{question.header}</div>}
<h2 className={css.title} id={`question-${pending.key}-${String(index)}`}>
<span>{question.multiSelect === true
? parseQuestionTitle(question.question)
: question.question}</span>
{question.multiSelect === true && (
<span className={css.multiSelectHint}>{t('title.multi')}</span>
)}
{question.question}
</h2>
</div>
<div className={css.headerActions}>
<span className={css.progress}>{index + 1} / {questions.length}</span>
<button
type="button" className={css.iconButton} aria-label={t('nav.prev')}
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
>
<IconChevronLeftOutline14 />
</button>
<button
type="button" className={css.iconButton} aria-label={t('nav.next')}
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
>
<IconChevronRightOutline14 />
</button>
<button
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
title={t('nav.cancel')}
disabled={busy !== null} onClick={cancelFlow}
>
<IconCloseOutline16 />
</button>
</div>
<button
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
title={t('nav.cancel')}
disabled={busy !== null} onClick={cancelFlow}
>
<IconCloseOutline16 />
</button>
</header>
<div className={css.body} data-question-scroll>
@@ -222,7 +204,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
return (
<button
type="button" key={`${option.label}-${String(optionIndex)}`}
className={clsx(css.option, selected && css.optionSelected)}
className={clsx(css.option, selected && question.multiSelect !== true && css.optionSelected)}
role={question.multiSelect === true ? 'checkbox' : 'radio'}
aria-checked={selected}
aria-label={display.label}
@@ -234,7 +216,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
submitDrafts(drafts)
}}
>
<span className={css.number}>{optionIndex + 1}</span>
{question.multiSelect === true
? (
<span className={clsx(css.checkbox, selected && css.checkboxChecked)} aria-hidden="true">
{selected && <IconCheckOutline14 size={12} />}
</span>
)
: <span className={css.number}>{optionIndex + 1}</span>}
<span className={css.optionCopy}>
<span className={css.optionLine}>
<span className={css.optionLabel}>{display.label}</span>
@@ -246,62 +234,80 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<Questi
)}
</span>
</span>
<span className={css.choiceIcon}>
{selected ? <IconCheckOutline16 /> : <IconChevronRightOutline14 />}
</span>
</button>
)
})}
<div className={clsx(
css.custom,
draft.customOpen && css.customOpen,
!hasOptions && css.customOptionless,
)}>
{hasOptions && (
<button
type="button" className={css.customTrigger}
disabled={busy !== null} onClick={openCustom}
aria-expanded={draft.customOpen}
>
<span className={css.number}><IconEditOutline16 /></span>
<span>{t('option.custom')}</span>
</button>
)}
{draft.customOpen && (
{hasOptions
? (
<div className={clsx(css.customRow, draft.custom !== '' && css.customRowActive)}>
{question.multiSelect === true
? (
<span
className={clsx(css.checkbox, draft.custom !== '' && css.checkboxChecked)}
aria-hidden="true"
>
{draft.custom !== '' && <IconCheckOutline14 size={12} />}
</span>
)
: (
<span className={css.number} aria-hidden="true">
<IconEditOutline16 size={12} />
</span>
)}
<input
type="text"
className={css.customInput}
value={draft.custom}
disabled={busy !== null}
placeholder={t('custom.placeholder')}
onChange={draftCustom}
onKeyDown={continueFromCustom}
/>
</div>
)
: (
<textarea
autoFocus
className={css.customInput}
className={css.customTextarea}
value={draft.custom}
disabled={busy !== null}
rows={2}
placeholder={t('custom.placeholder')}
onChange={(event) => {
const value = event.target.value
updateDraft(current => ({
...current, selected: [], custom: value, customOpen: true, skipped: false,
}))
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
event.preventDefault()
continueFlow()
}
}}
onChange={draftCustom}
onKeyDown={continueFromCustom}
/>
)}
</div>
</div>
</div>
<footer className={css.footer}>
<div className={css.feedback} role="status">{error === null ? null : 'key' in error ? t(error.key) : error.text}</div>
<div className={css.pager}>
<button
type="button" className={css.iconButton} aria-label={t('nav.prev')}
disabled={index === 0 || busy !== null}
onClick={() => { setIndex(index - 1); setError(null) }}
>
<IconChevronLeftOutline14 />
</button>
<span className={css.progress}>{index + 1} / {questions.length}</span>
<button
type="button" className={css.iconButton} aria-label={t('nav.next')}
disabled={index === questions.length - 1 || busy !== null}
onClick={() => { setIndex(index + 1); setError(null) }}
>
<IconChevronRightOutline14 />
</button>
</div>
<div className={css.feedback} role="status">
{error === null ? null : 'key' in error ? t(error.key) : error.text}
</div>
<div className={css.footerActions}>
<Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
<Button variant="outline" disabled={busy !== null} onClick={skipQuestion}>
{t('action.skip')}
</Button>
<Button
variant="primary" size="sm"
variant="primary"
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
>
{busy === 'answer'

View File

@@ -4,12 +4,10 @@
export const zh = {
'error.incomplete': '请先完成这道问题。',
'error.unanswered': '请选择一个选项或填写自定义答案。',
'title.multi': '可多选',
'nav.prev': '上一题',
'nav.next': '下一题',
'nav.cancel': '放弃整组问题',
'option.recommended': '推荐',
'option.custom': '其他,请填写自定义答案',
'custom.placeholder': '输入你的答案',
'action.skip': '跳过本题',
'action.next': '下一题',
@@ -22,12 +20,10 @@ export type QuestionKey = keyof typeof zh
export const en = {
'error.incomplete': 'Please complete this question first.',
'error.unanswered': 'Please select an option or enter a custom answer.',
'title.multi': 'Multi-select',
'nav.prev': 'Previous question',
'nav.next': 'Next question',
'nav.cancel': 'Dismiss all questions',
'option.recommended': 'Recommended',
'option.custom': 'Other — enter a custom answer',
'custom.placeholder': 'Type your answer',
'action.skip': 'Skip this question',
'action.next': 'Next',

View File

@@ -9,19 +9,22 @@ import type { RpcReceipt } from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { PendingQuestion, type QuestionComposerProps } from '../src/client/contract/slots.ts'
import {
QuestionComposer, parseQuestionTitle, parseRecommendedLabel,
} from '../src/client/QuestionComposer.tsx'
import { zh } from '../src/client/locales.ts'
import { QuestionComposer, parseRecommendedLabel } from '../src/client/QuestionComposer.tsx'
import { en, zh } from '../src/client/locales.ts'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
afterEach(cleanup)
const SID = 's1' as SessionId
/** Framework standard-kit stubs: the composer consumes none of them, the
* composed props type mandates their delivery (framework hooks are plain
* stubs per the client testing discipline). */
/** Seat stub over a dictionary pair mirroring the real lookup chain: package dictionary, then common vocabulary, then the key. */
const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
(key => dict[key] ?? common[key] ?? key)
/** Framework standard-kit stubs: the composer consumes only the locale seat;
* the composed props type mandates delivery of the rest (framework hooks are
* plain stubs per the client testing discipline). */
const kit = {
sessionId: SID,
useSession: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<ConversationSnapshot>,
@@ -30,11 +33,8 @@ const kit = {
useProjection: (() => undefined) as never,
useInput: (() => { throw new Error('unused') }) as never,
inputActions: { setDraft: () => { throw new Error('unused') }, submit: () => { throw new Error('unused') } } as never,
// The seat's key domain is question common; the stub mirrors the real
// lookup chain: package dictionary, then common vocabulary, then the key.
t: (key => (zh as Record<string, string>)[key]
?? (commonZh as Record<string, string>)[key]
?? key) as QuestionComposerProps['t'],
// The seat's key domain is question common.
t: seatOver(zh, commonZh),
}
const QUESTIONS = [
@@ -75,6 +75,7 @@ describe('QuestionComposer', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
expect(screen.getByText('偏好')).toBeTruthy()
expect(screen.getByText('1 / 3')).toBeTruthy()
expect(screen.getByText('推荐')).toBeTruthy()
expect(screen.getByText('工程落地型')).toBeTruthy()
@@ -96,9 +97,8 @@ describe('QuestionComposer', () => {
fireEvent.keyDown(custom, { key: 'Enter' })
expect(screen.getByText('3 / 3')).toBeTruthy()
expect(screen.getByText('选择重要信号')).toBeTruthy()
expect(screen.getByText('可多选')).toBeTruthy()
expect(screen.queryByText('(可多选)')).toBeNull()
// The model's question text renders verbatim — no marker filtering.
expect(screen.getByText('选择重要信号(可多选')).toBeTruthy()
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' }))
@@ -175,11 +175,10 @@ describe('QuestionComposer', () => {
expect(screen.getByText('3 / 3')).toBeTruthy()
})
it('opens custom input, reports missing skipped answers, and supports header navigation', () => {
it('shows the inline custom input, reports missing answers, and supports pager navigation', () => {
const { carrier, respond } = wait()
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: '其他,请填写自定义答案' }))
expect(screen.getByPlaceholderText('输入你的答案')).toBeTruthy()
fireEvent.click(screen.getByRole('radio', { name: '工程落地型' }))
const emptyCustom = screen.getByPlaceholderText('输入你的答案')
@@ -240,6 +239,16 @@ describe('QuestionComposer', () => {
expect(await screen.findByText('字符串错误')).toBeTruthy()
})
it('renders chrome copy through the English dictionary', () => {
const respond = vi.fn(() => Promise.resolve<RpcReceipt>({ accepted: true }))
const carrier = new PendingWait(
'question', RpcId('solo'), SID, { questions: [{ id: 'detail', question: '补充你的要求' }] }, respond)
render(<QuestionComposer matched={carrier} interactions={[carrier]} {...kit} t={seatOver(en, commonEn)} />)
expect(screen.getByLabelText('Dismiss all questions')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Skip this question' })).toBeTruthy()
expect(screen.getByPlaceholderText('Type your answer')).toBeTruthy()
})
it('same-key carrier replacement (baseline replay) keeps drafts', () => {
const first = wait('same-id')
const view = render(<QuestionComposer matched={first.carrier} interactions={[first.carrier]} {...kit} />)
@@ -295,11 +304,3 @@ describe('parseRecommendedLabel', () => {
expect(parseRecommendedLabel('Plain')).toEqual({ label: 'Plain', recommended: false })
})
})
describe('parseQuestionTitle', () => {
it('removes Chinese and ASCII multi-select suffixes', () => {
expect(parseQuestionTitle('选择信号(可多选)')).toBe('选择信号')
expect(parseQuestionTitle('选择信号 (可多选)')).toBe('选择信号')
expect(parseQuestionTitle('选择信号')).toBe('选择信号')
})
})

View File

@@ -192,6 +192,13 @@
overflow-wrap: break-word;
}
.hoverPath {
font-size: 12px;
line-height: 16px;
color: #CFD3D6;
word-break: break-all;
}
.hoverTime {
font-size: 12px;
line-height: 16px;

View File

@@ -2,8 +2,8 @@
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
* except workspace Rename/Delete and session Rename; the session hover card is
* suppressed while a menu is open.
* except workspace Rename/Delete and session Rename; the session and workspace
* hover cards are suppressed while a menu is open.
*/
import { useState } from 'react'
import clsx from 'clsx'
@@ -30,10 +30,26 @@ const WORKSPACE_MENU_ITEMS = [
{ id: 'delete', label: 'Delete workspace', icon: <IconTrashOutline16 />, danger: true },
]
/** Hover-card body: workspace title, full directory path, absolute creation time. */
function WorkspaceHoverContent({ label, cwd, createdAt }: {
label: string
cwd: string | undefined
createdAt: number
}) {
return (
<div className={css.hoverContent}>
<div className={css.hoverTitle}>{label}</div>
<div className={css.hoverPath}>{cwd}</div>
<div className={css.hoverTime}>{`Created ${new Date(createdAt).toLocaleString()}`}</div>
</div>
)
}
/**
* Project (workspace) header row: 54px, folder + title + session count;
* hover reveals the chevron and create button. `containsCurrent` arrives on
* the node (derivation fact, no renderer scan).
* hover reveals the chevron and create button, and dwelling on a real
* Workspace shows its hover card (the ungrouped bucket has none).
* `containsCurrent` arrives on the node (derivation fact, no renderer scan).
* @param props.group - derived group node.
* @param props.onToggle - expand/collapse the group.
* @param props.onCreate - start a frontend Session inside this Workspace.
@@ -50,7 +66,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
const active = group.expanded && group.containsCurrent
const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}`
const [menuOpen, setMenuOpen] = useState(false)
return (
const ownRow = (
<div
className={clsx(css.projectRow, menuOpen && css.menuOpen)}
role="treeitem"
@@ -107,6 +123,15 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
</span>
</div>
)
// The ungrouped bucket has no backing Workspace: no card to show.
if (row.createdAt === undefined) return ownRow
return (
<HoverCard
anchor={ownRow}
content={<WorkspaceHoverContent label={row.label} cwd={row.cwd} createdAt={row.createdAt} />}
disabled={menuOpen}
/>
)
}
/**

View File

@@ -31,6 +31,8 @@ export interface GroupNode {
/** Backing Workspace id; absent only for the ungrouped bucket. */
workspaceId: WorkspaceId | undefined
cwd: string | undefined
/** Workspace creation time (epoch ms); absent only for the ungrouped bucket. */
createdAt: number | undefined
label: string
/** Total visible sessions in the group. */
sessionCount: number
@@ -52,6 +54,7 @@ interface Group {
key: string
workspaceId: WorkspaceId | undefined
cwd: string | undefined
createdAt: number | undefined
label: string
summaries: Map<SessionId, SessionSummary>
roots: SessionId[]
@@ -91,6 +94,7 @@ function buildGroup(
key: string,
workspaceId: WorkspaceId | undefined,
cwd: string | undefined,
createdAt: number | undefined,
label: string,
members: readonly SessionSummary[],
order: 'account' | 'recency',
@@ -142,7 +146,7 @@ function buildGroup(
for (const m of members) {
if (!reachable.has(m.id)) rootIds.push(m.id)
}
return { key, workspaceId, cwd, label, summaries, roots: rootIds, children }
return { key, workspaceId, cwd, createdAt, label, summaries, roots: rootIds, children }
}
/**
@@ -163,7 +167,8 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
members.push(summary)
}
groups.push(buildGroup(
workspace.workspaceId, workspace.workspaceId, workspace.path, workspace.title, members, 'account',
workspace.workspaceId, workspace.workspaceId, workspace.path,
Date.parse(workspace.createdAt), workspace.title, members, 'account',
))
}
const stray = list.ids
@@ -171,7 +176,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
.filter((s): s is SessionSummary =>
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current))
if (stray.length > 0) {
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
}
return groups
}
@@ -267,6 +272,7 @@ export function deriveGroups(
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
createdAt: g.createdAt,
label: g.label,
sessionCount: g.summaries.size,
expanded,
@@ -280,6 +286,7 @@ export function deriveGroups(
key: g.key,
workspaceId: g.workspaceId,
cwd: g.cwd,
createdAt: g.createdAt,
label: g.label,
sessionCount: g.summaries.size,
expanded: visible.size > 0,

View File

@@ -42,7 +42,7 @@ describe('workspace browser rows', () => {
const onToggle = vi.fn()
const onCreate = vi.fn()
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
sessionCount: 1, expanded: true, containsCurrent: true, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />)
@@ -103,7 +103,7 @@ describe('workspace browser rows', () => {
const onDelete = vi.fn()
const onToggle = vi.fn()
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
}
render(<ProjectRowItem
@@ -128,9 +128,28 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('menu')).toBeNull()
})
it('workspace hover card shows title, directory path, and creation time after the dwell', () => {
vi.useFakeTimers()
try {
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />)
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
// Card body: full title + cwd + absolute creation time.
expect(screen.getAllByText('Project')).toHaveLength(2)
expect(screen.getByText('/projects/project')).toBeTruthy()
expect(screen.getByText(/^Created /)).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('ungrouped bucket renders no workspace menu', () => {
const group: GroupNode = {
key: '', workspaceId: undefined, cwd: undefined, label: 'Ungrouped',
key: '', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: 'Ungrouped',
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />)

View File

@@ -2,7 +2,7 @@
* React renderer for declarative slots. Per-entry bindings enforce child
* authorization, and entry boundaries contain registrant failures.
*/
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import { Component, useState, useSyncExternalStore, type FC, type ReactNode } from 'react'
import {
SlotOwnershipError, StaleAuthorizationError,
type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts,
@@ -366,15 +366,69 @@ function SessionEntry({ entry, ownerProps, info }: {
return <Comp {...kit} {...injected} {...ownerProps} />
}
function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
function SessionMaybeEntryBody({ entry, ownerProps, info }: {
entry: StoredEntry
ownerProps: object
info: SessionMaybeProvideInfo
}) {
const host = useHost()
const info = useSessionMaybeProvideInfo()
const Comp = entry.component as FC<InjectedProps>
const { kit, actions } = standardKit(host, entry, 'session-maybe', info)
const injected = cachedSessionMaybeInject(entry, info, actions)
return <Comp {...kit} {...injected} {...ownerProps} />
}
/**
* Session-maybe identity: adoption — the ONLY behavior (there is no
* hold-identity-forever mode). An incarnation born session-less ADOPTS the
* first session that arrives: identity holds across that one transition
* (undefined → first id), so a blank shell's DOM survives the moment a
* session appears. From then on the entry behaves exactly like a strict
* session entry: switching to a DIFFERENT session remounts (component-local
* state must not leak between sessions), and dropping back to no-session
* remounts into a fresh blank incarnation, which will adopt again.
* Component-local per-session state therefore clears by construction; state
* that must SURVIVE a switch belongs in session-bound sources (machine,
* store, hooks) — the existing layering rule, now load-bearing.
*/
function SessionMaybeEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
const info = useSessionMaybeProvideInfo()
// The child key is an incarnation counter, NOT the session id: adoption
// must keep the key constant across undefined → first id. Bookkeeping
// lives in this stable (unkeyed) wrapper via the render-phase setState
// form (React's sanctioned derived-state pattern: setState during render
// of the same component re-renders once before children mount, and the
// guard conditions make it convergent — StrictMode-safe).
const [state, setState] = useState<MaybeIncarnation>(FIRST_INCARNATION)
let { adopted, epoch } = state
if (info.sessionId !== undefined && adopted === undefined) {
// Adoption: same epoch — no remount.
adopted = info.sessionId
setState({ adopted, epoch })
} else if (adopted !== undefined && info.sessionId !== undefined && info.sessionId !== adopted) {
// Post-adoption session switch: next incarnation, born already adopted.
adopted = info.sessionId
epoch += 1
setState({ adopted, epoch })
} else if (adopted !== undefined && info.sessionId === undefined) {
// Back to no-session: next incarnation, born blank (adopts anew later).
adopted = undefined
epoch += 1
setState({ adopted, epoch })
}
return <SessionMaybeEntryBody key={epoch} entry={entry} ownerProps={ownerProps} info={info} />
}
/** Adoption bookkeeping of one session-maybe outlet (see SessionMaybeEntry). */
interface MaybeIncarnation {
/** Session this incarnation adopted; undefined while born blank and unadopted. */
readonly adopted: string | undefined
/** Incarnation counter — the child key; bumps exactly when an incarnation dies. */
readonly epoch: number
}
const FIRST_INCARNATION: MaybeIncarnation = { adopted: undefined, epoch: 0 }
function RootEntry({ entry, ownerProps }: { entry: StoredEntry; ownerProps: object }) {
const host = useHost()
const Comp = entry.component as FC<InjectedProps>

View File

@@ -117,9 +117,10 @@ const projectionHookCache = new WeakMap<SessionMaybeProvideInfo, (
) => unknown>()
/**
* Root-level binding provider. It follows current selection without a key, so
* session-maybe entries retain their React identity while the context value
* moves between absent and definite session bundles.
* Root-level binding provider. It follows current selection without a key;
* per-entry identity is the outlet's adoption bookkeeping (SessionMaybeEntry):
* a blank-born incarnation adopts the first session without remounting, and
* every later transition (switch or loss) remounts like a strict entry.
*/
export function SessionMaybeProvider({ children }: { children: ReactNode }) {
const host = useHost()

View File

@@ -10,7 +10,7 @@
*/
import { describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render } from '@testing-library/react'
import { useEffect, type ReactNode } from 'react'
import { useEffect, useState, type ReactNode } from 'react'
import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import {
@@ -863,3 +863,70 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
expect(props['shared']).toBe('owner') // owner overrides inject
})
})
describe('session-maybe adoption identity', () => {
const SINGLE_MAYBE: DeclaredSpec = { kind: 'single', scope: 'session-maybe' }
/** Mount a maybe entry that records its mount count and local state. */
function mountMaybeCounter(h: Fake) {
let mounts = 0
const seen: { sessionId: string | undefined; mount: number }[] = []
h.declare('k.maybe', SINGLE_MAYBE)
h.add('k.maybe', {
component: ({ sessionId }: { sessionId?: string }) => {
// Local mount marker: useState initializer runs once per incarnation.
const [mount] = useState(() => ++mounts)
seen.push({ sessionId, mount })
return <b>{`${sessionId ?? 'blank'}#${mount}`}</b>
},
})
const { view } = mountRoot(h, { 'k.maybe': SINGLE_MAYBE }, renderSlot => renderSlot('k.maybe', {}))
return { view, seen }
}
it('adopts the first session: blank → first id keeps the incarnation (no remount)', () => {
const h = makeHost()
h.addSession('s1')
const { view } = mountMaybeCounter(h)
expect(view.container.textContent).toBe('blank#1')
act(() => { h.current.set('s1') })
// Same incarnation (#1): the blank shell adopted s1.
expect(view.container.textContent).toBe('s1#1')
})
it('remounts on a post-adoption session switch (local state must not leak across sessions)', () => {
const h = makeHost()
h.addSession('s1')
h.addSession('s2')
const { view } = mountMaybeCounter(h)
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('s1#1')
act(() => { h.current.set('s2') })
// New incarnation (#2): strict-session behavior after adoption.
expect(view.container.textContent).toBe('s2#2')
})
it('remounts into a fresh blank incarnation on session loss, then adopts anew', () => {
const h = makeHost()
h.addSession('s1')
h.addSession('s2')
const { view } = mountMaybeCounter(h)
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('s1#1')
act(() => { h.current.set(undefined) })
// The adopted incarnation dies with its session; blank state is fresh.
expect(view.container.textContent).toBe('blank#2')
act(() => { h.current.set('s2') })
// The fresh blank adopts again — still incarnation #2, no flash.
expect(view.container.textContent).toBe('s2#2')
})
it('keeps the incarnation across a no-op republish of the same session', () => {
const h = makeHost()
h.addSession('s1')
const { view } = mountMaybeCounter(h)
act(() => { h.current.set('s1') })
act(() => { h.current.set('s1') })
expect(view.container.textContent).toBe('s1#1')
})
})

View File

@@ -0,0 +1,78 @@
// Regression drive for the unified hero composer (0729-0357-hero-unify):
// cold start with zero workspaces -> create a workspace -> type. Asserts the
// composer textarea is the SAME DOM node across the disabled->live flip (a
// remount drops the __heroMark marker property) — the session-maybe
// composer.bar contract.
//
// Prereqs: `pnpm run build`, then a fresh server against empty state:
// rm -rf .storages && DSH_HOME=$(mktemp -d) node --experimental-transform-types \
// --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web --port 44285 \
// --workspace-root $(mktemp -d)
// Run: node scripts/hero-composer-dom-continuity.mjs
// (BASE_URL overrides the target; screenshots land in .artifacts/.)
import { createRequire } from 'node:module'
// playwright is a devDependency of apps/web only — resolve through its tree.
const require = createRequire(new URL('../apps/web/package.json', import.meta.url))
const { chromium } = require('playwright')
const BASE = process.env.BASE_URL ?? 'http://127.0.0.1:44285'
const SHOTS = new URL('../.artifacts/screenshots/0729-0357-hero-unify/', import.meta.url).pathname
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
page.on('console', msg => { if (msg.type() === 'error') console.log('[console.error]', msg.text()) })
page.on('pageerror', err => { console.log('[pageerror]', err.message) })
await page.goto(BASE)
await page.waitForSelector('textarea', { timeout: 20000 })
await page.screenshot({ path: SHOTS + '01-cold-start.png' })
const initial = await page.evaluate(() => {
const boxes = [...document.querySelectorAll('textarea')]
boxes.forEach((b, i) => { b.__heroMark = 'alive-' + i })
return boxes.map(b => ({ disabled: b.disabled, placeholder: b.placeholder }))
})
console.log('cold-start textareas:', JSON.stringify(initial))
// Open the picker and create a workspace by name (typed-input flow). The name
// must be unique per registry; keystrokes go through pressSequentially so the
// dialog's React onChange enables the submit button.
await page.getByRole('button', { name: 'Choose workspace' }).click()
await page.getByText('Create a new workspace').click()
await page.screenshot({ path: SHOTS + '03-create-form.png' })
const nameBox = page.getByPlaceholder('Workspace name')
await nameBox.click()
const wsName = 'proj-' + Date.now().toString(36)
await nameBox.pressSequentially(wsName, { delay: 30 })
await page.locator('button:text-is("Create workspace")').click()
// Wait for the composer to go live (placeholder flips, textarea enabled).
await page.waitForFunction(() => {
const box = document.querySelector('textarea')
return box !== null && !box.disabled
}, { timeout: 20000 })
await page.screenshot({ path: SHOTS + '04-live.png' })
const after = await page.evaluate(() => {
const boxes = [...document.querySelectorAll('textarea')]
return boxes.map(b => ({
mark: b.__heroMark ?? 'REMOUNTED',
disabled: b.disabled,
placeholder: b.placeholder,
}))
})
console.log('post-pick textareas:', JSON.stringify(after))
// Type into the live composer.
await page.locator('textarea').first().fill('hello from acceptance run')
const typed = await page.evaluate(() => document.querySelector('textarea')?.value)
console.log('typed value:', JSON.stringify(typed))
await page.screenshot({ path: SHOTS + '05-typed.png' })
const survived = after.length === 1 && after[0].mark === 'alive-0'
console.log(survived
? 'DOM-CONTINUITY: PASS (same textarea node across cold-start -> live)'
: 'DOM-CONTINUITY: FAIL ' + JSON.stringify(after))
await browser.close()
process.exit(survived && typed === 'hello from acceptance run' ? 0 : 1)