Merge branch 'stack/agent-profiles-3-wire' into stack/agent-profiles-5-web-ui

The Client API carrier's `agentPresets` member was the one member of its class
without an `IApiClient[...]` annotation. Inferring it inlined `AgentPresetEntry`
into the emitted declaration by the specifier TS picks — the host `index.ts` —
dragging the whole gateway, and with it the host `Context` merges, into every
Client program importing the carrier. Annotated like its siblings.

`ApiRemoteAgentOptions.setup` now takes the inspected session rather than its
header alone: this layer resolves a resumed session's preset from the LOG,
because a session that switched while blank ran its turns under the newer
composition and the header is written once at creation.

Conflicts:
	apps/web/tests/snapshots/*/*.expected.md
	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
	packages/host/apiproxy/src/api-proxy.ts
	scripts/doc-budgets.manifest.json
This commit is contained in:
Yichen Jiang
2026-08-08 15:00:31 +08:00
649 changed files with 21091 additions and 2838 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: a75f25d8669cd688795842a655106e0e27bb7173
README.zh.md: f0d744c31020730857d210d75749b907dbffca08
README.md: 0cf50146cc44ef0d6cc060a4c97b3d1ff454f013
README.zh.md: 8bfb96bb9326d8fcadc3c357b6abaad88c92bd17

View File

@@ -8,17 +8,19 @@ Compaction renders as one collapsed row at the checkpoint's flow position withou
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model, when no adapter serves its route) already depend on this package, so this package cannot read them. The model seat is the one control a block leaves live — every block this contract has is cleared by choosing a model, so locking it too would leave the composer asking for the only thing it prevents. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite.
The view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is an underlined link — it reads as one at rest, not only on hover, because a path styled like the surrounding prose is an affordance nobody finds — and it opens the file through the Host (`host.openPath`, relative paths resolve against the session cwd). A document a browser renders prefers the default browser where the Host platform can name one; Windows and WSL use the Windows registered association. The Host opens it on the Host's own machine: a client reached over a network sees nothing, which is the deliberate scope of this surface. Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card below its summary row; tool rows are summary surfaces, so the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which keeps the summary bounded; the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound. A Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
@@ -50,6 +52,8 @@ The chat stats line takes its token accounting from the generic token-meter `tok
`src/client/` is organized by domain. `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations and composed props, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` 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 and the store factory stay internal and reach the page through apply's slot registrations.
A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost.
## Model Experience
None, as the conversation UI renders session history and streams in the browser; nothing here reaches a model request.

View File

@@ -8,15 +8,17 @@
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero编辑器子树首个会话到达时彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏作为普通列 chrome仅显示当前会话标题和视图标签fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。该滚动容器无条件预留自己的滚动条槽选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block输入栏就渲染同一个禁用的 textarea并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好知道某会话发不出消息的插件ui-model在没有适配器服务其路由时本就依赖本包因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份契约里的每个 block 都靠选模型来解除把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它路由不了的 prompt。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。
视图环是一个 slot严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页则从注册选项(`id``order``label`投影而来。聊天视图是该包自身的配置项ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill技能目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区即按真实换行展示面向模型的文本并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering中途引导气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill技能目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动且不会合成工具状态、摘要或键控 toolview 分发([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区即按真实换行展示面向模型的文本并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering中途引导气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。
Think 行默认保持折叠并在不展开思维链的情况下暴露实时推理reasoning吞吐当推理块是流式输出尾部时摘要从结算后的首行切换到最新的非空行其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是下划线的链接——静止状态下就读得出是链接,而不只在悬停时,因为一条与周围正文同样样式的路径是没人会发现的交互——点击即经由 Host 打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。浏览器能渲染的文档会在 Host 平台能够确定默认浏览器时优先使用它Windows 与 WSL 则使用 Windows 注册的文件关联。Host 在它自己的机器上打开:经网络访问的客户端看不到任何东西,这是本交互面刻意划定的范围。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView``resultView` 对推导的唯一位置因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null落回通用路径。因此两个渲染点也都显示卡片的运行状态点它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片放在摘要行下方;工具行是摘要 surface因此卡片的复制与展开控件是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`8面板为 16因此摘要保持有界面板仍是单次调用的阅读 surface。内联输出按渲染意图开放——终端卡片与 web 卡片各有自己的上限。若 Bash 执行失败时落在通用路径,则改用同样有界的 IN/OUT 展开区暴露原始参数和完整错误;后台启动确认等成功的通用结果仍只显示摘要([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
@@ -50,6 +52,8 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
`src/client/` 按领域组织。`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明与组合后的 props、`views.ts` 共享原语、`tool-call-model.ts``skeleton/``chat/``toolviews/` 目录只导入 contract 文件,彼此之间从不互相导入。`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply``inject`、两个服务类和 `contract/` 类型家族;实现组件与 store factory 保持内部,经 apply 的 slot 注册抵达页面。
完成的一轮以一个 turn-tail 空位收尾chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot每轮一次、位于 `assistantActionsSeqs` 选出的 seq派发 `TurnTailOwnerProps`(快照节点、收尾 seq以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。
## 模型体验
无。会话 UI 在浏览器中渲染会话历史与流;这里没有任何内容进入模型请求。

View File

@@ -15,6 +15,8 @@ import { resolveToolPath } from './contract/tool-call-model.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import type { IConversation } from './service.ts'
import { ComposerBlockRegistry } from './input/blocks.ts'
import type { ComposerBlock } from './input/blocks.ts'
import { InputHub } from './input/hub.ts'
import { ComposerSubmissionPolicy } from './input/submission-policy.ts'
import { InputBar } from './skeleton/InputBar.tsx'
@@ -54,6 +56,11 @@ const ABSENT_NOTICES = {
getSnapshot: (): InputNotice | null => null,
subscribe: () => () => {},
}
/** No session, therefore nothing to block; same one-identity rule as above. */
const ABSENT_BLOCK = {
getSnapshot: (): ComposerBlock | undefined => undefined,
subscribe: () => () => {},
}
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
const ABSENT_LEXICON = {
getSnapshot: () => EMPTY_LEXICON,
@@ -133,6 +140,12 @@ export function apply(ctx: Context): void {
// ctx.conversation.input by the service below sharing this one instance).
const inputHub = new InputHub(ctx)
// The composer-block registry: a plugin that knows a session cannot send —
// ui-model, when no adapter serves the session's route — raises a block
// here, and the bar reads its own session's store. It cannot flow the other
// way: this package must not import the plugins that would know.
const composerBlocks = new ComposerBlockRegistry()
// Decision 19/20: the input machine feeds every session-scope slot
// component through the standard provide channel — the 'input' hook plus
// the two public actions. Materialization is the shell creation trigger
@@ -167,6 +180,7 @@ export function apply(ctx: Context): void {
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
},
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId) },
selectWorkspace: async (workspaceId) => {
const nextId = await workspaces.connectWorkspace(workspaceId)
if (sessionId !== undefined && nextId !== sessionId) {
@@ -304,6 +318,7 @@ export function apply(ctx: Context): void {
children: {
'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
@@ -352,7 +367,7 @@ export function apply(ctx: Context): void {
// registers itself as `conversation` and lives on its own child fiber.
// Presentation registrants depend directly on their slot declarations;
// this service remains only where conversation actions are required.
ctx.plugin(ConversationService, { input: inputHub })
ctx.plugin(ConversationService, { input: inputHub, blocks: composerBlocks })
// The bash sample rides the same declaration seam, in third-party posture
// (ToolRow-matching Bash · {description} chrome).

View File

@@ -11,10 +11,11 @@
import { memo, useMemo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import {
IconThinkOutline14, JsonBlock, MarkdownText,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts'
import { hasContentText } from './chat-flow.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ToolRow } from './ToolRow.tsx'
@@ -40,6 +41,8 @@ export interface AssistantMarkdownProps {
seq?: number | undefined
/** Fork the session through this finalized message's completed turn when eligible. */
onFork?: ((seq: number) => void) | undefined
/** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */
turnTail?: (Pick<PropsRenderSlots<'conversation.chat.turnTail'>, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined
/** The message is not the transcript tail of a completed turn. */
forkUnavailable?: boolean | undefined
/** The owning view's locale seat, passed down as a plain prop. */
@@ -83,7 +86,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
}
export const AssistantMarkdown = memo(function AssistantMarkdown({
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, t,
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t,
}: AssistantMarkdownProps) {
// Stable per locale revision (t identity changes on switch): a fresh object
// per render would rebuild MarkdownText's component table every chunk.
@@ -121,6 +124,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
})}
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
</div>
{showActions && turnTail?.renderSlotChain('conversation.chat.turnTail', turnTail.owner)}
{showActions && (
<MessageIconActions
text={copyText(blocks)}

View File

@@ -335,7 +335,7 @@ function StreamingTail({ useSession, t }: {
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
const turnTimings = useSession(s => s.turnTimings)
@@ -600,6 +600,9 @@ export function ChatView({
seq={node.seq}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}
turnTail={actionSeqs.has(node.seq)
? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } }
: undefined}
t={t}
/>
)

View File

@@ -103,7 +103,7 @@
color: var(--dsw-alias-label-tertiary);
}
/* File-tool path: same geometry as .summary; hover underline + pointer. */
/* File-tool path: same geometry as .summary, with a persistent link affordance. */
.fileLink {
flex: 1 1 auto;
min-width: 0;
@@ -118,12 +118,16 @@
text-align: left;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
color: var(--dsw-alias-label-secondary);
text-decoration: underline;
text-decoration-color: var(--dsw-alias-label-quaternary);
text-underline-offset: 3px;
cursor: pointer;
}
.fileLink:hover {
text-decoration: underline;
color: var(--dsw-alias-label-primary);
text-decoration-color: currentColor;
}
/* Error row's collapsed summary: the failure's first line in the error color. */

View File

@@ -3,8 +3,9 @@ import type { ReactNode, RefObject } from 'react'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerBlock } from '../input/blocks.ts'
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts'
@@ -46,6 +47,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* registration, and a domain upgrades by registering one row component.
*/
'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
/**
* The chat view's turn-tail chain: rendered between a closing assistant
* message's body and its IconActions footer, once per turn (the render
* site elects the closing seq). Entries derive a match from the owner
* currency before mounting, so presentation components never mount only
* to return null; an all-declined chain renders nothing.
*/
'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps }
/**
* The composer takeover chain: entries are selector-routed replacements
* of the default InputBar. Declared by this package's 'conversation'
@@ -156,6 +165,24 @@ export interface ConvViewOwnerProps {
onInspectDone?: () => void
}
/**
* Owner currency of the chat view's turn-tail hole: the finalized snapshot
* and the closing assistant's anchor. Registrants derive their own facts
* from the nodes (the owner never pre-chews a feature's vocabulary), and
* open files through the same opener the tool rows use.
*/
export interface TurnTailOwnerProps {
/** Finalized snapshot nodes in surface order. */
nodes: readonly ConversationNode[]
/** The closing assistant's seq — the anchor the tail renders under. */
seq: number
/**
* Open a filesystem path through the Host (tool-row semantics; the chat
* view resolves relative paths against the session cwd).
*/
openFile: (path: string) => void
}
/**
* Owner share of a per-view toolview slot: the call material the rendering
* view supplies per row. Uniform across views — the trajectory/waterfall
@@ -229,6 +256,12 @@ export interface ConversationInjected {
* When a blank session is already current, carry its draft to the target.
*/
selectWorkspace: (workspaceId: WorkspaceId) => Promise<void>
/**
* Framework-bound sources. `composerBlock` is this session's block when a
* plugin raised one; the reason is the blocker's own localized copy, which
* the root renders as the inert composer's placeholder.
*/
hooks: { composerBlock: ObservableSnapshot<ComposerBlock | undefined> }
}
/** Business callbacks injected into the strict Session body seat. */
@@ -264,6 +297,14 @@ export interface ConversationSessionHeaderInjected {
export interface ComposerBarOwnerProps {
/** Hero = empty-state centered card; composer = resident bottom bar. */
variant: 'hero' | 'composer'
/**
* A block another plugin raised for this session: the bar refuses input and
* shows the blocker's reason as the placeholder, but — unlike `disabled` —
* keeps the model seat live. Every block this contract has is one the user
* clears by choosing a model, so locking that seat too would leave the
* composer telling them to do the one thing it prevents.
*/
blocked?: { readonly reason: string }
/**
* Inert no-workspace state: the bar renders its normal DOM fully disabled
* (textarea, add, send) so the workspace pick transitions in place instead
@@ -362,7 +403,7 @@ export type ConversationSlotProps =
| 'conversation.input.left' | 'conversation.input.right'
| 'conversation.hero.workspace'
>
& ConversationInjected
& InjectFace<ConversationInjected>
& PropsLocale<'conversation'>
/** Full strict-session body props: per-session store, view ring, and draft mirror. */
@@ -486,7 +527,7 @@ export interface ChatViewInjected {
/** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */
export type ChatViewSlotProps =
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview'>
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'>
& PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
/**

View File

@@ -17,7 +17,7 @@ export type {
ComposerChainProps, ConversationInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps,
ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, TurnTailOwnerProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

View File

@@ -0,0 +1,77 @@
/**
* Composer blocks: the one way another plugin stops a session's input.
*
* The composer cannot read the plugins that would know — the dependency runs
* ui-model → ui-conversation, never back — so a blocker pushes here and the
* bar reads its own session's store. A block carries the localized reason it
* exists, because the plugin that raised it owns that copy; the composer only
* knows how to render an inert textarea with a placeholder, exactly as it
* already does for a session with no workspace.
*
* This is an affordance, not enforcement: the Host refuses a prompt it cannot
* route regardless of what any client disables.
*/
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** Why one session's composer is inert. */
export interface ComposerBlock {
/**
* Localized placeholder replacing the composer's own, owned by the plugin
* that raised the block.
*/
readonly reason: string
}
/** The registry face other plugins reach through `ctx.conversation.blocks`. */
export interface ComposerBlocks {
/**
* Raise or clear this session's block. Idempotent: setting a block equal to
* the current one, or clearing an absent one, notifies nobody.
* @param sessionId - the session whose composer is affected.
* @param block - the block to raise, or undefined to clear it.
*/
set(sessionId: SessionId, block: ComposerBlock | undefined): void
/**
* The store the composer subscribes to for one session. Created on first
* read from either side, so a blocker may raise a block before the session's
* composer mounts and the composer still sees it.
* @param sessionId - the session to observe.
* @returns that session's block store (undefined value = not blocked).
*/
storeFor(sessionId: SessionId): SnapshotStore<ComposerBlock | undefined>
/**
* Drop one session's store. The session scope's disposer calls this; a
* blocker never needs to.
* @param sessionId - the session being torn down.
*/
forget(sessionId: SessionId): void
}
/** The per-session composer-block registry (one instance per plugin fiber). */
export class ComposerBlockRegistry implements ComposerBlocks {
private readonly stores = new Map<SessionId, SnapshotStore<ComposerBlock | undefined>>()
/** @inheritdoc */
set(sessionId: SessionId, block: ComposerBlock | undefined): void {
const store = this.storeFor(sessionId)
const current = store.getSnapshot()
if (current?.reason === block?.reason) return
store.set(block)
}
/** @inheritdoc */
storeFor(sessionId: SessionId): SnapshotStore<ComposerBlock | undefined> {
const existing = this.stores.get(sessionId)
if (existing !== undefined) return existing
const created = createSnapshotStore<ComposerBlock | undefined>(undefined)
this.stores.set(sessionId, created)
return created
}
/** @inheritdoc */
forget(sessionId: SessionId): void {
this.stores.delete(sessionId)
}
}

View File

@@ -44,7 +44,7 @@ export const zh = {
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
'access.confirm.cancel': '取消',
'access.confirm.enable': '启用 Full access',
'hero.headline': '开始构建吧',
'hero.headline': '探索未知之境',
'hero.preview': '预览版',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
@@ -184,7 +184,7 @@ export const en = {
'access.confirm.acknowledge': 'I understand the risks and want to continue',
'access.confirm.cancel': 'Cancel',
'access.confirm.enable': 'Enable Full access',
'hero.headline': 'Let\'s start building',
'hero.headline': 'Into the Unknown',
'hero.preview': 'Preview',
'hero.chooseWorkspace': 'Choose workspace',
'session.hierarchy': 'Session hierarchy',

View File

@@ -14,6 +14,7 @@ import type { Context } from 'cordis'
// method) instead of the standalone helper.
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { QueueAction, QueueItemId } from './contract/queue.ts'
import type { ComposerBlocks } from './input/blocks.ts'
import type { InputService } from './input/contract.ts'
/**
@@ -24,6 +25,11 @@ import type { InputService } from './input/contract.ts'
export interface IConversation {
/** The per-session input machine registry (InputService face). */
readonly input: InputService
/**
* The per-session composer-block registry: how a plugin the composer
* cannot import makes a session's input inert with its own reason.
*/
readonly blocks: ComposerBlocks
/**
* Send a prompt into the caller scope's session (queued turn).
* @param text - prompt text, sent verbatim as one text block.
@@ -53,16 +59,20 @@ export interface IConversation {
export class ConversationService extends Service implements IConversation {
/** The per-session input machine registry (InputService face, design §5.2). */
readonly input: InputService
/** The per-session composer-block registry. */
readonly blocks: ComposerBlocks
/**
* @param ctx - owning root context (the plugin apply context; the service
* registers itself and follows that fiber's lifetime).
* @param config - carries the InputService instance constructed by the
* plugin apply (the same InputHub the slot inject factories close over).
* @param config - carries the InputService and composer-block registry
* constructed by the plugin apply (the same instances the slot inject
* factories close over).
*/
constructor(ctx: Context, config: { input: InputService }) {
constructor(ctx: Context, config: { input: InputService; blocks: ComposerBlocks }) {
super(ctx, 'conversation')
this.input = config.input
this.blocks = config.blocks
}
/**

View File

@@ -13,7 +13,7 @@ import css from './ConversationRoot.module.css'
export type ConversationRootProps = ConversationSlotProps
export function ConversationRoot({
sessionId, useSession, useSessions, useWorkspaces, useInput,
sessionId, useSession, useSessions, useWorkspaces, useInput, useComposerBlock,
renderSlot, renderSlotChain, selectWorkspace, t,
}: ConversationRootProps) {
const openState = useSession(s => s.openState)
@@ -24,6 +24,9 @@ export function ConversationRoot({
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank)
const workspaces = useWorkspaces(s => s)
// A plugin this package cannot import (ui-model) says this session cannot
// send; its reason is already localized by whoever raised it.
const composerBlock = useComposerBlock(block => block)
const [pickerOpen, setPickerOpen] = useState(false)
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
@@ -126,11 +129,20 @@ export function ConversationRoot({
// 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)
// A raised block is the same inert posture with the blocker's own reason:
// one disabled textarea, never a second tree. The no-workspace state wins
// when both hold — picking a workspace is the earlier prerequisite.
const blocked = !inert && composerBlock !== undefined
const inputBar = renderSlot('conversation.composer.bar', {
variant: hero ? 'hero' : 'composer',
...(inert
? { disabled: true, placeholder: t('placeholder.workspace') }
: hero ? { placeholder: t('placeholder.hero') } : {}),
: blocked
// `blocked`, not `disabled`: the bar refuses input either way, but a
// block keeps the model seat live because choosing a model is how the
// user clears it.
? { blocked: composerBlock, placeholder: composerBlock.reason }
: hero ? { placeholder: t('placeholder.hero') } : {}),
overlay: renderSlot('conversation.input.overlay', {}),
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),

View File

@@ -24,12 +24,12 @@
}
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. The preview
badge is a product addition outside that source and aligns to the title. */
badge is a product addition outside that source: a mono superscript pill
riding the title's top-right. */
.headline {
display: grid;
grid-template-columns: 34px auto;
grid-template-columns: 34px auto auto;
column-gap: 10px;
row-gap: 4px;
align-items: center;
justify-content: center;
font-size: 26px;
@@ -44,13 +44,17 @@
}
.previewBadge {
grid-row: 2;
grid-column: 2;
justify-self: start;
padding: 0 4px;
border-radius: 4px;
grid-row: 1;
grid-column: 3;
align-self: start;
margin-top: 2px;
margin-left: -3px;
padding: 1px 7px 0;
border: 1px solid var(--dsw-alias-interactive-bg-hover);
border-radius: 24px;
background: var(--dsw-alias-state-business-tertiary);
color: var(--dsw-alias-label-primary);
color: var(--dsw-alias-label-primary-bluish);
font-family: var(--ds-font-family-code);
font-size: 12px;
line-height: 18px;
font-weight: 500;

View File

@@ -37,7 +37,8 @@ export type InputBarProps = ComposerBarProps
export function InputBar({
useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t,
renderSlot, useNotices, useLexicon, useMenuLauncher,
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
useProjection, sessionId, variant, disabled: inert = false, blocked, placeholder,
accessory, overlay, leftItems, rightItems, footer,
}: InputBarProps) {
const input = useInput(s => s)
const notice = useNotices(s => s)
@@ -86,8 +87,13 @@ export function InputBar({
// 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 disabled = removed || inert || !live || blocked !== undefined
const locked = disabled
// The model seat is the ONE control a block leaves live: every block this
// contract has is cleared by choosing a model, so locking it too would leave
// the composer asking for the only thing it prevents. The other reasons to
// be disabled do lock it — there is no session to choose a model for.
const modelSeatLocked = removed || inert || !live
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
// Scroll the draft scrollport the minimum that brings `caret` into view — the
@@ -513,7 +519,7 @@ export function InputBar({
<div className={css.trailing}>
{rightItems}
{renderSlot('conversation.input.agentPreset', { locked })}
{renderSlot('conversation.input.model', { locked })}
{renderSlot('conversation.input.model', { locked: modelSeatLocked })}
<ContextMeter useProjection={useProjection} t={t} />
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
<Tooltip label={primaryLabel} side="top" delayMs={500}>

View File

@@ -130,6 +130,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
const chat = createChatStore().create()
const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot']
const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain']
// SessionProvider seat arrives with the session-scope child declaration;
// ChatView never invokes it (render-prop pass-through stub).
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
@@ -144,6 +146,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
renderSlot,
renderSlotChain,
SessionProvider: SessionProviderStub,
openDetails,
openFile,
@@ -732,7 +735,8 @@ describe('ChatView', () => {
// Count renderSlot invocations: the memo boundary holds when CallRow does
// not re-render, so the row's renderSlot call count freezes during chunks.
let rowRenders = 0
h.props.renderSlot = ((_key: string, _owner: object) => {
h.props.renderSlot = ((key: string, _owner: object) => {
if (key !== 'conversation.chat.toolview') return null
rowRenders += 1
return <div data-testid="counting-row" />
})

View File

@@ -7,6 +7,7 @@ import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
import { InputHub } from '../src/client/input/hub.ts'
async function bench() {
@@ -23,6 +24,7 @@ async function bench() {
// factories); the bench passes its own instance explicitly.
const fiber = runtime.ctx.plugin(ConversationService, {
input: new InputHub(runtime.ctx),
blocks: new ComposerBlockRegistry(),
})
await fiber.await()
const root = runtime.ctx.get('conversation') as ConversationService
@@ -86,6 +88,7 @@ describe('ConversationService', () => {
const bare = new Context()
await bare.plugin(ConversationService, {
input: new InputHub(bare),
blocks: new ComposerBlockRegistry(),
}).await()
const orphan = bare.get('conversation') as ConversationService
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)

View File

@@ -91,6 +91,8 @@ function mount(
omitSummaryRow?: boolean
/** Classify the selected child as a subagent instead of an ordinary fork. */
summaryOrigin?: 'subagent'
/** A composer block another plugin raised for this session. */
composerBlock?: { reason: string }
} = {},
) {
const root = sid('root')
@@ -118,9 +120,14 @@ function mount(
const stop = vi.fn()
const open = vi.fn()
const slotCalls: string[] = []
/** Owner share handed to the two composer tool-row seats, per render. */
const seatOwners: { key: string; owner: unknown }[] = []
let pickerOwner: unknown
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
slotCalls.push(key)
if (key === 'conversation.input.model' || key === 'conversation.input.plan') {
seatOwners.push({ key, owner })
}
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
if (key === 'conversation.session.header') {
return (
@@ -198,7 +205,12 @@ function mount(
stop={stop}
command={() => Promise.resolve(true)}
t={t}
renderSlot={(() => null) as InputBarProps['renderSlot']}
renderSlot={((key: string, seatOwner: object) => {
// The bar's own seats: recorded so a case can assert what share
// each tool-row control received.
seatOwners.push({ key, owner: seatOwner })
return null
}) as InputBarProps['renderSlot']}
{...bar}
/>
)
@@ -224,6 +236,7 @@ function mount(
useSessions: bindSnapshotSelector(sessions),
useWorkspaces: bindSnapshotSelector(workspaces),
useProjection: (() => undefined),
useComposerBlock: select => select(options.composerBlock),
useInput,
inputActions,
renderSlot,
@@ -233,7 +246,7 @@ function mount(
}
const view = render(<ConversationRoot {...props} />)
return {
view, chat, sink, retargetWorkspace, session, slotCalls, open,
view, chat, sink, retargetWorkspace, session, slotCalls, seatOwners, open,
pickerOwner: () => pickerOwner,
rerender: () => { view.rerender(<ConversationRoot {...props} />) },
}
@@ -242,12 +255,44 @@ function mount(
describe('Hero chrome', () => {
it('renders the English preview badge through the hero locale seat', () => {
const view = render(<HeroShell t={makeTranslate(en, commonEn)} />)
expect(view.getByText('Let\'s start building')).toBeTruthy()
expect(view.getByText('Into the Unknown')).toBeTruthy()
expect(view.getByText('Preview')).toBeTruthy()
})
})
describe('ConversationRoot resident composer', () => {
it('renders the composer inert with the blocker\u2019s own reason', () => {
const b = mount(conversationSnapshot(), undefined, undefined, {
composerBlock: { reason: 'select a model first' },
})
const box = b.view.getByRole('textbox') as HTMLTextAreaElement
// One disabled textarea with the blocker's placeholder, never a second
// tree: the DOM survives the block being raised and cleared.
expect(box.disabled).toBe(true)
expect(box.placeholder).toBe('select a model first')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.sink).not.toHaveBeenCalled()
// The model seat stays live. Locking it too would leave the composer
// asking for the one thing it prevents — every block this contract has is
// cleared by choosing a model.
const seat = (key: string) => b.seatOwners.filter(call => call.key === key).at(-1)?.owner
expect(seat('conversation.input.model')).toEqual({ locked: false })
expect(seat('conversation.input.plan')).toEqual({ locked: true })
})
it('lets the no-workspace posture win over a block', () => {
// Picking a workspace is the earlier prerequisite; naming a model first
// would send the user somewhere they cannot act yet.
const b = mount(conversationSnapshot({ composerPhase: 'blank' }), [], undefined, {
summaryBlank: true,
composerBlock: { reason: 'select a model first' },
})
const box = b.view.getByRole('textbox') as HTMLTextAreaElement
expect(box.disabled).toBe(true)
expect(box.placeholder).not.toBe('select a model first')
})
it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => {
const b = mount(conversationSnapshot())
const box = b.view.getByRole('textbox')
@@ -306,7 +351,7 @@ describe('ConversationRoot resident composer', () => {
const header = b.view.container.querySelector('header')
expect(host).not.toBeNull()
expect(header?.getAttribute('aria-hidden')).toBe('true')
expect(b.view.getByText('开始构建吧')).toBeTruthy()
expect(b.view.getByText('探索未知之境')).toBeTruthy()
expect(b.view.getByText('预览版')).toBeTruthy()
expect(b.view.queryByTestId('view-chat')).toBeNull()
// The same machine-backed textarea is live in the hero, and the
@@ -330,7 +375,7 @@ describe('ConversationRoot resident composer', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('settling')
expect(b.view.queryByText('开始构建吧')).toBeNull()
expect(b.view.queryByText('探索未知之境')).toBeNull()
})
it('settling phase: a session the list has no row for settles conservatively', () => {
@@ -355,7 +400,7 @@ describe('ConversationRoot resident composer', () => {
// blank the column for the history round-trip.
const root = b.view.container.querySelector('[data-phase]')
expect(root?.getAttribute('data-phase')).toBe('hero')
expect(b.view.getByText('开始构建吧')).toBeTruthy()
expect(b.view.getByText('探索未知之境')).toBeTruthy()
expect(b.view.getByRole('textbox')).toBeTruthy()
})
@@ -373,7 +418,7 @@ describe('ConversationRoot resident composer', () => {
expect(after.value).toBe('kept across flip')
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
expect(b.view.queryByText('开始构建吧')).toBeNull()
expect(b.view.queryByText('探索未知之境')).toBeNull()
expect(b.view.getByTestId('view-chat')).toBeTruthy()
})