mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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/view,composer 与各输入 slot 也保持严格 `session`。无 session 时 composer stack 直接放纯展示的 `DisabledInputBar`,session 出现后把输入体换成严格绑定的 InputBar;textarea 允许重建,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/view。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时以惰性态渲染(machine face 缺席、`disabled` owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。
|
||||
|
||||
- runtime 内建第一条:`'session'` hook——`useSession` 本身走同一机制,无特判。
|
||||
- Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -70,7 +70,7 @@ occurrence 表与 chip 三投影:
|
||||
|
||||
- hub(trigger/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 InputBar,textarea 不因 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 Hero;sidebar 的 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。
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note:Ask-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 在真实会话中演练了回答、取消与轮次打断路径。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
"project": [
|
||||
"scripts/**/*.ts",
|
||||
"scripts/**/*.mjs"
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
"playwright"
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 原生输入。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'))}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
129
packages/client/ui-conversation/tests/ask-question-row.spec.tsx
Normal file
129
packages/client/ui-conversation/tests/ask-question-row.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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('gone(session-not-found)')
|
||||
await expect(injected.setPlanMode(false)).resolves.toBe('gone(session-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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 和结构化结果。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('选择信号')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()} />)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
78
scripts/hero-composer-dom-continuity.mjs
Normal file
78
scripts/hero-composer-dom-continuity.mjs
Normal 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)
|
||||
Reference in New Issue
Block a user