From 7639f4cb68e32102dce67a7caf30260cf5ff104f Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Wed, 29 Jul 2026 14:12:01 +0800
Subject: [PATCH 01/11] feat(web): answerable ask_user_question flow with
toolview verdict row
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The pending question now owns exactly two surfaces: the redesigned
QuestionComposer takeover (footer pager, checkbox multi-select,
always-visible custom input, locale-injected bilingual chrome) collects
the answers, and a dedicated ask_user_question toolview row reports the
interaction outcome — waiting, N/M answered, cancelled (ASK_CANCELLED),
or interrupted with stopped semantics (ASK_ABORTED). PendingCard narrows
to approval waits only. Toolview leading icons and the hover chevron
unify on the tertiary label color, the checklist glyph matches the
14px figma extract, and dev-watch registers CSS modules so css-only
edits rebuild.
---
...29-ask-question-web-presentation.i18n.yaml | 6 +
...026-07-29-ask-question-web-presentation.md | 45 +++
...-07-29-ask-question-web-presentation.zh.md | 45 +++
docs/event-producer-consumer.md | 2 +-
packages/client/tsdown.client.ts | 5 +-
.../ui-conversation/src/client/apply.ts | 4 +
.../src/client/chat/ChatView.tsx | 5 +-
.../src/client/chat/PendingCard.tsx | 27 +-
.../src/client/chat/ToolRow.module.css | 10 -
.../src/client/chat/ToolRow.tsx | 5 +-
.../src/client/toolviews/ask-question-row.tsx | 94 ++++++
.../src/client/toolviews/todo-row.module.css | 58 ----
.../src/client/toolviews/todo-row.tsx | 63 ++--
.../tests/ask-question-row.spec.tsx | 130 ++++++++
.../ui-conversation/tests/chat-apply.spec.tsx | 6 +-
.../tests/coverage-tails.spec.tsx | 14 +-
.../ui-conversation/tests/todo-panel.spec.tsx | 32 +-
.../client/ui-primitives/src/icons/index.tsx | 39 ++-
.../client/ui-primitives/tests/icons.spec.tsx | 4 +-
packages/client/ui-question/README.i18n.yaml | 6 +-
packages/client/ui-question/README.md | 2 +
packages/client/ui-question/README.zh.md | 2 +
packages/client/ui-question/package.json | 4 +-
.../src/client/QuestionComposer.module.css | 280 ++++++++++--------
.../src/client/QuestionComposer.tsx | 206 +++++++------
.../ui-question/src/client/contract/slots.ts | 30 +-
.../client/ui-question/src/client/index.ts | 50 +++-
.../client/ui-question/src/client/locales.ts | 39 +++
.../ui-question/tests/browser-plugin.spec.ts | 52 +++-
.../tests/question-composer.spec.tsx | 48 +--
packages/client/ui-question/tsconfig.json | 3 +
pnpm-lock.yaml | 3 +
32 files changed, 869 insertions(+), 450 deletions(-)
create mode 100644 .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml
create mode 100644 .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md
create mode 100644 .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md
create mode 100644 packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx
delete mode 100644 packages/client/ui-conversation/src/client/toolviews/todo-row.module.css
create mode 100644 packages/client/ui-conversation/tests/ask-question-row.spec.tsx
create mode 100644 packages/client/ui-question/src/client/locales.ts
diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml
new file mode 100644
index 0000000000..6954c289bd
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml
@@ -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
diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md
new file mode 100644
index 0000000000..90eeb3cdcc
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md
@@ -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.
diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md
new file mode 100644
index 0000000000..5bb19d3a68
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md
@@ -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 在真实会话中演练了回答、取消与轮次打断路径。
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index e9e5bb6e6a..2ab220305c 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -68,7 +68,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/plugin` | - | `hmr`, `modules`, `webserver` |
| `internal/status` | - | [`agent`](../packages/core/agent) |
-| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` |
+| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-question`, `ui-settings-general` |
| `slots/changed` | `runtime` (`emit`) | - |
| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` |
diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts
index 9b93feae8b..6b004c80b4 100644
--- a/packages/client/tsdown.client.ts
+++ b/packages/client/tsdown.client.ts
@@ -124,9 +124,12 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi
const abs = importer !== undefined ? resolvePath(dirname(importer), source) : source
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
- async load(virtualId: string) {
+ async load(this: { addWatchFile?: (id: string) => void }, virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
+ // Virtual modules hide the real file from the watcher; register it so
+ // dev-web rebuilds on a css-only edit.
+ this.addWatchFile?.(fileId)
const source = await readFile(fileId)
const { code, exports: cssExports } = transform({
filename: fileId,
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index 62801ca0b8..48dee62787 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -14,6 +14,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.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'
@@ -187,6 +188,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)
diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
index deb7f09f6c..e5d80d52c6 100644
--- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
@@ -361,7 +361,10 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
))}
)}
- {pending.map(item => )}
+ {/* Approval waits only: a pending question already shows as the
+ ask_user_question row (waiting state) plus the composer takeover. */}
+ {pending.filter(item => item.kind === 'approval')
+ .map(item => )}
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && }
diff --git a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx
index b6825aed9a..5a2076fe85 100644
--- a/packages/client/ui-conversation/src/client/chat/PendingCard.tsx
+++ b/packages/client/ui-conversation/src/client/chat/PendingCard.tsx
@@ -1,31 +1,22 @@
-// PendingCard: approval/question placeholder card (visible, not answerable —
-// the composer-takeover approval panel is a P-II item; wire pending semantics
-// already exist so the flow must show them).
+// PendingCard: approval placeholder card (visible, not answerable — the
+// composer-takeover approval panel is a P-II item; wire pending semantics
+// already exist so the flow must show them). Question waits render through
+// the ask_user_question toolview row + the composer takeover instead.
import { memo } from 'react'
-import type { PendingInteraction } from '@deepseek-ai/dsh-client-runtime/client'
-import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
import css from './PendingCard.module.css'
export interface PendingCardProps {
- item: PendingInteraction
+ item: PendingWait<'approval'>
}
export const PendingCard = memo(function PendingCard({ item }: PendingCardProps) {
return (
- {item.kind === 'approval' ? (
- <>
-
等待审批:{item.payload.toolName}
- {item.payload.reason !== undefined &&
{item.payload.reason}
}
- >
- ) : (
- <>
-
等待回答({item.payload.questions.length} 题)
-
- >
- )}
-
请在原客户端处理(web 端作答后续里程碑提供)
+
等待审批:{item.payload.toolName}
+ {item.payload.reason !== undefined &&
{item.payload.reason}
}
+
请在原客户端处理(web 端审批后续里程碑提供)
)
})
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
index 018529961f..c18bbefb01 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
@@ -62,12 +62,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,
@@ -87,10 +81,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. */
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
index 5c5d059292..6abca0d739 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
@@ -7,7 +7,6 @@
// expandable content, retiring the details-panel handoff where feasible.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
-import clsx from 'clsx'
import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
@@ -74,12 +73,12 @@ export function ToolRow({
? (
<>
{icon}
-
+
>
)
: icon
const leading = open
- ?
+ ?
: leadingFor(state, collapsedIcon)
return (
diff --git a/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx
new file mode 100644
index 0000000000..3ba94cc438
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx
@@ -0,0 +1,94 @@
+// 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 (row click opens details; leading toggle
+ * expands the raw args). */
+export function AskQuestionRow({ toolName, block, openDetails }: 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 (
+ }
+ title="Ask question"
+ summary={summary}
+ body={model.body}
+ state={state}
+ onOpenDetails={openDetails}
+ />
+ )
+}
+
+/**
+ * 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)
+ },
+}
diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css
deleted file mode 100644
index 1a1b142b3a..0000000000
--- a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css
+++ /dev/null
@@ -1,58 +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;
- cursor: pointer;
- border-radius: 6px;
-}
-
-.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;
-}
diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
index a47322b614..2d72cfc700 100644
--- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
+++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
@@ -1,16 +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 type { KeyboardEvent } from 'react'
+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 }
@@ -40,48 +40,25 @@ 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
- case 'error': return
- case 'stopped': return
- default: return
- }
-}
-
-/** One-line plan update row (click opens the raw args in details). 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 (row click opens details; 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, openDetails }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const summary = summarize(argsRaw) ?? model.summary
- // Button semantics, not a
)
@@ -270,18 +277,8 @@ function QuestionFlow({ pending, t, useLocale }: {
disabled={busy !== null}
rows={2}
placeholder={t('custom.placeholder')}
- onChange={(event) => {
- const value = event.target.value
- updateDraft(current => ({
- ...current, selected: [], custom: value, skipped: false,
- }))
- }}
- onKeyDown={(event) => {
- if (event.key === 'Enter' && !event.shiftKey && !isComposing(event)) {
- event.preventDefault()
- continueFlow()
- }
- }}
+ onChange={draftCustom}
+ onKeyDown={continueFromCustom}
/>
)}
From ecd07abdc02b1dcec87aac6fb1c4f1b948f1aefb Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Thu, 30 Jul 2026 15:07:31 +0800
Subject: [PATCH 03/11] test(web): refresh question-composer goldens for the
merged composer
The master-side refresh batch recorded these goldens against master's
composer (customTrigger row, header pager); the merge keeps this
branch's redesigned composer (always-visible custom input, footer
pager) with copy on the standard locale seat, so the waiting aria and
the answered transcript re-record against the merged UI. The answered
golden also picks up the ask_user_question toolview row this branch
introduces.
---
.../question-composer/answered.expected.md | 2 +-
.../question-composer/ui.expected.md | 22 +++++++------------
2 files changed, 9 insertions(+), 15 deletions(-)
diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md
index 5ba842114e..ac9f26298e 100644
--- a/apps/web/tests/snapshots/question-composer/answered.expected.md
+++ b/apps/web/tests/snapshots/question-composer/answered.expected.md
@@ -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
diff --git a/apps/web/tests/snapshots/question-composer/ui.expected.md b/apps/web/tests/snapshots/question-composer/ui.expected.md
index a1c9dfac83..894f84d9ba 100644
--- a/apps/web/tests/snapshots/question-composer/ui.expected.md
+++ b/apps/web/tests/snapshots/question-composer/ui.expected.md
@@ -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]
From 6ea1238714cc6f952edf2b93390f54360bed3d61 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Wed, 29 Jul 2026 21:06:27 +0800
Subject: [PATCH 04/11] docs(web): the composer bar is one session-maybe slot,
not a swapped pair
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
fix(web): keep one composer bar DOM across the no-workspace transition
The composer.bar slot moves from session to session-maybe scope: with no
current session the entry still mounts, the machine faces (keyboard, stop,
command) arrive undefined, and the bar renders its normal DOM inert via the
disabled owner prop. DisabledInputBar and its parallel tree are gone, so the
textarea node survives the cold-start workspace pick instead of flashing
through a remount. A blank session whose workspace was deleted takes the
same inert path through owner props.
test(web): add the composer DOM-continuity acceptance probe
Drives a real dsh web server with headless chromium through the cold-start
-> pick-workspace -> type flow and asserts the composer textarea is the
same DOM node throughout (a marker property must survive). Rerun
prerequisites are in the header comment.
docs(web): session-maybe identity is adoption, not hold-forever
fix(web): session-maybe entries adopt the first session, then remount like strict entries
A session-maybe entry used to keep one React instance across every
transition, so component-local state leaked between sessions once the
composer bar moved to that scope (PermissionSelect's optimistic pick, the
IME composition guard). Identity is now adoption: an incarnation born
session-less holds through the arrival of the first session (the blank
shell's DOM survives the workspace pick), and afterwards behaves exactly
like a strict session entry — a switch or a drop to no-session remounts,
clearing local state by construction. The child key is an incarnation
counter kept in the stable outlet wrapper via render-phase setState.
chore: knip knows the root acceptance probe's playwright dependency
scripts/hero-composer-dom-continuity.mjs resolves playwright through
apps/web's devDependency tree (createRequire), which knip cannot follow;
ignore it at the root workspace.
---
...ession-scope-and-provide-channel.i18n.yaml | 4 +-
...lient-session-scope-and-provide-channel.md | 4 +-
...nt-session-scope-and-provide-channel.zh.md | 4 +-
...input-machine-and-slash-pipeline.i18n.yaml | 4 +-
...25-web-input-machine-and-slash-pipeline.md | 2 +-
...web-input-machine-and-slash-pipeline.zh.md | 2 +-
knip.json | 3 +
.../client/ui-conversation/README.i18n.yaml | 4 +-
packages/client/ui-conversation/README.md | 2 +-
packages/client/ui-conversation/README.zh.md | 2 +-
.../ui-conversation/src/client/apply.ts | 30 ++++++-
.../src/client/contract/slots.ts | 39 +++++++---
.../src/client/skeleton/ConversationRoot.tsx | 38 ++++-----
.../src/client/skeleton/DisabledInputBar.tsx | 40 ----------
.../src/client/skeleton/InputBar.tsx | 54 ++++++++-----
.../tests/apply-inject.spec.tsx | 15 +++-
.../client/web-react/src/scoped-slots.tsx | 60 +++++++++++++-
.../client/web-react/src/session-provider.tsx | 7 +-
.../web-react/tests/scoped-slots.spec.tsx | 69 +++++++++++++++-
scripts/hero-composer-dom-continuity.mjs | 78 +++++++++++++++++++
20 files changed, 346 insertions(+), 115 deletions(-)
delete mode 100644 packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx
create mode 100644 scripts/hero-composer-dom-continuity.mjs
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml
index 4783b79a6a..6b8c6de32d 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/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
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
index 4496e3786e..d19b256b83 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
@@ -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).
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
index 768dada95a..1f88dd2065 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md
@@ -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 收尸)。
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
index 7abc19ba70..98423a8c7d 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
-2026-07-25-web-input-machine-and-slash-pipeline.md: f446f42c9e202afcb404c7a551a4f715228bb8e5
-2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 8f5e449bb878811b70bc5bc29e4a09bbc1a33bfa
+2026-07-25-web-input-machine-and-slash-pipeline.md: 92bb91c3e892d928cedf18ec57c725a116b6ffc8
+2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 5bee6df52f16d935aa4f4ccff8627a2d43d44c8c
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
index f446f42c9e..92bb91c3e8 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
@@ -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 clear it goes only through `session.prompt {mode:'queue'|'steer'}`; backfill happens only when it fails and the live draft is still empty — a user who has kept typing is 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 moves 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.
diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
index 8f5e449bb8..5bee6df52f 100644
--- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
@@ -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:乐观清稿后只走 `session.prompt {mode:'queue'|'steer'}`;失败且 live draft 仍为空才回填,用户已经继续输入则不覆盖。不存在 Draft materialize 或 attach 事务。
- blank Hero 改选 Workspace 时,外壳调用 `connectWorkspace`;目标 session 不同时把非空 draft 从当前 shell 搬到目标 shell,再 open 新 id,旧 blank session 留存但不再 current。
diff --git a/knip.json b/knip.json
index e5b7110957..6585e1f628 100644
--- a/knip.json
+++ b/knip.json
@@ -25,6 +25,9 @@
"project": [
"scripts/**/*.ts",
"scripts/**/*.mjs"
+ ],
+ "ignoreDependencies": [
+ "playwright"
]
},
"examples": {
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 654722b589..9158acd4ca 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 3973c14f2b8fe746549bb74af85a7a60a7d66aea
-README.zh.md: a6bb15c4cdd53d05bf28147b97d9d64d1c59da2b
+README.md: 06fd6b963639390164582e70476b6a80f189bc61
+README.zh.md: 41047d563f5541aaddc87bc7b448f678538ef91a
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 3973c14f2b..06fd6b9636 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -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.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index a6bb15c4cd..41047d563f 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -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 在会话存在之前保持为空。
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index d261b26a3e..c6b597ae79 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -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'
@@ -31,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)
@@ -121,7 +135,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' },
@@ -166,6 +180,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
@@ -175,7 +192,16 @@ 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,
+ stop: undefined,
+ command: undefined,
+ translateHint,
+ hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON },
+ }
+ }
const shell = inputHub.shell(sessionId)
return {
keyboard: shell,
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts
index 38de7675a7..1684e616b2 100644
--- a/packages/client/ui-conversation/src/client/contract/slots.ts
+++ b/packages/client/ui-conversation/src/client/contract/slots.ts
@@ -77,11 +77,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
@@ -244,6 +248,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
@@ -261,19 +271,24 @@ 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
- /** 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
+ /** 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 `).
+ * controls' write path — the permission chip submits `/permission `);
+ * absent with the session.
* Resolves admission: false = rejected/unmatched/transport failure.
*/
- command: (line: string) => Promise
- /** Locale-aware hint translator for claimed command placeholders. */
+ command: ((line: string) => Promise) | 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
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
index bec3bb1fde..994351e8e6 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
@@ -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)
- ?
- : 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 = (
diff --git a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx
deleted file mode 100644
index bfaa0b8d02..0000000000
--- a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
{'\n'}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
index 0f922b5930..77747e0ae2 100644
--- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
@@ -17,9 +17,13 @@ import type {} from '@deepseek-ai/dsh-plan-mode/client'
import type {} from '@deepseek-ai/dsh-goal/client'
import type { ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
+import type { DraftDecorations } from '../input/decorations.ts'
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'
@@ -29,15 +33,16 @@ export interface InputBarError {
export type InputBarProps = ComposerBarProps
export function InputBar({
- useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection,
- variant, placeholder, accessory, overlay, leftItems, rightItems, footer, onAdd, addLabel = 'Add attachment',
+ useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon,
+ useProjection, 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))
@@ -49,7 +54,10 @@ export function InputBar({
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
- const draft = input.draft
+ // 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 empty = draft.trim() === ''
const inputRef = useRef(null)
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
@@ -68,16 +76,18 @@ 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'
// 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
@@ -101,6 +111,9 @@ export function InputBar({
}, [])
const onKeyDown = (e: KeyboardEvent): 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
@@ -162,6 +175,7 @@ export function InputBar({
}
const onChange = (e: ChangeEvent): 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)
@@ -187,6 +201,7 @@ export function InputBar({
/* oxlint-enable typescript/no-unnecessary-condition */
const onCopyOrCut = (e: React.ClipboardEvent, 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
@@ -211,6 +226,7 @@ export function InputBar({
}
const onPaste = (e: React.ClipboardEvent): void => {
+ if (keyboard === undefined) return // absent machine: disabled textarea, no events
if (machineBusy || locked) return
const text = e.clipboardData.getData('text/plain')
if (text === '') return
@@ -230,7 +246,7 @@ export function InputBar({
const onSelect = (e: React.SyntheticEvent): 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
}
@@ -242,6 +258,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
@@ -251,16 +268,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 = (
-
- )
+ // 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
+ :
// 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
@@ -322,7 +340,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
@@ -356,7 +374,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'))}
diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx
index e73f959341..87ec0dfde7 100644
--- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx
+++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx
@@ -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()
diff --git a/packages/client/web-react/src/scoped-slots.tsx b/packages/client/web-react/src/scoped-slots.tsx
index 950dfd4a1f..9c67da18d2 100644
--- a/packages/client/web-react/src/scoped-slots.tsx
+++ b/packages/client/web-react/src/scoped-slots.tsx
@@ -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
}
-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
const { kit, actions } = standardKit(host, entry, 'session-maybe', info)
const injected = cachedSessionMaybeInject(entry, info, actions)
return
}
+/**
+ * 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(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
+}
+
+/** 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
diff --git a/packages/client/web-react/src/session-provider.tsx b/packages/client/web-react/src/session-provider.tsx
index 152bc793cb..865e4fc081 100644
--- a/packages/client/web-react/src/session-provider.tsx
+++ b/packages/client/web-react/src/session-provider.tsx
@@ -117,9 +117,10 @@ const projectionHookCache = new WeakMap 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()
diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx
index 971a6ad060..16334ff7c7 100644
--- a/packages/client/web-react/tests/scoped-slots.spec.tsx
+++ b/packages/client/web-react/tests/scoped-slots.spec.tsx
@@ -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 {`${sessionId ?? 'blank'}#${mount}`}
+ },
+ })
+ 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')
+ })
+})
diff --git a/scripts/hero-composer-dom-continuity.mjs b/scripts/hero-composer-dom-continuity.mjs
new file mode 100644
index 0000000000..ef39052b1b
--- /dev/null
+++ b/scripts/hero-composer-dom-continuity.mjs
@@ -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)
From 4c33ca8becae4272e79bd8a085a131927d5e9163 Mon Sep 17 00:00:00 2001
From: imccyu
Date: Thu, 30 Jul 2026 15:25:50 +0800
Subject: [PATCH 05/11] fix: hero layout when no-workspace
---
.../ui-conversation/src/client/skeleton/ConversationRoot.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
index 994351e8e6..3f9635bb48 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
@@ -178,7 +178,7 @@ export function ConversationRoot({
'conversation.session',
{ wrapActiveBody },
)}
- {sessionId === undefined ? composerSeat : null}
+ {sessionId === undefined ? wrapActiveBody(null) : null}
)
}
From 245aeb414ca73932dd56643fbae7ccac7bb9e853 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Thu, 30 Jul 2026 01:00:12 +0800
Subject: [PATCH 06/11] feat(web): workspace header hover card with cwd and
creation time
---
.../src/client/rows/Rows.module.css | 7 ++++
.../ui-workspace/src/client/rows/Rows.tsx | 35 ++++++++++++++++---
.../client/ui-workspace/src/client/tree.ts | 13 +++++--
.../client/ui-workspace/tests/rows.spec.tsx | 6 ++--
4 files changed, 50 insertions(+), 11 deletions(-)
diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css
index 7b19284b66..5a5cac914e 100644
--- a/packages/client/ui-workspace/src/client/rows/Rows.module.css
+++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css
@@ -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;
diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx
index d75fabdd8b..a507ac2991 100644
--- a/packages/client/ui-workspace/src/client/rows/Rows.tsx
+++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx
@@ -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: , 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 (
+