Merge remote-tracking branch 'origin/master' into worktree/web-model-request-retry

# Conflicts:
#	apps/web/tests/smoke-real.e2e.ts
#	packages/client/runtime/src/client/index.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.module.css
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
This commit is contained in:
Yichen Jiang
2026-07-27 12:14:23 +08:00
1521 changed files with 44884 additions and 10950 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 1e87c5937af5a0c8e5c99f1e8bab47884c5721e6
README.zh.md: 619bd946ffbacacef4116d595c066a731f8c61be

View File

@@ -1,12 +1,14 @@
# @deepseek-ai/dsh-client-ui-conversation
English | [中文](README.zh.md)
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
The no-session hero renders the frontend Session Intent from the Session list projection, including its frontend Workspace Intent when no real Workspace exists. It declares `conversation.empty.workspace`, where ui-workspace registers the same picker used by the sidebar. WorkspacesService starts the cross-object flow; each Workspace or Session object owns its own materialization. The Session keeps its identity across publication and retains any prompt that still needs connection or delivery; ConversationRoot reads that `pendingPrompt` from `useSession` and edits or retries it through the scoped ConversationService.
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction.
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 `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. 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), and the details panel resolves a selected sub-call id to its full logged args and complete output.
The chat flow projects consecutive model-retry nodes from one turn into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown derives from the scheduled delay, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer, then settles to a static completed label. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds.

View File

@@ -0,0 +1,35 @@
# @deepseek-ai/dsh-client-ui-conversation
[English](README.md) | 中文
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、统计行、逐工具行 slot 及一个 bash 示例注册方)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
无会话主视觉区会渲染来自 Session 列表投影的前端 Session Intent;没有真实 Workspace 时,还会包含其前端 Workspace Intent。它声明 `conversation.empty.workspace`,ui-workspace 会在此注册侧边栏所用的同一选择器。WorkspacesService 启动跨对象流程;每个 Workspace 或 Session 对象拥有自身的物化。Session 在发布期间保持身份,并保留任何仍需连接或交付的提示词;ConversationRoot 读取该 `pendingPrompt`,其来源是 `useSession`,再通过 scope 内的 ConversationService 编辑或重试。
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>` 或 `Edit · <path>` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。
聊天流会将同一轮次内连续的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时由计划延迟派生,剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画,完成后则稳定显示为静态的已完成标签。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
逐 Session UI 状态(选择、普通编辑器草稿、活跃视图)位于已声明的聊天 store(`stores.ts` `createChatStore`)中:apply 构造一个 handle,并将其传给会话、聊天视图和详情注册,因此 Session slot 每个 Session 共享一个实例(选择由聊天视图写入、详情读取),框架拥有实例生命周期与草稿持久化。前端 Session Intent 来自 Session 列表投影;发布后,任何保留的提示词都来自该 Session 的会话快照。组件保持纯粹:框架标准工具包(Session scope 下的 `useSession`/`sessionId`,以及全局 `useSessions`/`useWorkspaces`)和 store 表层(`useStore`/`actions`)会从注册声明自动到达;inject factory 为运行时 Session 操作、发送/停止、标签页、详情和分页贡献普通数据与回调。
`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/*` 子路径获取它们)。
## 模型体验
无。会话 UI 在浏览器中渲染会话历史与流;这里没有任何内容进入模型请求。
#### KV Cache 影响
无;该包既不组装也不发送提供方请求。
## 已知限制与暂缓事项
- **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。
- **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。
- **assistant footer 扩展(IconActions 行、逐消息分页)是预留 slot**:设计中已有图稿,尚未实现。
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
- **审批卡片只是只读占位符**:问题请求通过编辑器链回答(ui-question),Web 侧审批回答属于 P-II 审批项目。

View File

@@ -41,6 +41,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
@@ -50,6 +51,7 @@
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",

View File

@@ -5,15 +5,18 @@ import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ViewTab } from './contract/views.ts'
import type {
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
} from './contract/slots.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import { InputHub } from './input/hub.ts'
import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { queueDockEntry } from './queue/QueueDock.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { ConversationSession } from './skeleton/ConversationSession.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { EmptyState } from './skeleton/EmptyState.tsx'
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
@@ -49,50 +52,100 @@ export function apply(ctx: Context): void {
return tabs
}
// Conversation occupant. Declaring the view ring here is claiming it:
// ConversationRoot is the only component authorized to render the ring.
// The per-session input machine registry (InputService face; published as
// ctx.conversation.input by the service below sharing this one instance).
const inputHub = new InputHub(ctx)
// 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
// (per-session lazy; scope disposer tears down).
ctx.effect(() => sessions.provide({
hooks: ['input'],
props: ['inputActions'],
resolve: (binding) => {
const shell = inputHub.shellFor(binding)
return {
hooks: { input: shell.state },
props: { inputActions: shell.actions },
}
},
}), 'ui-conversation: input standard-kit provider')
// Resident current-session-optional shell. It owns the stable Hero/composer
// frame while strict session slots fill only their session-bound regions.
slots.register({
name: 'conversation',
// The composer chain rides the same declaration table: takeover plugins
// register selector-routed replacements of the InputBar.
children: {
'conversation.view': { kind: 'list', scope: 'session' },
'conversation.session': { kind: 'single', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
'conversation.composer.bar': { kind: 'single', scope: 'session' },
'conversation.input.overlay': { kind: 'list', scope: 'session' },
'conversation.input.dock': { kind: 'list', scope: 'session' },
'conversation.composer.dock': { kind: 'list', scope: 'session' },
'conversation.input.left': { kind: 'list', scope: 'session' },
'conversation.input.right': { kind: 'list', scope: 'session' },
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
},
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
selectWorkspace: (workspaceId) => {
void workspaces.connectWorkspace(workspaceId).then((nextId) => {
if (sessionId !== undefined && nextId !== sessionId) {
const from = inputHub.shell(sessionId)
const draft = from.snapshot.draft
if (draft !== '') {
inputHub.shell(nextId).setDraft(draft)
from.setDraft('')
}
}
sessions.open(nextId)
}).catch(() => {
// Failure leaves the current Hero state available to retry.
})
},
}),
}, ConversationRoot)
// The strict session subtree owns only per-session store and view content;
// the resident parent keeps Hero and composer layout identity stable.
slots.register({
name: 'conversation.session',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ConversationInjected => {
// History pull is NOT triggered here: the runtime sessions service opens
// the event window when the watch lands on the session (cell/binding
// resolution) — an inject factory assembles callbacks, it has no side
// effect on session state.
const scoped = scopedConversation(sessions, sessionId)
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
views: {
list: viewTabs,
subscribe: fn => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
},
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
open: (id) => { sessions.open(id) },
}),
}, ConversationSession)
// The default composer body: its own single slot inside the composer
// 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).
slots.register({
name: 'conversation.composer.bar',
// The two named control seats in the bar's tool row (plan left, model
// right); empty until their owning plugins register (B ruling).
children: {
'conversation.input.plan': { kind: 'single', scope: 'session' },
'conversation.input.model': { kind: 'single', scope: 'session' },
},
inject: (sessionId: SessionId): ComposerBarInjected => {
return {
views: {
list: viewTabs,
subscribe: fn => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
},
send: (text, mode) => {
const trimmed = text.trim()
if (trimmed === '') return
// Optimistic clear with failure restore (choreography lives with the
// sender; the business failure also lands in snapshot.promptError).
// The store write path stays inside the declared actions set:
// restoreDraft itself no-ops once the user typed something new.
actions.clearDraft()
void scoped.send(trimmed, mode).catch(() => { actions.restoreDraft(trimmed) })
},
keyboard: inputHub.keyboard(sessionId),
stop: () => {
scoped.cancel().catch(() => {
scopedConversation(sessions, sessionId).cancel().catch(() => {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
})
},
open: (sessionId) => { sessions.open(sessionId) },
updateSessionPrompt: (text) => { scoped.updatePendingPrompt(text) },
retrySessionPrompt: () => { scoped.retryPendingPrompt() },
}
},
}, ConversationRoot)
}, InputBar)
// The chat view: first entry of the ring this package just declared.
// Declaring the keyed toolview hole here is claiming it: ChatView is the
@@ -124,11 +177,15 @@ export function apply(ctx: Context): void {
// toolview registrants using `inject: ['conversation']` as their load-order
// seam: the service being present implies the chat entry (and with it the
// 'conversation.chat.toolview' declaration) is on the ledger.
ctx.plugin(ConversationService)
ctx.plugin(ConversationService, { input: inputHub })
// The bash sample rides that exact seam, in third-party posture.
ctx.plugin(bashToolviewSample)
// The read-only queue dock entry (T9 file territory) rides the same
// registration seam into the input dock declared above.
ctx.plugin(queueDockEntry)
slots.register({
name: 'details',
store: chatStore,
@@ -137,13 +194,4 @@ export function apply(ctx: Context): void {
}),
}, DetailsPanel)
slots.register({
name: 'conversation.empty',
children: { 'conversation.empty.workspace': { kind: 'single', scope: 'root' } },
inject: (): EmptyStateInjected => ({
startSession: (workspaceId, prompt) => { workspaces.startSession(workspaceId, prompt) },
updateSessionPrompt: (text) => { sessions.updateIntent(text) },
sendSession: () => { workspaces.sendSession() },
}),
}, EmptyState)
}

View File

@@ -44,7 +44,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
<div className={css.root} data-streaming={streaming || undefined}>
{blocks.map((block, i) => {
switch (block.kind) {
case 'text': return <MarkdownText key={i} text={block.text} />
case 'text': return <MarkdownText key={i} text={block.text} streaming={streaming} />
case 'reasoning': return <ThinkRow key={i} text={block.text} running={streaming && i === last} />
// Tool-call heads render as tool rows in the chat view's grouping pass.
case 'tool-call': return null

View File

@@ -45,6 +45,18 @@
outline-offset: 1px;
}
/* run_code sub-dispatch rows: indented under the parent row, left-edged so
the code turn reads as one unit; each nested row is itself a .callRow
(same components, same selection outline as top-level rows). */
.subCalls {
display: flex;
flex-direction: column;
gap: 4px;
margin: 4px 0 2px 22px;
padding-left: 8px;
border-left: 1px solid var(--dsw-alias-border-l2);
}
.hint {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;

View File

@@ -20,7 +20,7 @@ import {
memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react'
import type {
ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
CodeSubCall, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -55,10 +55,39 @@ function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): n
return null
}
/** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a
* top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
* renders the running state exactly as a native in-flight row. */
const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected }: {
renderSlot: RenderToolRow
node: CodeSubCall
onOpenDetails: OpenDetails
selected: boolean
}) {
const settled = 'kind' in node
const toolName = settled ? node.call?.name ?? '' : node.name
const seq = settled ? node.seq : node.time
const owner = useMemo(() => ({
callId: node.callId, toolName, block: node,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) },
}), [node, toolName, seq, onOpenDetails])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} />,
})}
</div>
)
})
/** One tool call row (result or running): dispatches through the keyed
* toolview slot with the owner payload; unregistered tools fall back to
* GenericToolCard at this render site. */
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected }: {
* GenericToolCard at this render site. A `run_code` call additionally
* renders its logged sub-dispatches as always-visible indented rows —
* each one the same keyed-slot dispatch as a native top-level call. */
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId }: {
renderSlot: RenderToolRow
callId: string
toolName: string
@@ -67,6 +96,10 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
seq: number
onOpenDetails: OpenDetails
selected: boolean
/** `run_code` sub-dispatches in dispatch order (reference-stable per parent; running entries settle in place); undefined for ordinary calls. */
subCalls?: readonly CodeSubCall[] | undefined
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
selectedCallId?: string | undefined
}) {
const owner = useMemo(() => ({
callId, toolName, block,
@@ -78,17 +111,32 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
entryKey: toolName,
fallback: <GenericToolCard {...owner} />,
})}
{subCalls !== undefined && subCalls.length > 0 && (
<div className={css.subCalls} data-subcalls>
{subCalls.map((node) => (
<SubCallRow
key={node.callId}
renderSlot={renderSlot}
node={node}
onOpenDetails={onOpenDetails}
selected={node.callId === selectedCallId}
/>
))}
</div>
)}
</div>
)
})
/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId }: {
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches }: {
renderSlot: RenderToolRow
results: readonly ToolResultNode[]
onOpenDetails: OpenDetails
/** Only set when the selected call lives in THIS group (memo economy). */
/** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */
selectedCallId: string | undefined
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
}) {
return (
<div className={css.toolGroup}>
@@ -102,6 +150,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
seq={node.seq}
onOpenDetails={onOpenDetails}
selected={node.callId === selectedCallId}
subCalls={codeDispatches.get(node.callId)}
selectedCallId={selectedCallId}
/>
))}
</div>
@@ -127,6 +177,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
const nodes = useSession((s) => s.nodes)
const running = useSession((s) => s.running)
const runningCalls = useSession((s) => s.runningCalls)
const codeDispatches = useSession((s) => s.codeDispatches)
const pending = useSession((s) => s.pending)
const openState = useSession((s) => s.openState)
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
@@ -215,7 +266,8 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
const renderItem = (item: ChatFlowItem): ReactNode => {
if (item.kind === 'tool-group') {
const inGroup = selectedCallId !== undefined
&& item.results.some((r) => r.callId === selectedCallId)
&& item.results.some((r) => r.callId === selectedCallId
|| codeDispatches.get(r.callId)?.some((sub) => sub.callId === selectedCallId) === true)
return (
<ToolGroup
key={item.key}
@@ -223,6 +275,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
results={item.results}
onOpenDetails={openDetails}
selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches}
/>
)
}
@@ -268,6 +321,8 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
seq={call.turn}
onOpenDetails={openDetails}
selected={call.callId === selectedCallId}
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}
/>
))}
</div>

View File

@@ -6,7 +6,7 @@
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowOwnerProps } from '../contract/slots.ts'
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
@@ -21,6 +21,7 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
bash: <IconApiOutline14 size={16} />,
write: <IconEditOutline16 />,
edit: <IconEditOutline16 />,
code: <IconCodeOutline16 />,
others: <IconSparkle16 />,
}

View File

@@ -132,3 +132,18 @@
animation: none;
}
}
/* Reference chip projection inside a user bubble (`<skill>name</skill>` model
spans render as chips; free geometry — no textarea pairing here). */
.refChip {
display: inline-block;
margin: 0 2px;
padding: 0 8px;
border-radius: 6px;
background: rgba(97, 135, 216, 0.22);
color: var(--dsw-alias-label-primary);
font-size: 0.85em;
line-height: 1.6;
white-space: nowrap;
vertical-align: baseline;
}

View File

@@ -3,7 +3,7 @@
// Props are frozen node slices off the snapshot cache; memo holds across
// streaming because unchanged nodes keep their references.
import { memo, useEffect, useState } from 'react'
import { memo, useEffect, useState, type ReactNode } from 'react'
import type {
ContextMessageNode, ModelRetryNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -75,6 +75,38 @@ function ModelRetryItem({ node, active }: { node: ModelRetryNode; active: boolea
)
}
/**
* Display projection of reference forms in a user bubble (free geometry — no
* textarea alignment constraint here); everything else stays plain text. The
* logged model text remains the single truth; this is presentation only. Two
* shapes decorate: legacy `<skill>name</skill>` spans (pre-decision-21
* history) and plain-text `/name` / `@name` word-boundary tokens (decision
* 21: the sent text IS the reference — the bubble uses the same plainest
* token scan as the composer, minus the lexicon: sent tokens were validated
* at compose time, so shape alone decorates).
*/
function projectUserText(text: string): ReactNode {
const re = /<skill>([^<]+)<\/skill>|(^|\s)([/@][\w-]+)(?=\s|$)/g
const parts: ReactNode[] = []
let cursor = 0
let m: RegExpExecArray | null
while ((m = re.exec(text)) !== null) {
const legacy = m[1] !== undefined
const tokenStart = legacy ? m.index : m.index + (m[2]?.length ?? 0)
const label = legacy ? `/${m[1]}` : m[3] ?? ''
if (tokenStart > cursor) parts.push(<MessageText key={cursor} text={text.slice(cursor, tokenStart)} />)
parts.push(
<span key={tokenStart} className={css.refChip} data-ref-chip={label.startsWith('@') ? 'subagent' : 'skill'}>
{label}
</span>,
)
cursor = legacy ? m.index + m[0].length : tokenStart + label.length
}
if (parts.length === 0) return <MessageText text={text} />
if (cursor < text.length) parts.push(<MessageText key={cursor} text={text.slice(cursor)} />)
return <>{parts}</>
}
export const MessageItem = memo(function MessageItem({ node, retryActive = false }: MessageItemProps) {
switch (node.kind) {
case 'user':
@@ -84,7 +116,7 @@ export const MessageItem = memo(function MessageItem({ node, retryActive = false
<div className={css.userRow}>
<div className={css.bubble}>
{node.kind === 'steering' && <span className={css.badge}>插话</span>}
<MessageText text={text} />
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
</div>
</div>

View File

@@ -86,3 +86,10 @@ button.leading {
word-break: break-word;
color: var(--dsw-alias-label-tertiary);
}
/* The code variant's expanded body is the run_code program, rendered through
the shared CodeBlock (shiki-highlighted TypeScript); only indentation is
this row's concern. */
.codeBody {
margin: 4px 0 4px 22px;
}

View File

@@ -6,7 +6,7 @@
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
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'
import css from './ToolRow.module.css'
@@ -96,7 +96,9 @@ export function ToolRow({
</>
)}
</div>
{open && <div className={css.body}>{body}</div>}
{open && (variant === 'code'
? <CodeBlock code={body} lang="typescript" className={css.codeBody} />
: <div className={css.body}>{body}</div>)}
</div>
)
}

View File

@@ -1,12 +1,22 @@
/** Conversation slot declarations and their composed component props. */
import type { RefObject } from 'react'
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ReactNode, RefObject } from 'react'
import type {
MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, InputActions, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
* Strict-session content inside the resident conversation shell. This
* subtree owns the per-session chat store, header, and view ring and is
* remounted when the current session id changes.
*/
'conversation.session': { kind: 'single'; scope: 'session'; owner: ConversationSessionOwnerProps }
/**
* The conversation view ring: one list entry per view tab (chat here;
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
@@ -31,9 +41,83 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* zero owner changes.
*/
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
/** Shared Workspace picker hole used by the page-local Session Intent hero. */
'conversation.empty.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps }
/**
* The hero-phase Workspace picker hole: rendered by ConversationRoot
* while the session is blank (picking another workspace switches to that
* workspace's blank session, draft carried). Root scope: the picker
* reads the global workspace list.
*/
'conversation.hero.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps }
// 'conversation.input.overlay' merges in ui-slash (dedup ruling: the
// dependency direction is the hard constraint — ui-slash cannot import
// this package, while this package's input contract already imports
// ui-slash, so the type arrives transitively). The runtime declaration
// (children table in apply.ts) stays here with the other input slots.
/**
* Stacked strip above the input (queue rows / GoalBar / attachments;
* design §6 MIX evidence: entries coexist in fixed order).
*/
'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
/** The composer top-edge band (stats line family). */
'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
/** Tool-row left region inside the input card (existing chrome stays in place beside entries). */
'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone }
/** Tool-row right region inside the input card. */
'conversation.input.right': { kind: 'list'; scope: 'session'; owner: InputZone }
/**
* 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.
*/
'conversation.composer.bar': { kind: 'single'; scope: 'session'; owner: ComposerBarOwnerProps }
/**
* The Plan-mode control seat in the composer tool row (left group).
* Declared by the composer-bar entry; empty until a plan plugin
* registers (B ruling: no placeholder fallback).
*/
'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
/**
* The model-select seat in the composer tool row (right group). Same
* empty-until-registered contract as the plan seat.
*/
'conversation.input.model': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
}
/**
* ui-conversation's members of the session standard kit, provided through
* `sessions.provide` (decision 19/20): every session-scope slot component
* receives the input machine's state hook and the two public actions.
*/
interface SessionStandardProps {
/** Selector hook over the session's live input machine state. */
useInput: SnapshotSelectorHook<InputState>
/** The public input action face (stable identity per session). */
inputActions: InputActions
}
/** Input members for the resident composer while current session is optional. */
interface SessionMaybeStandardProps {
useInput: MaybeSnapshotSelectorHook<InputState>
inputActions: InputActions | undefined
}
}
/** Owner share of the strict session content seat. */
export interface ConversationSessionOwnerProps {
}
/**
* The input-region slot currency (plan §1.4): dock/left/right entries read
* the conversation snapshot and the live input state as owner props (both
* are point-in-time snapshots — the dispatching skeleton re-renders on
* either store's change, so entries stay current without subscribing).
*/
export interface InputZone {
readonly session: ConversationSnapshot
readonly input: InputState
}
/**
@@ -87,24 +171,72 @@ export type ChatStore = ReturnType<typeof createChatStore>
/** Business callbacks injected into the conversation slot. */
export interface ConversationInjected {
/**
* Connect the selected Workspace and open its reusable/new blank session.
* When a blank session is already current, carry its draft to the target.
*/
selectWorkspace(workspaceId: WorkspaceId): void
}
/** Business callbacks injected into the strict session content seat. */
export interface ConversationSessionInjected {
/** Views projected from the `conversation.view` slot ledger. */
views: {
list(): readonly ViewTab[]
subscribe(fn: () => void): () => void
version(): number
}
/** Send choreography: trims, clears the draft optimistically, restores it on failure. */
send(text: string, mode: 'queue' | 'steer'): void
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
stop(): void
/** Bind the input machine's draft persistence mirror to the session store. */
bindDraftMirror(write: (text: string) => void): () => void
/** Select a real Session through the runtime navigation owner. */
open(sessionId: SessionId): void
/** Update the scoped Session's retained prompt. */
updateSessionPrompt(text: string): void
/** Retry the scoped Session's retained prompt. */
retrySessionPrompt(): void
}
/**
* Owner share of the composer-bar slot: ConversationRoot's layout-phase
* inputs plus the input-region child-slot content it renders (the region
* slots stay declared/rendered by the conversation entry; the bar hosts the
* results as chrome).
*/
export interface ComposerBarOwnerProps {
/** Hero = empty-state centered card; composer = resident bottom bar. */
variant: 'hero' | 'composer'
placeholder?: string
/** Optional content rendered above the textarea. */
accessory?: ReactNode
/** Floating overlay anchor content (menu / popup shell entries), rendered inside the card. */
overlay?: ReactNode
/** input.left slot entries (tool row, beside the resident chrome). */
leftItems?: ReactNode
/** input.right slot entries (tool row, before the primary button). */
rightItems?: ReactNode
onAdd?: () => void
addLabel?: string
}
/** 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
}
/**
* Owner share of the two named composer control seats (plan / model): the
* bar passes its disable state; the filling entry owns everything else.
*/
export interface InputControlOwnerProps {
/** Session-removed lock (the bar's chrome disable state). */
locked: boolean
}
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share. */
export type ComposerBarProps =
PropsRuntime<'conversation.composer.bar'>
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
& ComposerBarInjected
/**
* Composer chain currency: what ConversationRoot dispatches at its
* renderSlotChain site. The owner declares the currency only — never a
@@ -116,10 +248,26 @@ export interface ComposerChainProps {
interactions: readonly PendingInteraction[]
}
/** Full conversation-slot component props: runtime & child-render (view ring + composer chain) & store & injected shares. */
/**
* Full conversation-slot component props: runtime & child-render (view ring
* + composer chain/bar + input-region + hero picker slots) & store & injected shares.
*/
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view' | 'conversation.composer'>
& PropsStore<ChatStore> & ConversationInjected
PropsRuntime<'conversation'> & PropsRenderSlots<
| 'conversation.session' | 'conversation.composer' | 'conversation.composer.bar'
| 'conversation.input.overlay'
| 'conversation.input.dock' | 'conversation.composer.dock'
| 'conversation.input.left' | 'conversation.input.right'
| 'conversation.hero.workspace'
>
& ConversationInjected
/** Full strict-session content props: per-session store, view ring, and callbacks. */
export type ConversationSessionSlotProps =
PropsRuntime<'conversation.session'>
& PropsRenderSlots<'conversation.view'>
& PropsStore<ChatStore>
& ConversationSessionInjected
/**
* Injected share of the chat view entry: the two callbacks whose targets live
@@ -148,24 +296,10 @@ export interface DetailsInjected {
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected
/** Owner share common to the empty hero's Workspace picker. */
/** Owner share common to the hero / New-Session Workspace pickers. */
export interface EmptyWorkspaceOwnerProps {
open: boolean
anchorRef?: RefObject<HTMLElement>
onPick(workspaceId: WorkspaceId): void
onClose(): void
}
/** Runtime-owned actions injected into the empty-state occupant. */
export interface EmptyStateInjected {
/** Replace the current Session intent, optionally preserving a prompt while retargeting. */
startSession(workspaceId?: WorkspaceId, prompt?: string): void
/** Update the current Session intent's controlled prompt. */
updateSessionPrompt(text: string): void
/** Materialize and send the current Session intent. */
sendSession(): void
}
/** Full empty-state component props: runtime projections, picker child slot, and injected actions. */
export type EmptyStateSlotProps =
PropsRuntime<'conversation.empty'> & PropsRenderSlots<'conversation.empty.workspace'> & EmptyStateInjected

View File

@@ -13,8 +13,8 @@ export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
/** The frozen slice the chat view hands to toolview components as `block`
* (both members are cache-stable references off ConversationSnapshot). */
/** The seven row variants (think is fed by reasoning blocks, not tool calls). */
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'others'
/** The eight row variants (think is fed by reasoning blocks, not tool calls). */
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'code' | 'others'
/** Row state semantic; colors self-supplied via StateDot (design gives none). */
export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped'
@@ -22,7 +22,7 @@ export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped'
/** Figma row titles per variant (design literals, not translatable copy). */
export const VARIANT_TITLES: Record<ToolRowVariant, string> = {
think: 'Think', search: 'Search', read: 'Read', bash: 'Bash',
write: 'Write', edit: 'Edit', others: 'Tool call',
write: 'Write', edit: 'Edit', code: 'Code', others: 'Tool call',
}
/** Known tool name -> variant. */
@@ -35,6 +35,7 @@ const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
glob: 'search',
write: 'write',
edit: 'edit',
run_code: 'code',
}
/**
@@ -86,6 +87,7 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
think: [],
write: ['path', 'file_path'],
edit: ['path', 'file_path'],
code: ['description'],
others: [],
}
@@ -101,10 +103,17 @@ function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
return firstLine(argsRaw)
}
function deriveBody(argsRaw: string): string | null {
function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
if (argsRaw === '') return null
const parsed = parseArgs(argsRaw)
return parsed === undefined ? argsRaw : JSON.stringify(parsed, null, 2)
if (parsed === undefined) return argsRaw
// The code row's expanded body IS the program (monospace via the row's
// variant styling), not the args JSON envelope around it.
if (variant === 'code' && typeof parsed === 'object' && parsed !== null) {
const code = (parsed as Record<string, unknown>).code
if (typeof code === 'string' && code !== '') return code
}
return JSON.stringify(parsed, null, 2)
}
/**
@@ -128,7 +137,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowMod
variant,
title: VARIANT_TITLES[variant],
summary,
body: deriveBody(argsRaw),
body: deriveBody(variant, argsRaw),
state,
}
}

View File

@@ -13,9 +13,9 @@ export type {
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ConversationInjected,
ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps, EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

View File

@@ -0,0 +1,269 @@
/**
* Frozen input-machine contract (design §9.1, eng. plan §3.9-3.12). Types
* only. Three-tier visibility: business packages see InputState via the
* InputZone currency; the scoped input events carry the mutation verbs; the
* conversation wiring layer alone sees the full SessionInput. InputMachine
* (machine.ts) is package-private and never exported.
*/
import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
ReferenceInsert, SubmitOutcome, TokenSpan,
} from '@deepseek-ai/dsh-client-ui-slash/client'
/**
* The scoped-event application verbs: the hub's bail listeners call these,
* and the boolean answer IS the event's bail value (true ⟺ the machine
* accepted after phase and span/bare-token guards).
*/
export interface InputTarget {
/** Replace the trigger span with claim.token and enter claimed (span-CAS'd). */
beginCommand(claim: CommandClaim, span: TokenSpan): boolean
/** Replace the trigger span with one reference occurrence (span-CAS'd). */
insertReference(ref: ReferenceInsert, span: TokenSpan): boolean
}
/** Per-session input facade owned by the conversation wiring layer. */
export interface SessionInput extends InputTarget {
/** Single write path for draft text (all mutation rides machine events). */
setDraft(text: string): void
/** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */
submit(mode?: 'queue' | 'steer'): void
/**
* Surface a notice outside the machine's own effect stream: detached
* command results and business notifications render through here.
* Session-routed — resolving the facade via InputService.for(actx) lands
* the notice on that session's composer, so a result arriving after a
* session switch still reaches its own session.
* @param level - severity tier.
* @param text - notice body.
*/
notify(level: 'info' | 'error', text: string): void
/** Input state store (InputZone currency + decorations read here). */
readonly state: SnapshotStore<InputState>
}
/** Session-addressed access to the per-session input facade. */
export interface InputService {
/** Resolve the facade for one session-scope ctx. */
for(actx: ClientContext): SessionInput
}
/**
* The public input action face provided to every session-scope slot
* component (decision 20): two stable-identity void callbacks, mirroring the
* useStore+actions convention. Command-style handles (track/arbitrate/space/
* undo/paste/…) stay InputBar-private and never ride this face.
*/
export interface InputActions {
/** Single public draft write path (full next draft; occurrence math via diff scan). */
setDraft(text: string): void
/** Enter submission (adjudication / claim transaction / default sink inside). */
submit(mode?: 'queue' | 'steer'): void
}
/** One surfaced notice (command results, adjudication failures). seq keys re-render of repeats. */
export interface InputNotice {
readonly level: 'info' | 'error'
readonly text: string
readonly seq: number
}
/**
* The InputBar-exclusive keyboard/DOM command face (decision 20): synchronous
* returns and event-handler semantics that must not enter the public provide
* channel. Handed to the composer-bar entry through its own inject —
* package-internal, never across a plugin boundary. The session shell
* satisfies it structurally.
*/
export interface ComposerKeyboard {
/** Latest surfaced notice store (null after none). */
readonly notices: SnapshotStore<InputNotice | null>
/** Live machine state for event-handler reads (render reads go through useInput). */
readonly snapshot: InputState
/** Draft write with the DOM-observed edit shape (narrows occurrence math). */
setDraft(text: string, editRange?: EditRange): void
/** Newline at the selection as a machine transaction (Ctrl+Enter path). */
newline(selection: EditSelection): void
undo(): void
redo(): void
/** Paste over the selection (sync components ride the same transaction). */
pasteBegin(text: string, selection: EditSelection, components?: readonly PasteComponent[], generation?: number): void
/** Caret/selection gestures the machine cannot observe end the paste attempt. */
invalidatePaste(): void
/** Feed a draft/caret change through trigger detection (guard derived from phase). */
track(draft: string, caret: number): void
/** Keyboard arbitration while the menu is open ('pass' when no pipeline). */
arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome
/** Space adjudication; true = the input applied a claim — caller preventDefaults. */
space(): boolean
/** Dismiss the popupSelect shell (any interaction outside the box). */
dismissPopup(): void
/** Hot plain-text reference lexicons for the decoration scan (decision 21; empty Map without a pipeline). */
lexicon(): ReadonlyMap<'/' | '@', readonly string[]>
}
/** One queued-message row projected from the session/queued frames (T9 supplies the store). */
export interface QueuedMessage {
/** Stable row key: the enqueueing prompt's rpcId. */
readonly key: string
readonly preview: string
}
/** Guard union of the scoped consume-token event, checked by the machine. */
export type ConsumeTokenGuard = ConsumeTokenRequest['guard']
/** Half-open [start, end) range/selection in draft character coordinates. */
export interface EditSelection {
readonly start: number
readonly end: number
}
/**
* One edit applied to the previous draft: [start, end) in the PREVIOUS
* draft's coordinates was replaced by insertedLength characters. Supplied by
* the wiring layer when the DOM event exposes the edit shape; absent, the
* machine recovers it with a prefix/suffix common-scan diff.
*/
export interface EditRange extends EditSelection {
readonly insertedLength: number
}
/**
* One reference chip occurrence, backing exactly one U+FFFC placeholder in
* the draft (design §9.1 底层表示). Identity is occurrenceId — same-named
* references stay independently addressable. label/clipboardText are the
* owner's insert-time projections, cached so the chip survives owner loss
* (invalid flips instead of dropping the occurrence).
*/
export interface Occurrence {
/** Machine-minted stable identity (monotonic per machine). */
readonly occurrenceId: number
/** Owning source name (serializer routing key). */
readonly source: string
/** Owner-scoped reference id. */
readonly ref: string
/** Placeholder offset in the draft; the occurrence occupies exactly [offset, offset+1). */
readonly offset: number
/** Chip display label (insert-time cache). */
readonly label: string
/** Clipboard / persistence projection, e.g. `/name` (insert-time cache, never the model form). */
readonly clipboardText: string
/** Owner-resolution failure flag: chip renders invalid; serialization must fail. */
readonly invalid?: boolean
}
/** One sync-matched paste component; start/end are relative to the pasted text. */
export interface PasteComponent extends EditSelection {
readonly reference: ReferenceInsert
}
/**
* Live paste-match attempt published while async matching may still upgrade
* pasted tokens (design §9.1 剪贴板 round-trip). Any non-paste transaction,
* submit start, invalidate-paste, or release ends it; a paste-upgrade keeps
* it current (later tokens re-CAS against the advanced draftRev).
*/
export interface PasteAttemptState {
/** Machine-minted attempt identity (paste-upgrade must match it). */
readonly attemptId: number
/** Pasted range in the draft as of the paste transaction. */
readonly insertedRange: EditSelection
/** Caller-supplied projection generation echoed back (the controller drops cross-generation results). */
readonly generation: number
}
/**
* InputMachine construction knobs. The machine never reads an ambient clock:
* `now` is the only time source, injected by the shell (tests inject a
* fake). The default clock is constant, i.e. consecutive single-char typing
* always coalesces until a non-typing transaction intervenes.
*/
export interface InputMachineOptions {
/** Single-char typing undo-merge window in ms (default 1000). */
readonly mergeWindowMs?: number
/** Monotonic clock for typing-merge decisions (default: constant 0). */
readonly now?: () => number
}
/** Published input state (the currency; per-session). */
export interface InputState {
readonly draft: string
/** Monotonic draft revision (span CAS compares against this). */
readonly draftRev: number
readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
/** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */
readonly claim?: { readonly token: string; readonly hint?: string }
/** Chip occurrence table, sorted by offset (one U+FFFC per entry). */
readonly occurrences: readonly Occurrence[]
/** Live paste-match attempt (absent when no paste is matchable). */
readonly paste?: PasteAttemptState
/** Read-only queue projection (session/queued frames + connect snapshot). */
readonly queue: readonly QueuedMessage[]
}
/**
* One in-flight submission attempt: the ONLY id concept in the submit plane.
* Created on enter; carried by adjudicated/submit-settled events; stale
* attempts are dropped (anti-backwash). release/session teardown aborts the
* current attempt, keeping the promise bounded.
*/
export interface SubmitAttempt {
readonly seq: number
readonly signal: AbortSignal
/** Draft at enter time; rollback restores it only while the live draft still equals it. */
readonly draftSnapshot: string
}
/**
* InputMachine input events (the machine's single write path). Every draft
* mutation is one transaction: draft edit, occurrence reconciliation, and
* undo-log push are atomic inside dispatch(). Events carrying `at` stamp the
* injected clock reading; only single-char typing coalescing reads it.
*/
export type InputEvent =
/** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */
| { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange }
/** Insert '\n' replacing the selection (F1: the execCommand newline path moved into the machine). */
| { readonly type: 'newline'; readonly selection: EditSelection }
| { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan }
/** Place one U+FFFC at the span and mint the occurrence (scoped insert-reference event payload). */
| { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan }
/** Delete a settled command token; success is observable as a draftRev advance. */
| { readonly type: 'consume-token'; readonly guard: ConsumeTokenGuard }
/** Owner-resolution result: exactly the listed occurrences are invalid (style bit; not a transaction). */
| { readonly type: 'set-invalid'; readonly invalidIds: readonly number[] }
| { readonly type: 'undo' }
| { readonly type: 'redo' }
/**
* Paste text replacing the selection, one transaction. Hot-snapshot sync
* matches ride in as components (chips minted inside the SAME transaction:
* one undo returns to pre-paste); a PasteMatchAttempt opens for the async
* remainder. Component ranges must be disjoint and inside the pasted text.
*/
| { readonly type: 'paste-begin'; readonly text: string; readonly selection: EditSelection; readonly components?: readonly PasteComponent[]; readonly generation?: number }
/** Async match landed: upgrade one pasted token to a chip as an INDEPENDENT transaction (undo #1 → text, undo #2 → pre-paste). */
| { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert }
/** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */
| { readonly type: 'invalidate-paste' }
| { readonly type: 'enter'; readonly mode: 'queue' | 'steer' }
| { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
| { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
/**
* An ordinary (default-sink) send was accepted: clear the draft as a COMMIT —
* undo must not resurrect sent content (mirrors submit-settled's success arm).
*/
| { readonly type: 'send-committed' }
| { readonly type: 'release' }
/**
* InputMachine output effects (executed by the SessionInput shell; the
* machine stays pure). Draft/occurrence mutations carry no effect — the
* shell publishes the state store after every dispatch.
*/
export type InputEffect =
| { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string }
| { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string }
| { readonly type: 'default-sink'; readonly draft: string; readonly mode: 'queue' | 'steer' }
| { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }

View File

@@ -0,0 +1,105 @@
/**
* Draft decoration pure core (design §9.1: chips render from the occurrence
* table at placeholder offsets; the claim token renders as a mirror-layer
* highlight, the claim hint as ghost text). Zero React — the skeleton renders
* the instructions; tests drive this directly.
*/
import type { InputState } from './contract.ts'
/** The claim-token highlight range (always draft-leading while the watch holds). */
export interface TokenRange {
readonly start: number
readonly end: number
}
/** One chip render instruction: the placeholder at `offset` draws as `label`. */
export interface ChipRender {
/** Stable render key (same-labeled chips stay independent). */
readonly occurrenceId: number
/** Placeholder offset in the draft (the chip occupies [offset, offset+1)). */
readonly offset: number
readonly label: string
/** Owner-resolution failure styling bit. */
readonly invalid: boolean
}
/**
* One plain-text reference range (decision 21): a `/name` or `@name` token
* whose name is on the trigger's lexicon. Pure derivation — editing the text
* out of match shape simply drops the range next scan.
*/
export interface TextRefRange {
readonly start: number
readonly end: number
readonly trigger: '/' | '@'
}
/** Decoration product: claim token range + chip instructions + text-ref ranges + the ghost hint. */
export interface DraftDecorations {
/** Claim token range while claimed/submitting and the prefix watch holds; null otherwise. */
readonly token: TokenRange | null
/** Chip render instructions in draft order (occurrence table is offset-sorted). */
readonly chips: readonly ChipRender[]
/** Scan-derived plain-text reference ranges (empty without a lexicon). */
readonly textRefs: readonly TextRefRange[]
/** Ghost hint shown while the claim's args are blank; null otherwise. */
readonly hint: string | null
}
/** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */
const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g
/**
* Scan the draft for plain-text reference tokens against the hot lexicons
* (decision 21). Word-boundary discipline: the trigger must sit at the draft
* start or after whitespace ('x/name' never matches); the name must be an
* exact lexicon member.
* @param draft - draft text.
* @param lexicon - per-trigger name lists (a missing trigger scans nothing).
* @returns matched ranges in draft order.
*/
export function scanTextRefs(
draft: string, lexicon: ReadonlyMap<'/' | '@', readonly string[]>,
): TextRefRange[] {
if (lexicon.size === 0 || draft === '') return []
const out: TextRefRange[] = []
TEXT_REF_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = TEXT_REF_RE.exec(draft)) !== null) {
const trigger = m[2] as '/' | '@'
const name = m[3] ?? ''
if (lexicon.get(trigger)?.includes(name)) {
const start = m.index + (m[1]?.length ?? 0)
out.push({ start, end: start + 1 + name.length, trigger })
}
}
return out
}
/** The empty lexicon (default: zero text-ref decorations, old call sites unchanged). */
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
/**
* Derive the mirror-layer decorations from the input state.
* @param state - published input state.
* @param lexicon - optional per-trigger reference lexicons (decision 21 scan).
* @returns token range, chip instructions, text-ref ranges, and the ghost hint.
*/
export function deriveDecorations(
state: InputState, lexicon: ReadonlyMap<'/' | '@', readonly string[]> = EMPTY_LEXICON,
): DraftDecorations {
const { draft, claim, phase, occurrences } = state
const claimActive = (phase === 'claimed' || phase === 'submitting')
&& claim !== undefined && draft.startsWith(claim.token)
const token: TokenRange | null = claimActive ? { start: 0, end: claim.token.length } : null
const chips = occurrences.map(o => ({
occurrenceId: o.occurrenceId,
offset: o.offset,
label: o.label,
invalid: o.invalid === true,
}))
const hint = claimActive && claim.hint !== undefined && draft.slice(claim.token.length).trim() === ''
? claim.hint
: null
return { token, chips, textRefs: scanTextRefs(draft, lexicon), hint }
}

View File

@@ -0,0 +1,446 @@
/**
* SessionInput shell over the pure input machine: the sole machine caller
* and effect executor. Owns the InputState store (machine state + the queue
* overlay), the notice channel, and the submit transaction plumbing
* (adjudicate via the session's SlashController; claim.submit; default
* sink). Package-private; the hub alone constructs it and wires the scoped
* event listeners onto it.
*/
import type { ClientContext, ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
ReferenceInsert, SlashController, TokenSpan,
} from '@deepseek-ai/dsh-client-ui-slash/client'
import type {
EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState,
PasteComponent, QueuedMessage, SessionInput, SubmitAttempt,
} from './contract.ts'
import { InputMachine } from './machine.ts'
/** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */
export interface PopupDismissFace {
dismiss(): void
}
/**
* Construction seams of one facade. The slash/popup faces are THUNKS: the
* shell is created inside the sessions provide materialization (before the
* scope record is queryable), where `slash.sessionOf`/`command.popupFor`
* cannot resolve yet — resolution defers to first interactive use.
*/
export interface SessionInputDeps {
/** Session-scope ctx handed to claim.submit transactions. */
actx: ClientContext
/** Enter adjudication face resolver; absent/undefined answer = every '/' line falls to the default sink. */
slash?: (() => SlashController | undefined) | undefined
/** PopupSelect shell face resolver (dismissal on submit lock / escape). */
popup?: (() => PopupDismissFace | undefined) | undefined
/** Queue read face; overlaid onto InputState.queue (absent = empty). */
queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
defaultSink(text: string, mode: 'queue' | 'steer'): void
}
/** Guard tier from the machine phase. */
function guardOf(phase: InputState['phase']): 'plain' | 'claimed' | 'frozen' {
switch (phase) {
case 'plain': return 'plain'
case 'claimed': return 'claimed'
default: return 'frozen' // adjudicating / submitting
}
}
const EMPTY_QUEUE: readonly QueuedMessage[] = []
/** No-pipeline lexicon: zero text-ref decorations. */
const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
/**
* The per-session input facade: scoped-event application verbs +
* setDraft/submit + the published InputState store.
*/
export class SessionInputShell implements SessionInput {
/** Published machine state + queue overlay (the InputZone currency source). */
readonly state: SnapshotStore<InputState>
/** Latest surfaced notice (null after clear); the wiring renders it beside the error strip. */
readonly notices: SnapshotStore<InputNotice | null> = createSnapshotStore<InputNotice | null>(null)
/** The public provide-channel action face (one stable identity per session — decision 20). */
readonly actions: InputActions = {
setDraft: (text) => { this.setDraft(text) },
submit: (mode) => { this.submit(mode) },
}
// Real wall clock: the typing-run merge window must actually expire in
// production (the machine's no-clock default is a constant for pure tests).
private readonly core = new InputMachine({ now: () => Date.now() })
private noticeSeq = 0
private lastDraft = ''
private disposed = false
/** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */
private mirrorFn: ((text: string) => void) | undefined
constructor(private readonly deps: SessionInputDeps) {
this.state = createSnapshotStore<InputState>(this.compose())
deps.queue?.subscribe(() => { this.publish() })
}
// ---- SessionInput face ----
/**
* Single draft write path (all mutation rides machine events).
* @param text - the full next draft.
* @param editRange - the DOM-observed edit shape, when the caller knows it
* (narrows the machine's occurrence math; absent → diff scan).
*/
setDraft(text: string, editRange?: EditRange): void {
this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) }))
}
/**
* Clear the draft as a successful-send commit: no undo unit is recorded and
* the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content
* (the command path gets the same discipline from submit-settled success).
*/
commitSend(): void {
this.run(this.core.dispatch({ type: 'send-committed' }))
}
/**
* Insert a newline at the selection as one machine transaction (the
* execCommand path is gone — a second undo history would fork).
* @param selection - current DOM selection in draft coordinates.
*/
newline(selection: EditSelection): void {
this.run(this.core.dispatch({ type: 'newline', selection }))
}
/** Undo the latest transaction (InputBar intercepts the platform chord). */
undo(): void {
this.run(this.core.dispatch({ type: 'undo' }))
}
/** Redo the latest undone transaction. */
redo(): void {
this.run(this.core.dispatch({ type: 'redo' }))
}
/**
* Paste text over the selection in one transaction, with any hot-snapshot
* sync matches componentized inside it.
* @param text - pasted plain text.
* @param selection - replaced selection in draft coordinates.
* @param components - sync-matched reference components (disjoint, inside `text`).
* @param generation - projection generation for late async-upgrade guards.
*/
pasteBegin(text: string, selection: EditSelection, components?: readonly PasteComponent[], generation?: number): void {
this.run(this.core.dispatch({
type: 'paste-begin', text, selection,
...(components !== undefined ? { components } : {}),
...(generation !== undefined ? { generation } : {}),
}))
}
/** End the live paste-match attempt (caret/selection ops and Slash updates the machine cannot see). */
invalidatePaste(): void {
this.run(this.core.dispatch({ type: 'invalidate-paste' }))
}
/**
* Enter adjudication + submit transaction + default sink. Effects fan out
* from the machine; this method only feeds the event. Lock entry
* (adjudicating/submitting) force-closes the transient layers: the popup
* dismisses and the menu tracks frozen.
* @param mode - default-sink mode (queue appends; steer interrupts).
*/
submit(mode: 'queue' | 'steer' = 'queue'): void {
this.run(this.core.dispatch({ type: 'enter', mode }))
const phase = this.snapshot.phase
if (phase === 'adjudicating' || phase === 'submitting') {
this.deps.popup?.()?.dismiss()
this.deps.slash?.()?.track(this.snapshot.draft, 0, { tier: 'frozen' }, this.snapshot.draftRev)
}
}
/**
* Feed a draft/caret change through trigger detection (guard derived from
* the machine phase).
* @param draft - live draft text.
* @param caret - caret position in draft coordinates.
*/
track(draft: string, caret: number): void {
this.deps.slash?.()?.track(draft, caret, { tier: guardOf(this.snapshot.phase) }, this.snapshot.draftRev)
}
/**
* Keyboard arbitration while the menu is open.
* @param key - the intercepted key.
* @param composing - IME composition guard state.
* @returns the menu's verdict; 'pass' when no pipeline is mounted.
*/
arbitrate(key: ArbitrateKey, composing: boolean): ArbitrateOutcome {
return this.deps.slash?.()?.arbitrate(key, composing) ?? 'pass'
}
/**
* Space adjudication over the controller's hot state.
* @returns true = a claim/insert was applied — the caller preventDefaults.
*/
space(): boolean {
const slash = this.deps.slash?.()
if (slash === undefined) return false
const consumed = slash.onSpace()
// Machine-driven draft replacement never passes through onChange, so
// re-track: the caret lands after the token, where detection sees
// whitespace and closes the menu.
if (consumed) {
const next = this.snapshot
slash.track(next.draft, next.draft.length, { tier: guardOf(next.phase) }, next.draftRev)
}
return consumed
}
/** Dismiss the popupSelect shell (any interaction outside the box). */
dismissPopup(): void {
this.deps.popup?.()?.dismiss()
}
/**
* Hot plain-text reference lexicons for the decoration scan (decision 21).
* @returns the controller's per-trigger aggregation; empty Map without a pipeline.
*/
lexicon(): ReadonlyMap<'/' | '@', readonly string[]> {
return this.deps.slash?.()?.lexicon() ?? EMPTY_LEXICON
}
/**
* Apply one command claim (scoped begin-command event listener body).
* @param claim - the command claim from the pick path.
* @param span - pick-time span snapshot.
* @returns whether the machine accepted (phase + span CAS passed and the draft mutated).
*/
beginCommand(claim: CommandClaim, span: TokenSpan): boolean {
const before = this.core.state.draftRev
this.run(this.core.dispatch({ type: 'begin-command', claim, span }))
return this.core.state.phase === 'claimed' && this.core.state.draftRev !== before
}
/**
* Apply one reference insertion (scoped insert-reference event listener body).
* @param ref - the reference insertion from the pick path.
* @param span - pick-time span snapshot.
* @returns whether the machine accepted.
*/
insertReference(ref: ReferenceInsert, span: TokenSpan): boolean {
const before = this.core.state.draftRev
this.run(this.core.dispatch({ type: 'insert-ref', reference: ref, span }))
return this.core.state.draftRev !== before
}
/**
* Consume one command token after business success (scoped consume-token
* event listener body). Span guard: revision CAS then splice; bare-token
* guard: trimmed-draft equality then clear.
* @param guard - exact span or bare-token guard.
* @returns whether the token was consumed.
*/
consumeToken(guard: ConsumeTokenRequest['guard']): boolean {
const snapshot = this.core.state
if (guard.kind === 'span') {
if (guard.span.draftRev !== snapshot.draftRev) return false
const draft = snapshot.draft
this.setDraft(draft.slice(0, guard.span.start) + draft.slice(guard.span.end))
return true
}
if (snapshot.draft.trim() !== guard.token) return false
this.setDraft('')
return true
}
/**
* Insert plain reference text over the pick-time span (scoped insert-text
* event listener body, decision 21). Same CAS-then-splice shape as the
* consume-token span branch: the machine sees an ordinary draft-changed
* transaction (one undo step), no occurrence is minted — the chip look is
* a scan-derived decoration, never state.
* @param text - the plain reference text to splice in (e.g. `/name `).
* @param span - pick-time span snapshot (draftRev CAS).
* @returns whether the text was applied.
*/
insertText(text: string, span: TokenSpan): boolean {
const snapshot = this.core.state
if (span.draftRev !== snapshot.draftRev) return false
const draft = snapshot.draft
this.setDraft(draft.slice(0, span.start) + text + draft.slice(span.end))
return true
}
/**
* Surface a notice from outside the machine (detached command results).
* @param level - severity tier.
* @param text - notice body.
*/
notify(level: 'info' | 'error', text: string): void {
this.noticeSeq += 1
this.notices.set({ level, text, seq: this.noticeSeq })
}
// ---- wiring-layer extras (not on the frozen SessionInput face) ----
/** Teardown: abort any in-flight attempt and stop accepting async settlements. */
dispose(): void {
this.disposed = true
this.run(this.core.dispatch({ type: 'release' }))
}
/** Read the live machine state (guard derivation reads here). */
get snapshot(): InputState {
return this.state.getSnapshot()
}
/**
* Bind the draft persistence mirror (chat store write). Adopt-on-bind: the
* store draft may hold a persisted value from a previous mount; the caller
* seeds it via setDraft BEFORE binding, and afterwards every machine-adopted
* draft mirrors out.
* @param write - store draft write.
* @returns the unbind disposer.
*/
bindMirror(write: (text: string) => void): () => void {
this.mirrorFn = write
return () => {
if (this.mirrorFn === write) this.mirrorFn = undefined
}
}
// ---- effect executor ----
private run(effects: readonly InputEffect[]): void {
for (const fx of effects) this.execute(fx)
this.publish()
}
private execute(fx: InputEffect): void {
switch (fx.type) {
case 'notice': {
this.noticeSeq += 1
this.notices.set({ level: fx.level, text: fx.text, seq: this.noticeSeq })
return
}
case 'adjudicate': {
this.adjudicate(fx.attempt, fx.draft)
return
}
case 'begin-submit': {
this.beginSubmit(fx.attempt, fx.claim, fx.args)
return
}
case 'default-sink': {
this.sinkSerialized(fx.draft, fx.mode)
return
}
default:
return // machine-internal effects (mirror rides publish)
}
}
/**
* Prompt serialization before the sink (design §3.12): expand each
* placeholder to its owner's model form via the session controller's
* codec routing. Owner missing / serialize failure / disposal blocks the
* send — notice + draft and chips retained, never a silent downgrade to
* the clipboard text. Chip-free drafts skip the async detour.
*/
private sinkSerialized(draft: string, mode: 'queue' | 'steer'): void {
const occurrences = this.core.state.occurrences
if (occurrences.length === 0) {
this.deps.defaultSink(draft.trim(), mode)
return
}
const slash = this.deps.slash?.()
const controller = new AbortController()
void Promise.all(occurrences.map(async (o) => {
if (slash === undefined) throw new Error(`no serializer for reference source "${o.source}"`)
return { offset: o.offset, text: await slash.serializeReference(o.source, o.ref, controller.signal) }
})).then(
(parts) => {
if (this.disposed) return
// Splice model forms over their placeholders (offsets are draft-time;
// parts arrive offset-sorted since the table is).
let out = ''
let cursor = 0
for (const part of parts) {
out += draft.slice(cursor, part.offset) + part.text
cursor = part.offset + 1
}
out += draft.slice(cursor)
this.deps.defaultSink(out.trim(), mode)
},
(error: unknown) => {
controller.abort()
if (this.disposed) return
const message = error instanceof Error ? error.message : String(error)
this.notify('error', message)
},
)
}
/** Enter adjudication: poll the session controller; failure = notice + draft retained (never a silent downgrade). */
private adjudicate(attempt: SubmitAttempt, draft: string): void {
const slash = this.deps.slash?.()
if (slash === undefined) {
// No pipeline mounted: the '/' line is an ordinary message.
this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
return
}
slash.adjudicate(draft.trim(), attempt.signal).then(
(outcome: PickOutcome) => {
if (this.dead(attempt)) return
this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome }))
},
(error: unknown) => {
if (this.dead(attempt)) return
const message = error instanceof Error ? error.message : String(error)
this.run(this.core.dispatch({ type: 'adjudication-failed', attempt, message }))
},
)
}
/** The submit transaction: claim.submit against the session scope; ok maps from the outcome kind. */
private beginSubmit(attempt: SubmitAttempt, claim: CommandClaim, args: string): void {
Promise.resolve()
.then(() => claim.submit(args, this.deps.actx))
.then(
(outcome) => {
if (this.dead(attempt)) return
this.run(this.core.dispatch({
type: 'submit-settled', attempt, ok: outcome.kind === 'success', outcome,
}))
},
(error: unknown) => {
if (this.dead(attempt)) return
const message = error instanceof Error ? error.message : String(error)
this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message }))
},
)
}
/** Late-settlement guard: superseded attempts and disposed facades drop silently. */
private dead(attempt: SubmitAttempt): boolean {
return this.disposed || attempt.signal.aborted
}
private compose(): InputState {
const core = this.core.state
return { ...core, queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE }
}
private publish(): void {
const next = this.compose()
this.state.set(next)
if (next.draft !== this.lastDraft) {
this.lastDraft = next.draft
this.mirrorFn?.(next.draft)
}
}
}

View File

@@ -0,0 +1,146 @@
/**
* InputHub: the InputService implementation (`ctx.conversation.input`) — one
* SessionInputShell per session, created inside the sessions provide
* materialization (decision 19: the 'input' standard-kit entry IS the
* creation trigger) and torn down by the scope disposer (instance-and-scope
* share one lifecycle). The hub registers the three scoped input-mutation
* listeners on each session's actx (the sole consumer side of the ui-slash
* bail events) and owns the default-sink choreography: every session is a
* real host entity, so the sink is one unconditional prompt path.
*/
import type { ClientContext, Session, SessionBinding, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashController, SlashServiceContract } from '@deepseek-ai/dsh-client-ui-slash/client'
import type {} from '@deepseek-ai/dsh-client-ui-slash/client'
import { queueReadFaceOf } from '../queue/store.ts'
import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts'
import type { PopupDismissFace } from './facade.ts'
import { SessionInputShell } from './facade.ts'
/** Structural command face for per-session popup resolution. */
interface CommandFace {
popupFor(actx: ClientContext): PopupDismissFace
}
/** Session-addressed input facade registry (InputService face + composer-layer extras). */
export class InputHub implements InputService {
private readonly shells = new Map<SessionId, SessionInputShell>()
/** @param ctx - client root context (services resolved lazily per call — boot order stays free). */
constructor(private readonly rootCtx: ClientContext) {}
/**
* Resolve the facade for one session-scope ctx (InputService face).
* @param actx - session-scope context.
* @returns the resident per-session facade.
*/
for(actx: ClientContext): SessionInput {
const sessions = this.sessions()
const id = sessions.scopeOf(actx)
if (id === undefined) throw new Error('conversation.input.for requires a session scope')
return this.shell(id)
}
/**
* Resident shell for one session binding — the provide-channel entry
* (called during scope materialization, BEFORE the scope record is
* queryable, hence binding-fed and hence the thunked slash/popup deps).
* Wires the scoped event listeners + teardown into the session scope.
* @param binding - session assembly handle.
* @returns the shell.
*/
shellFor(binding: SessionBinding): SessionInputShell {
const existing = this.shells.get(binding.sessionId)
if (existing !== undefined) return existing
const { sessionId: id, session, ctx: actx } = binding
const shell = new SessionInputShell({
actx,
slash: () => this.controller(actx),
popup: () => this.popup(actx),
queue: queueReadFaceOf(session),
defaultSink: (text, mode) => { this.sink(session, text, mode) },
})
this.shells.set(id, shell)
// The one teardown axis: listeners, shell, and map entries all ride the
// scope fiber (decision 12 — nothing here outlives the scope).
actx.effect(() => {
const offs = [
actx.on('slash/input-begin-command', req =>
shell.beginCommand(req.claim, req.span) ? true : undefined),
actx.on('slash/input-insert-reference', req =>
shell.insertReference(req.reference, req.span) ? true : undefined),
actx.on('slash/input-consume-token', req =>
shell.consumeToken(req.guard) ? true : undefined),
actx.on('slash/input-insert-text', req =>
shell.insertText(req.text, req.span) ? true : undefined),
]
return () => {
for (const off of offs) off()
shell.dispose()
this.shells.delete(id)
}
}, 'conversation.input: session shell')
return shell
}
/**
* Resident shell by session id (service-face path; the provide channel has
* normally created it already — this covers direct id-addressed access).
* @param id - session id.
* @returns the shell.
*/
shell(id: SessionId): SessionInputShell {
const existing = this.shells.get(id)
if (existing !== undefined) return existing
const binding = this.sessions().binding(id)
if (binding === undefined) throw new Error(`conversation.input: session "${id}" resolved no binding`)
return this.shellFor(binding)
}
/**
* The InputBar-exclusive keyboard command face (decision 20): the shell
* satisfies it structurally; package-internal — handed through the
* composer-bar entry's inject, never across a plugin boundary.
* @param id - session id.
* @returns the shell as the keyboard face.
*/
keyboard(id: SessionId): ComposerKeyboard {
return this.shell(id)
}
/**
* Default sink: optimistic clear + prompt. The session is always a real
* host entity (materialized when its workspace was picked), so there is
* exactly one path; a failed first prompt is an ordinary prompt failure
* (error strip via promptError, draft restored only while untouched).
*/
private sink(session: Session, text: string, mode: 'queue' | 'steer'): void {
if (text === '') return
const shell = this.shells.get(session.sessionId)
// Commit, not an editable clear: undo must not resurrect sent content.
shell?.commitSend()
void session.prompt([{ type: 'text', text }], mode).then(
(result) => {
if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text)
},
() => {
if (shell?.snapshot.draft === '') shell.setDraft(text)
},
)
}
private controller(actx: ClientContext): SlashController | undefined {
const slash = this.rootCtx.get('slash') as SlashServiceContract | undefined
return slash?.sessionOf(actx)
}
private popup(actx: ClientContext): PopupDismissFace | undefined {
const command = this.rootCtx.get('command') as CommandFace | undefined
return command?.popupFor(actx)
}
private sessions(): SessionsService {
const sessions = this.rootCtx.get('sessions')
if (sessions === undefined) throw new Error('conversation.input: sessions service unavailable')
return sessions
}
}

View File

@@ -0,0 +1,570 @@
/**
* InputMachine: the pure per-session input state machine (design §9.1, eng.
* plan §3.9-3.12). Events in, effects out; zero React / DOM / cordis / ambient
* clock. Package-private — the SessionInput shell is the only caller and the
* sole executor of the returned effects.
*
* Draft truth: the draft string holds one U+FFFC placeholder per chip; the
* occurrence table carries identity and the owner's cached projections. Every
* draft mutation is one transaction — draft edit, occurrence reconciliation,
* and undo-log push are atomic inside dispatch() — and bumps draftRev, which
* is what lets span CAS reduce to a revision-equality check: equal rev ⟹
* identical draft ⟹ identical span content. Callers observe mutation success
* as a draftRev advance (begin-command / insert-ref / consume-token /
* paste-upgrade all answer their bail events this way).
*/
import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-slash/client'
import type {
ConsumeTokenGuard, EditRange, EditSelection, InputEffect, InputEvent, InputMachineOptions,
InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt,
} from './contract.ts'
/** The object-replacement character backing every chip occurrence in the draft. */
export const PLACEHOLDER = ''
/** The machine never writes the queue; the wiring layer overlays the T9 store projection. */
const EMPTY_QUEUE: InputState['queue'] = []
/** Undo ring depth (design §9.1: bounded self-managed transaction log). */
const LOG_LIMIT = 100
/** Exhaustiveness backstop for the closed InputEvent / guard unions. */
function unreachable(value: never): never {
throw new Error(`unreachable input event: ${JSON.stringify(value)}`)
}
/**
* Strip the claim token off a draft to yield submit args. Leading whitespace
* (incl. newlines — leading-trigger trim) is tolerated; a bare `/name`
* missing the token's trailing separator yields empty args. Exactly one
* separator char is consumed; the remainder — newlines included — stays
* verbatim (`/goal x\ny` → `x\ny`).
*/
function argsAfter(draft: string, token: string): string {
const s = draft.trimStart()
if (s.startsWith(token)) return s.slice(token.length)
const base = token.trimEnd()
if (s.startsWith(base)) {
const rest = s.slice(base.length)
return /^\s/.test(rest) ? rest.slice(1) : rest
}
return ''
}
/**
* Prefix/suffix common-scan recovering the edit range between two drafts
* (used when the wiring layer cannot supply one from the DOM event).
*/
function diffEdit(prev: string, next: string): EditRange {
let p = 0
const maxCommon = Math.min(prev.length, next.length)
while (p < maxCommon && prev[p] === next[p]) p += 1
let s = 0
const maxSuffix = maxCommon - p
while (s < maxSuffix && prev[prev.length - 1 - s] === next[next.length - 1 - s]) s += 1
return { start: p, end: prev.length - s, insertedLength: next.length - s - p }
}
/**
* Expand the draft's placeholders into their occurrences' clipboard text
* (decision 16: the persistence mirror and clipboard both write this
* projection — U+FFFC never leaves the machine). Table order is offset
* order, so one linear walk pairs placeholders with entries.
* @param state - published input state.
* @returns the plain-text projection of the draft.
*/
export function projectClipboard(state: Pick<InputState, 'draft' | 'occurrences'>): string {
const { draft, occurrences } = state
if (occurrences.length === 0) return draft
let out = ''
let cursor = 0
for (const o of occurrences) {
out += draft.slice(cursor, o.offset) + o.clipboardText
cursor = o.offset + 1
}
return out + draft.slice(cursor)
}
/** One undo unit: snapshots taken before the transaction applied. */
interface Transaction {
readonly draftBefore: string
readonly occurrencesBefore: readonly Occurrence[]
/** Pre-edit selection when the triggering event carried one (shell caret restore on undo). */
readonly selectionBefore?: EditSelection
}
/**
* Pure input machine, one instance per session (per-session isolation is by
* construction). The machine constructs one AbortController per SubmitAttempt
* at enter time and aborts it itself on release; the shell never aborts, it
* only observes attempt.signal on its adjudicate/submit promises. Stale
* attempts (any adjudicated / adjudication-failed / submit-settled whose seq
* is not the in-flight one) are dropped: same state, zero effects.
*/
export class InputMachine {
private draft = ''
private draftRev = 0
private phase: InputState['phase'] = 'plain'
private claim: CommandClaim | undefined
private occurrences: readonly Occurrence[] = []
private occurrenceSeq = 0
private seq = 0
private inflight: {
readonly attempt: SubmitAttempt
readonly controller: AbortController
readonly mode: 'queue' | 'steer'
} | undefined
private log: Transaction[] = []
private redoStack: Transaction[] = []
/** Open single-char typing run: the next contiguous char within the window coalesces. */
private typingRun: { readonly end: number; readonly at: number } | undefined
private paste: PasteAttemptState | undefined
private pasteSeq = 0
private readonly mergeWindowMs: number
private readonly now: () => number
constructor(options: InputMachineOptions = {}) {
this.mergeWindowMs = options.mergeWindowMs ?? 1000
this.now = options.now ?? (() => 0)
}
/** Read-only snapshot of the machine state (queue always empty at this tier). */
get state(): InputState {
const c = this.claim
return {
draft: this.draft,
draftRev: this.draftRev,
phase: this.phase,
...(c ? { claim: { token: c.token, ...(c.hint !== undefined ? { hint: c.hint } : {}) } } : {}),
occurrences: this.occurrences,
...(this.paste !== undefined ? { paste: this.paste } : {}),
queue: EMPTY_QUEUE,
}
}
/**
* Feed one event through the machine.
* @param ev - Input event; the single write path for all input state.
* @returns Effects for the shell to execute in order; empty on no-ops, locks, and dropped stale events.
*/
dispatch(ev: InputEvent): readonly InputEffect[] {
switch (ev.type) {
case 'draft-changed': return this.onDraftChanged(ev.draft, ev.editRange)
case 'newline': return this.onNewline(ev.selection)
case 'begin-command': return this.onBeginCommand(ev.claim, ev.span)
case 'insert-ref': return this.onInsertRef(ev.reference, ev.span)
case 'consume-token': return this.onConsumeToken(ev.guard)
case 'set-invalid': return this.onSetInvalid(ev.invalidIds)
case 'undo': return this.onUndo()
case 'redo': return this.onRedo()
case 'paste-begin': return this.onPasteBegin(ev.text, ev.selection, ev.components, ev.generation)
case 'paste-upgrade': return this.onPasteUpgrade(ev.attemptId, ev.span, ev.reference)
case 'invalidate-paste': {
this.paste = undefined
return []
}
case 'enter': return this.onEnter(ev.mode)
case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
case 'submit-settled': return this.onSubmitSettled(ev)
case 'send-committed': return this.onSendCommitted()
case 'release': return this.onRelease()
default: return unreachable(ev)
}
}
// ---- transaction plumbing ----
/** Adopt a new draft: bump the revision (the span-CAS invalidation point). */
private adopt(draft: string): void {
this.draft = draft
this.draftRev += 1
}
/** Push one undo unit (before-state), trim the ring, and cut the redo chain. */
private pushTxn(selectionBefore?: EditSelection): void {
this.log.push({
draftBefore: this.draft,
occurrencesBefore: this.occurrences,
...(selectionBefore !== undefined ? { selectionBefore } : {}),
})
if (this.log.length > LOG_LIMIT) this.log.shift()
this.redoStack = []
}
/**
* Reconcile the occurrence table with one edit (old-draft coordinates):
* entries past the range shift by the length delta; entries whose
* placeholder sits inside the replaced range go away whole (design §9.1: a
* deletion/replacement intersecting a placeholder acts on the whole chip).
*/
private reconcile(range: EditRange): void {
const delta = range.insertedLength - (range.end - range.start)
const kept: Occurrence[] = []
for (const o of this.occurrences) {
if (o.offset < range.start) kept.push(o)
else if (o.offset >= range.end) kept.push(delta === 0 ? o : { ...o, offset: o.offset + delta })
}
this.occurrences = kept
}
/** Claimed integrity watch: any mutation that breaks the token prefix releases the claim. */
private watchClaim(): void {
if (this.phase === 'claimed' && this.claim !== undefined && !this.draft.startsWith(this.claim.token)) {
this.phase = 'plain'
this.claim = undefined
}
}
/** Mint one occurrence at a draft offset. */
private mint(reference: ReferenceInsert, offset: number): Occurrence {
this.occurrenceSeq += 1
return {
occurrenceId: this.occurrenceSeq,
source: reference.source,
ref: reference.ref,
offset,
label: reference.label,
clipboardText: reference.clipboardText,
}
}
/** Splice minted entries into the offset-sorted table. */
private withMinted(minted: readonly Occurrence[]): void {
if (minted.length === 0) return
this.occurrences = [...this.occurrences, ...minted].sort((a, b) => a.offset - b.offset)
}
// ---- draft transactions ----
private onDraftChanged(draft: string, editRange?: EditRange): InputEffect[] {
if (draft === this.draft) return []
const range = editRange ?? diffEdit(this.draft, draft)
// Single-char typing coalesces into the open run while contiguous and
// inside the merge window; anything else opens its own transaction.
const typing = range.start === range.end && range.insertedLength === 1
const at = this.now()
const run = this.typingRun
const merges = typing && run !== undefined && run.end === range.start && at - run.at <= this.mergeWindowMs
if (!merges) this.pushTxn({ start: range.start, end: range.end })
this.typingRun = typing ? { end: range.start + 1, at } : undefined
this.reconcile(range)
this.adopt(draft)
this.watchClaim()
this.paste = undefined
return []
}
/** F1: caret newline as an ordinary machine transaction (execCommand path removed). */
private onNewline(selection: EditSelection): InputEffect[] {
const { start, end } = selection
if (start < 0 || start > end || end > this.draft.length) return []
this.pushTxn(selection)
this.typingRun = undefined
this.reconcile({ start, end, insertedLength: 1 })
this.adopt(this.draft.slice(0, start) + '\n' + this.draft.slice(end))
this.watchClaim()
this.paste = undefined
return []
}
/** Span CAS: revision equality (content identity follows) plus bounds sanity. */
private casOk(span: TokenSpan): boolean {
return span.draftRev === this.draftRev
&& span.start >= 0 && span.start <= span.end && span.end <= this.draft.length
}
private onBeginCommand(claim: CommandClaim, span: TokenSpan): InputEffect[] {
if (this.phase !== 'plain' && this.phase !== 'claimed') return []
// Leading-trigger contract: only whitespace may precede the span; the
// whitespace prefix is dropped so the claimed watch (startsWith) holds.
if (!this.casOk(span) || this.draft.slice(0, span.start).trim() !== '') return []
this.pushTxn()
this.typingRun = undefined
this.reconcile({ start: 0, end: span.end, insertedLength: claim.token.length })
this.adopt(claim.token + this.draft.slice(span.end))
this.claim = claim
this.phase = 'claimed'
this.paste = undefined
return []
}
private onInsertRef(reference: ReferenceInsert, span: TokenSpan): InputEffect[] {
if (this.phase !== 'plain' && this.phase !== 'claimed') return []
if (!this.casOk(span)) return []
this.replaceSpanWithChip(reference, span)
this.paste = undefined
return []
}
/** Shared chip-insertion transaction: replace [span) with one placeholder occurrence (insert-ref and paste-upgrade both land here). */
private replaceSpanWithChip(reference: ReferenceInsert, span: TokenSpan): void {
this.pushTxn()
this.typingRun = undefined
this.reconcile({ start: span.start, end: span.end, insertedLength: 1 })
this.withMinted([this.mint(reference, span.start)])
this.adopt(this.draft.slice(0, span.start) + PLACEHOLDER + this.draft.slice(span.end))
this.watchClaim()
}
/**
* Guarded token deletion after business success (popup settle / menu-pick
* execute). No effect signals success: the caller reads the draftRev
* advance off the published state (same currency as the other bail verbs).
*/
private onConsumeToken(guard: ConsumeTokenGuard): InputEffect[] {
if (this.phase !== 'plain' && this.phase !== 'claimed') return []
switch (guard.kind) {
case 'span': {
const span = guard.span
if (!this.casOk(span) || span.start === span.end) return []
this.pushTxn()
this.typingRun = undefined
this.reconcile({ start: span.start, end: span.end, insertedLength: 0 })
this.adopt(this.draft.slice(0, span.start) + this.draft.slice(span.end))
this.watchClaim()
this.paste = undefined
return []
}
case 'bare-token': {
if (guard.token === '' || this.draft.trim() !== guard.token) return []
this.pushTxn()
this.typingRun = undefined
this.occurrences = []
this.adopt('')
this.watchClaim()
this.paste = undefined
return []
}
default: return unreachable(guard)
}
}
/**
* Owner-resolution style bits: exactly the listed occurrences render
* invalid. Not a transaction — the draft, revision, and undo log are
* untouched (design §9.1: invalidation never deletes or rewrites chips).
*/
private onSetInvalid(invalidIds: readonly number[]): InputEffect[] {
const ids = new Set(invalidIds)
if (!this.occurrences.some(o => (o.invalid === true) !== ids.has(o.occurrenceId))) return []
this.occurrences = this.occurrences.map((o) => {
const invalid = ids.has(o.occurrenceId)
if ((o.invalid === true) === invalid) return o
const { invalid: _drop, ...rest } = o
return invalid ? { ...rest, invalid: true } : rest
})
return []
}
// ---- undo / redo ----
private onUndo(): InputEffect[] {
const entry = this.log.pop()
if (entry === undefined) return []
this.redoStack.push({ draftBefore: this.draft, occurrencesBefore: this.occurrences })
this.occurrences = entry.occurrencesBefore
this.adopt(entry.draftBefore)
this.watchClaim()
this.typingRun = undefined
this.paste = undefined
return []
}
private onRedo(): InputEffect[] {
const entry = this.redoStack.pop()
if (entry === undefined) return []
// Manual log push: pushTxn would cut the redo chain being walked.
this.log.push({ draftBefore: this.draft, occurrencesBefore: this.occurrences })
if (this.log.length > LOG_LIMIT) this.log.shift()
this.occurrences = entry.occurrencesBefore
this.adopt(entry.draftBefore)
this.watchClaim()
this.typingRun = undefined
this.paste = undefined
return []
}
// ---- paste plane ----
/**
* Paste as one transaction: the text (U+FFFC-sanitized) replaces the
* selection; hot-snapshot sync matches componentize inside the SAME
* transaction (one undo returns to pre-paste); a match attempt opens for
* the async remainder while the phase still accepts reference mutations.
*/
private onPasteBegin(
rawText: string, selection: EditSelection,
components: readonly PasteComponent[] = [], generation = 0,
): InputEffect[] {
const { start, end } = selection
if (start < 0 || start > end || end > this.draft.length) return []
const text = rawText.split(PLACEHOLDER).join('')
this.pushTxn(selection)
this.typingRun = undefined
// Componentize: replace each matched token range (paste-text coordinates,
// disjoint by contract) with a placeholder while assembling the insert.
const sorted = [...components].sort((a, b) => a.start - b.start)
const minted: Occurrence[] = []
let inserted = ''
let cursor = 0
for (const c of sorted) {
inserted += text.slice(cursor, c.start)
minted.push(this.mint(c.reference, start + inserted.length))
inserted += PLACEHOLDER
cursor = c.end
}
inserted += text.slice(cursor)
this.reconcile({ start, end, insertedLength: inserted.length })
this.withMinted(minted)
this.adopt(this.draft.slice(0, start) + inserted + this.draft.slice(end))
this.watchClaim()
if (this.phase === 'plain' || this.phase === 'claimed') {
this.pasteSeq += 1
this.paste = {
attemptId: this.pasteSeq,
insertedRange: { start, end: start + inserted.length },
generation,
}
} else {
this.paste = undefined
}
return []
}
/**
* Async match landed: upgrade one pasted token to a chip as an INDEPENDENT
* transaction (undo #1 → the token text, undo #2 → pre-paste). The attempt
* stays current — later tokens re-CAS against the advanced draftRev.
*/
private onPasteUpgrade(attemptId: number, span: TokenSpan, reference: ReferenceInsert): InputEffect[] {
const attempt = this.paste
if (attempt === undefined || attempt.attemptId !== attemptId) return []
if (this.phase !== 'plain' && this.phase !== 'claimed') return []
if (!this.casOk(span) || span.start === span.end) return []
this.replaceSpanWithChip(reference, span)
this.paste = {
...attempt,
insertedRange: { start: attempt.insertedRange.start, end: attempt.insertedRange.end + 1 - (span.end - span.start) },
}
return []
}
// ---- submit plane ----
/** Mint the next SubmitAttempt and take the in-flight slot. */
private beginAttempt(mode: 'queue' | 'steer'): SubmitAttempt {
const controller = new AbortController()
this.seq += 1
const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft }
this.inflight = { attempt, controller, mode }
return attempt
}
private onEnter(mode: 'queue' | 'steer'): InputEffect[] {
if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
if (this.phase === 'claimed' && this.claim !== undefined) {
const attempt = this.beginAttempt(mode)
this.phase = 'submitting'
this.paste = undefined
return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }]
}
const trimmed = this.draft.trim()
if (trimmed === '') return []
this.paste = undefined
if (trimmed.startsWith('/')) {
const attempt = this.beginAttempt(mode)
this.phase = 'adjudicating'
return [{ type: 'adjudicate', attempt, draft: this.draft }]
}
return [{ type: 'default-sink', draft: this.draft, mode }]
}
private onAdjudicated(attempt: SubmitAttempt, outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome']): InputEffect[] {
const flight = this.inflight
if (this.phase !== 'adjudicating' || flight === undefined || flight.attempt.seq !== attempt.seq) return []
if (outcome !== undefined && outcome !== 'handled' && 'claim' in outcome) {
this.claim = outcome.claim
this.phase = 'submitting'
return [{
type: 'begin-submit',
attempt,
claim: outcome.claim,
args: argsAfter(attempt.draftSnapshot, outcome.claim.token),
}]
}
// 'handled' (source dealt internally), {insert} (no enter-time span
// semantics), or a miss: all land plain; only the miss flows to the sink.
this.inflight = undefined
this.phase = 'plain'
return outcome === undefined
? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: flight.mode }]
: []
}
private onAdjudicationFailed(attempt: SubmitAttempt, message: string): InputEffect[] {
if (this.phase !== 'adjudicating' || this.inflight?.attempt.seq !== attempt.seq) return []
this.inflight = undefined
this.phase = 'plain'
// Draft retained: warmup failure never silently downgrades to a prompt.
return [{ type: 'notice', level: 'error', text: message }]
}
private onSubmitSettled(ev: Extract<InputEvent, { type: 'submit-settled' }>): InputEffect[] {
const flight = this.inflight
if (this.phase !== 'submitting' || flight === undefined || flight.attempt.seq !== ev.attempt.seq) return []
this.inflight = undefined
if (ev.ok) {
this.phase = 'plain'
this.claim = undefined
this.occurrences = []
this.adopt('')
// Committed content is gone for good: undo must not resurrect a sent draft.
this.log = []
this.redoStack = []
this.typingRun = undefined
this.paste = undefined
return ev.outcome?.text !== undefined
? [{ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text }]
: []
}
const text = ev.message ?? ev.outcome?.text ?? 'command failed'
// Drift guard: keep the enter-time draft (same claim) only while the
// live draft still equals it; user input typed during flight wins.
// Claimed re-entry additionally requires the watch to hold — an
// enter-path snapshot may carry leading whitespace the token never had.
if (this.draft === flight.attempt.draftSnapshot
&& this.claim !== undefined && this.draft.startsWith(this.claim.token)) {
this.phase = 'claimed'
return [{ type: 'notice', level: 'error', text }]
}
this.phase = 'plain'
this.claim = undefined
return [{ type: 'notice', level: 'error', text }]
}
/** Ordinary send accepted: clear as a commit (no undo unit; sent content
* must not be resurrectable — same discipline as submit-settled success). */
private onSendCommitted(): InputEffect[] {
this.claim = undefined
this.occurrences = []
this.adopt('')
this.log = []
this.redoStack = []
this.typingRun = undefined
this.paste = undefined
return []
}
private onRelease(): InputEffect[] {
if (this.inflight !== undefined) {
this.inflight.controller.abort()
this.inflight = undefined
}
this.phase = 'plain'
this.claim = undefined
this.typingRun = undefined
this.paste = undefined
return []
}
}

View File

@@ -0,0 +1,30 @@
/* Neutral stacked strip above the input (queue rows are informational, not a warn state). */
.dock {
margin: 6px 0;
padding: 8px 12px;
border: 1px solid var(--dsw-alias-separator-primary);
border-radius: 10px;
background: var(--dsw-alias-bg-base);
}
.title {
font-size: 12px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
}
.list {
margin: 4px 0 0;
padding: 0;
list-style: none;
}
.row {
overflow: hidden;
font-size: 12px;
line-height: 20px;
color: var(--dsw-alias-label-primary);
white-space: nowrap;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,48 @@
// Read-only queue dock entry (design v4 queue cut 1): renders the session's
// inbox mirror (session/queued frames + connect baseline) as one stacked
// strip above the input. No per-row actions — the host inbox has no
// addressable entries yet (queue cut 2 ledger).
//
// The 'conversation.input.dock' SlotMap declaration lives in
// ../contract/slots.ts beside the other input-region slots.
import type { Context } from 'cordis'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-runtime/client'
import css from './QueueDock.module.css'
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type QueueDockProps = PropsRuntime<'conversation.input.dock'>
/** Queue strip: one preview line per queued message; renders null when the queue is empty. */
export function QueueDock({ useSession }: QueueDockProps) {
const queue = useSession(s => s.queue)
if (queue.length === 0) return null
return (
<div className={css.dock}>
<div className={css.title}>已排队 {queue.length} 条</div>
<ul className={css.list}>
{queue.map(row => (
<li key={row.key} className={css.row}>{row.preview}</li>
))}
</ul>
</div>
)
}
/**
* The dock entry as a plain registrant plugin (bash-sample posture).
* `inject: ['conversation']` is the ordering seam: the conversation service
* mounts after ui-conversation's slot registrations, so the
* 'conversation.input.dock' declaration is on the ledger by then.
*/
export const queueDockEntry = {
name: 'conversation-queue-dock',
inject: ['slots', 'conversation'],
/**
* Register the queue strip into the input dock (list entry, order 0).
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.input.dock', id: 'queue', order: 0 }, QueueDock)
},
}

View File

@@ -0,0 +1,24 @@
/**
* Queue read face for the InputState.queue projection (frozen contract in
* ../input/contract.ts): a uSES-compatible observable over one session's
* queue rows. The Session snapshot already keeps the queue array
* reference-stable across unrelated snapshot swaps, so this is a pure
* projection — no second store, no copy.
*/
import type { ObservableSnapshot, Session } from '@deepseek-ai/dsh-client-runtime/client'
import type { QueuedMessage } from '../input/contract.ts'
/**
* Project a session's queue rows as a bare observable (subscribe/getSnapshot).
* The wiring layer (T5) overlays this onto InputState.queue; the runtime
* QueuedMessage and the input-contract QueuedMessage are structurally the
* same frozen shape ({key, preview}).
* @param session - the resident session instance.
* @returns the queue read face (snapshot reference stable while the queue is unchanged).
*/
export function queueReadFaceOf(session: Session): ObservableSnapshot<readonly QueuedMessage[]> {
return {
getSnapshot: () => session.getSnapshot().queue,
subscribe: fn => session.subscribe(fn),
}
}

View File

@@ -1,5 +1,5 @@
/**
* Scope-addressed conversation send, cancel, history, and retained-prompt orchestration.
* Scope-addressed conversation send, cancel, and history orchestration.
*
* Scope addressing rides the cordis Service tracker: property access through
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
@@ -13,15 +13,23 @@ import type { Context } from 'cordis'
// error, so scope resolution goes through the sessions service (scopeOf
// method) instead of the standalone helper.
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { InputHub } from './input/hub.ts'
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
export class ConversationService extends Service {
/** The per-session input machine registry (InputService face, design §5.2). */
readonly input: InputHub
/**
* @param ctx - owning root context (the plugin apply context; the service
* registers itself and follows that fiber's lifetime).
* @param config - the shared InputHub constructed by the plugin apply
* (shared with the slot inject factories); absent = own instance
* (object-layer tests that never touch slots).
*/
constructor(ctx: Context) {
constructor(ctx: Context, config?: { input?: InputHub }) {
super(ctx, 'conversation')
this.input = config?.input ?? new InputHub(ctx)
}
/**
@@ -49,19 +57,6 @@ export class ConversationService extends Service {
await this.scopedSession('loadOlder').loadOlder()
}
/**
* Update the scoped Session's retained pending prompt.
* @param text - exact controlled-input value to retain.
*/
updatePendingPrompt(text: string): void {
this.scopedSession('updatePendingPrompt').updatePendingPrompt(text)
}
/** Retry the scoped Session's retained pending prompt. */
retryPendingPrompt(): void {
this.scopedSession('retryPendingPrompt').retryPendingPrompt()
}
/** Resolve the caller scope's Session or throw on root contexts. */
private scopedSession(op: string): Session {
const id = this.scopeId(op)

View File

@@ -127,3 +127,23 @@
flex-direction: column;
min-height: 0;
}
/* Composer stack: dock strips above the input card (design §6 MIX order). */
.composerStack {
display: flex;
flex-direction: column;
}
/* Hero phase: the composer stack (hero chrome + workspace row + card) is
flex-centered in the column; composer phase docks it at the bottom. Flex,
NOT absolute+transform: a transform would make this box the containing
block for position:fixed descendants (pickers/modals), shrinking them. */
.composerHero {
align-self: center;
width: min(776px, calc(100% - 48px));
z-index: 1;
}
.root[data-phase='hero'] {
justify-content: center;
}

View File

@@ -1,182 +1,92 @@
// ConversationRoot: the conversation slot's skeleton (figma Header 39:27730 +
// Tab_Group + view area + composer). Pure component — everything arrives via
// props: the framework standard kit (useSession/sessionId/useSessions), the
// declared chat store's useStore/actions, the injected business face, and the
// renderSlot share for the declared 'conversation.view' child slot (views are
// slot entries; the active one renders via the list `only` filter) plus the
// renderSlotChain share for the 'conversation.composer' takeover chain.
// Breadcrumbs derive from useSessions with a pure parentId walk; the active
// view id lives in the chat store's `view` field (per-session by store scope).
// 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.
import { useSyncExternalStore } from 'react'
import { useRef, useState } from 'react'
import clsx from 'clsx'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps } from '../contract/slots.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import { EmptyHero, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
import { DisabledInputBar } from './DisabledInputBar.tsx'
import css from './ConversationRoot.module.css'
/** Full props = the automatic shares & injected share — composed by reference
* from the contract, never re-typed here (share-ownership rule). */
/** Full props composed from the slot contract. */
export type ConversationRootProps = ConversationSlotProps
/** Breadcrumb chain: walk parentId links (root ancestor first, self last;
* empty when unknown; a broken link stops the walk). Pure twin of the
* sessions service's ancestry — components derive, they don't subscribe. */
function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] {
const chain: SessionSummary[] = []
let cursor: SessionId | undefined = id
while (cursor !== undefined) {
const summary: SessionSummary | undefined = list.byId[cursor]
if (summary === undefined || chain.includes(summary)) break
chain.unshift(summary)
cursor = summary.parentId
}
return chain
}
export function ConversationRoot({
sessionId, useSession, useSessions, useWorkspaces, useStore, actions, renderSlot, renderSlotChain,
views, send, stop, open, updateSessionPrompt, retrySessionPrompt,
sessionId, useSession, useSessions, useWorkspaces, useInput,
renderSlot, renderSlotChain, selectWorkspace,
}: ConversationRootProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
// The store's persisted view id may be stale (view plugin unloaded); the
// slot ledger is the runtime validator — unknown ids fall to the first view.
const activeId = useStore(s => s.view) ?? 'chat'
const active = tabs.find(v => v.id === activeId) ?? tabs[0]
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const pendingPrompt = useSession(s => s.pendingPrompt ?? undefined)
const storedDraft = useStore(s => s.draft)
const draft = pendingPrompt?.text ?? storedDraft
const sessionRunning = useSession(s => s.running)
const running = sessionRunning || pendingPrompt?.phase === 'sending'
const removed = useSession(s => s.removed)
const promptError = useSession(s => s.promptError)
const turns = useSession(s => countTurns(s))
const pending = useSession(s => s.pending)
const openState = useSession(s => s.openState)
const composerPhase = useSession(s => s.composerPhase)
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
const workspaceTitle = useWorkspaces(state =>
state.items.find(workspace => workspace.sessionIds.includes(sessionId))?.title)
const error: InputBarError | null = pendingPrompt?.error !== undefined
? {
op: pendingPrompt.retry === 'connect' ? 'session' : 'send',
message: pendingPrompt.retry === 'connect'
? `Workspace attach failed: ${pendingPrompt.error}`
: `Message send failed: ${pendingPrompt.error}`,
}
: promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
const status = pendingPrompt?.phase === 'sending'
? pendingPrompt.retry === 'connect' ? 'Attaching session to workspace…' : 'Sending message…'
: undefined
const setDraft = (text: string): void => {
if (pendingPrompt === undefined) actions.setDraft(text)
else updateSessionPrompt(text)
}
const submit = (mode: 'queue' | 'steer'): void => {
if (pendingPrompt === undefined) send(draft, mode)
else retrySessionPrompt()
}
const pending = useSession(s => s.pending) ?? []
const session = useSession(s => s)
const inputState = useInput(s => s)
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
const workspaces = useWorkspaces(s => s)
// Blank-session guidance: phase-derived (the runtime snapshot owns the
// predicate — see ComposerPhase). Only `blank` renders the hero; `engaging`
// and `active` fall through to the conversation view, so an in-flight
// first send never bounces back here. Gated on the OPEN window: phase has
// no jurisdiction over loading/error frames (ChatView renders those).
if (openState === 'open' && composerPhase === 'blank') {
return (
<EmptyHero
workspaceRow={<WorkspaceChip label={workspaceTitle ?? workspaceLabel(cwd ?? '')} locked />}
draft={draft}
disabled={removed || pendingPrompt?.phase === 'sending'}
error={error}
{...(status === undefined ? {} : { status })}
onDraftChange={setDraft}
onSend={submit}
const [pickerOpen, setPickerOpen] = useState(false)
const pickerAnchor = useRef<HTMLButtonElement>(null)
const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))
const zone: InputZone | undefined =
session === undefined || inputState === undefined ? undefined : { session, input: inputState }
const heroWorkspaceRow = (
<>
<WorkspaceChip
buttonRef={pickerAnchor}
label={
sessionId === undefined
? workspaceLabel('')
: workspaces.items.find(w => w.sessionIds.includes(sessionId))?.title ?? workspaceLabel(cwd ?? '')
}
menuOpen={pickerOpen}
onClick={() => { setPickerOpen(open => !open) }}
/>
)
}
{renderSlot('conversation.hero.workspace', {
open: pickerOpen,
anchorRef: pickerAnchor,
onPick: (workspaceId) => {
setPickerOpen(false)
selectWorkspace(workspaceId)
},
onClose: () => { setPickerOpen(false) },
})}
</>
)
const inputBar = sessionId === undefined
? <DisabledInputBar />
: renderSlot('conversation.composer.bar', {
variant: hero ? 'hero' : 'composer',
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
overlay: renderSlot('conversation.input.overlay', {}),
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
})
// The default composer doubles as the chain's all-decline fallback: a
// pending wait with no registered takeover must still leave the input usable.
const composerBar = (
<InputBar
draft={draft}
running={running}
disabled={removed}
error={error}
{...(status === undefined ? {} : { status })}
variant="composer"
onDraftChange={setDraft}
onSend={submit}
onStop={stop}
/>
<div className={clsx(css.composerStack, hero && css.composerHero)}>
{hero && <HeroShell />}
{hero && heroWorkspaceRow}
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
{!hero && zone !== undefined && renderSlot('conversation.composer.dock', zone)}
{inputBar}
</div>
)
return (
<div className={css.root}>
<header className={css.header}>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label="Session hierarchy">
{ancestry.map((s, i) => {
const last = i === ancestry.length - 1
return (
<span key={s.id} className={css.crumbSeg}>
{i > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(s.id) }}
>
{s.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
<span className={css.meta}>· {turns} turns</span>
</nav>
{/* Header button row (Fork / Session log / I/O Details): a P-I visual
placeholder registry slot is deferred — buttons land with their features. */}
</div>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{tabs.map(v => (
<button
key={v.id}
type="button"
role="tab"
aria-selected={v.id === active?.id}
className={clsx(css.tab, v.id === active?.id && css.tabActive)}
onClick={() => { actions.setView(v.id) }}
>
{v.label}
</button>
))}
</div>
)}
</header>
<div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>
{renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })}
<div className={css.root} data-phase={hero ? 'hero' : 'active'}>
{/* Mounted for every real session, hero included: ConversationSession
renders no chrome while blank but owns the draft-persistence mirror
bind — unmounting it in the hero would lose pre-first-send text on
a refresh or scope rebuild. */}
{sessionId !== undefined && renderSlot('conversation.session', {})}
{renderSlotChain(
'conversation.composer',
{ interactions: pending },
{ fallback: composerBar, overlay: true },
)}
</div>
)
}
/** Turn count = user message nodes in the window (display meta; exact host count deferred). */
function countTurns(s: { nodes: readonly { kind: string }[] }): number {
let n = 0
for (const node of s.nodes) if (node.kind === 'user') n += 1
return n
}

View File

@@ -0,0 +1,103 @@
/** Strict per-session conversation content: header, view ring, and chat store bindings. */
import { useEffect, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSessionSlotProps } from '../contract/slots.ts'
import css from './ConversationRoot.module.css'
/** Full props composed from the strict session slot contract. */
export type ConversationSessionProps = ConversationSessionSlotProps
function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] {
const chain: SessionSummary[] = []
let cursor: SessionId | undefined = id
while (cursor !== undefined) {
const summary: SessionSummary | undefined = list.byId[cursor]
if (summary === undefined || chain.includes(summary)) break
chain.unshift(summary)
cursor = summary.parentId
}
return chain
}
export function ConversationSession({
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror, open,
}: ConversationSessionProps) {
useSyncExternalStore(views.subscribe, views.version)
const tabs = views.list()
const activeId = useStore(s => s.view) ?? 'chat'
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const turns = useSession(s => countTurns(s))
const composerPhase = useSession(s => s.composerPhase)
const blank = useSession(s => s.blank)
const inputState = useInput(s => s)
const storedDraft = useStore(s => s.draft)
useEffect(() => {
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
const unmirror = bindDraftMirror(actions.setDraft)
return () => { unmirror() }
// Mount-only: later store writes come from the machine mirror.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputActions])
if (blank && composerPhase === 'blank') return null
return (
<>
<header className={css.header}>
<div className={css.crumbRow}>
<nav className={css.crumbs} aria-label="Session hierarchy">
{ancestry.map((summary, index) => {
const last = index === ancestry.length - 1
return (
<span key={summary.id} className={css.crumbSeg}>
{index > 0 && <span className={css.crumbSep}>/</span>}
<button
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { open(summary.id) }}
>
{summary.displayTitle}
</button>
</span>
)
})}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
<span className={css.meta}>· {turns} turns</span>
</nav>
</div>
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{tabs.map(view => (
<button
key={view.id}
type="button"
role="tab"
aria-selected={view.id === active?.id}
className={clsx(css.tab, view.id === active?.id && css.tabActive)}
onClick={() => { actions.setView(view.id) }}
>
{view.label}
</button>
))}
</div>
)}
</header>
<div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>
</>
)
}
function countTurns(snapshot: { nodes: readonly { kind: string }[] }): number {
let count = 0
for (const node of snapshot.nodes) if (node.kind === 'user') count += 1
return count
}

View File

@@ -5,6 +5,7 @@
// share the store seat exists for) and derives the call material from the
// session snapshot — no data of its own.
import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { DetailsSlotProps } from '../contract/slots.ts'
@@ -31,6 +32,18 @@ function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | nu
if (open !== undefined) {
return { name: open.name, argsRaw: open.argsRaw, result: null, running: true }
}
// run_code sub-dispatches: the native call-block shapes, so a selected
// sub-row resolves through the same material as a native call — the
// settled ToolResultNode form, or the RunningToolCall form mid-flight.
for (const subs of s.codeDispatches.values()) {
for (const sub of subs) {
if (sub.callId !== callId) continue
if ('kind' in sub) {
return { name: sub.call?.name ?? callId, argsRaw: sub.call?.argsRaw ?? null, result: sub, running: false }
}
return { name: sub.name, argsRaw: sub.argsRaw, result: null, running: true }
}
}
return null
}
@@ -77,7 +90,7 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
{material.argsRaw !== null && (
<section className={css.section}>
<div className={css.sectionLabel}>Input</div>
<pre className={css.code}>{pretty(material.argsRaw)}</pre>
<CodeBlock code={pretty(material.argsRaw)} lang="json" />
</section>
)}
<section className={css.section}>

View File

@@ -0,0 +1,40 @@
/** Inert no-session input body; the resident Hero shell renders around it. */
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './InputBar.module.css'
/** Disabled visual twin of the session-bound InputBar. */
export function DisabledInputBar() {
return (
<div className={clsx(css.root, css.hero)}>
<div className={css.card}>
<div className={css.grow}>
<textarea
className={css.input}
value=""
disabled
placeholder="Choose a workspace to start"
rows={2}
readOnly
/>
<div aria-hidden className={css.mirror}>{'\n'}</div>
</div>
<div className={css.row}>
<div className={css.tools}>
<button type="button" className={css.add} aria-label="Add attachment" disabled>
<IconPlusOutline16 size={14} />
</button>
</div>
<div className={css.trailing}>
<button type="button" className={css.primary} aria-label="Send message" disabled>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,8 +1,8 @@
// EmptyHero: the shared NEW SESSION hero (fish headline + glow + workspace
// row + hero InputBar), extracted from EmptyState so the bound guidance
// state (a current session with zero messages, ConversationRoot) renders the
// same layout without the picker wiring. Hosts own the workspace-row content
// and the send wiring; modals ride `children` after the stack.
// Hero chrome for the blank-draft phase of ConversationRoot: fish headline,
// glow backdrop, and the workspace row. Pure presentation — the resident
// composer is NOT rendered here (it keeps its own stable tree position in
// ConversationRoot so the textarea survives the hero → composer flip); CSS
// positions it over this shell's glow area during the hero phase.
import { useId } from 'react'
import type { ReactNode, RefObject } from 'react'
@@ -10,9 +10,7 @@ import {
FishLogo, IconChevronDownOutline14, IconFolderOpen16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import css from './EmptyState.module.css'
import css from './HeroShell.module.css'
/**
* Basename label for the workspace chip / menu rows (the shared derivation);
@@ -28,19 +26,17 @@ export function workspaceLabel(cwd: string): string {
}
/**
* The workspace chip (folder + label + chevron). Locked form (bound guidance
* state): no chevron, no menu affordance, clicks disabled — the bound
* session's cwd is final.
* The workspace chip (folder + label + chevron), always interactive: before
* the first message the workspace stays switchable — picking another one
* moves the New Session flow to that workspace's blank session.
* @param props.label - chip label (see {@link workspaceLabel}).
* @param props.locked - read-only echo form.
* @param props.menuOpen - menu expansion echo (interactive form only).
* @param props.onClick - menu toggle (interactive form only).
* @param props.menuOpen - menu expansion echo.
* @param props.onClick - menu toggle.
* @returns the chip button element.
*/
export function WorkspaceChip({ buttonRef, label, locked = false, menuOpen = false, onClick }: {
export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
buttonRef?: RefObject<HTMLButtonElement>
label: string
locked?: boolean
menuOpen?: boolean
onClick?: () => void
}) {
@@ -49,50 +45,30 @@ export function WorkspaceChip({ buttonRef, label, locked = false, menuOpen = fal
ref={buttonRef}
type="button"
className={css.workspace}
aria-label={locked ? 'Current workspace' : 'Choose workspace'}
{...(locked ? {} : { 'aria-haspopup': 'menu' as const, 'aria-expanded': menuOpen })}
disabled={locked}
aria-label="Choose workspace"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={onClick}
>
<IconFolderOpen16 className={css.folder} size={16} />
<span className={css.workspaceLabel}>{label}</span>
{!locked && <IconChevronDownOutline14 className={css.chevron} size={12} />}
<IconChevronDownOutline14 className={css.chevron} size={12} />
</button>
)
}
/** Hero-card props: both hosts supply the workspace row and their send wiring. */
export interface EmptyHeroProps {
/** Workspace-row content (Menu-wrapped chip in EmptyState; bare locked chip in guidance). */
workspaceRow: ReactNode
draft: string
disabled: boolean
/** Composer placeholder override (EmptyState's pick-a-workspace hint); defaults to the hero copy. */
placeholder?: string
error: InputBarError | null
status?: string
onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void
/** Overlay content after the stack (EmptyState's modals). */
/** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */
export interface HeroShellProps {
/** Overlay content after the stack (modals). */
children?: ReactNode
}
/**
* Render the hero card.
* @param props - see {@link EmptyHeroProps}.
* Render the hero chrome (headline + glow; no composer, no workspace row).
* @param props - see {@link HeroShellProps}.
* @returns the centered hero element tree.
*/
export function EmptyHero({
workspaceRow,
draft,
disabled,
placeholder,
error,
status,
onDraftChange,
onSend,
children,
}: EmptyHeroProps) {
export function HeroShell({ children }: HeroShellProps) {
// Stable filter id so multiple hero mounts do not collide in the DOM.
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
return (
@@ -104,7 +80,7 @@ export function EmptyHero({
Let&apos;s start building
</div>
<div className={css.body}>
{/* figma 313:14109: soft ellipse behind workspace + InputBar; width
{/* figma 313:14109: soft ellipse behind workspace + composer; width
tracks the card (glow asset 1051 vs design card 776) so blur
scales in userSpace with it. */}
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
@@ -127,20 +103,10 @@ export function EmptyHero({
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
</g>
</svg>
<div className={css.workspaceRow}>{workspaceRow}</div>
<InputBar
draft={draft}
running={false}
disabled={disabled}
error={error}
{...(status === undefined ? {} : { status })}
variant="hero"
placeholder={placeholder ?? 'Describe what you want to build'}
onDraftChange={onDraftChange}
onSend={onSend}
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
onStop={() => {}}
/>
{/* The resident composer (rendered by ConversationRoot at its stable
tree position; the workspace row rides its accessory hole) is
CSS-positioned into this gap during the hero phase — see
ConversationRoot.module.css [data-phase='hero']. */}
</div>
</div>
{children}

View File

@@ -1,77 +0,0 @@
/** Page-local Session Intent hero. */
import { useRef, useState } from 'react'
import type { EmptyStateSlotProps } from '../contract/slots.ts'
import type { InputBarError } from './InputBar.tsx'
import { EmptyHero, WorkspaceChip } from './EmptyHero.tsx'
/** Full props composed from runtime projections, injected actions, and the declared picker slot. */
export type EmptyStateProps = EmptyStateSlotProps
export function EmptyState({
useSessions,
useWorkspaces,
startSession,
updateSessionPrompt,
sendSession,
renderSlot,
}: EmptyStateProps) {
const intent = useSessions(state => state.intent)
const workspaceSnapshot = useWorkspaces(state => state)
const workspaces = workspaceSnapshot.items
const [pickerOpen, setPickerOpen] = useState(false)
const pickerAnchor = useRef<HTMLButtonElement>(null)
if (intent === undefined) return null
const workspaceId = intent.target.kind === 'workspace' ? intent.target.workspaceId : undefined
const workspace = workspaceId === undefined
? undefined
: workspaces.find(item => item.workspaceId === workspaceId)
const workspaceLabel = intent.target.kind === 'workspace-intent'
? workspaceSnapshot.intent?.name ?? 'Workspace unavailable'
: workspace?.title ?? 'Workspace unavailable'
const workspaceIntent = workspaceSnapshot.intent
const busy = intent.phase === 'connecting' || workspaceIntent?.phase === 'creating'
const status = workspaceIntent?.phase === 'creating'
? 'Creating workspace…'
: intent.phase === 'connecting'
? 'Creating session…'
: workspaceSnapshot.phase === 'pending'
? 'Loading workspaces…'
: undefined
const error: InputBarError | null = workspaceIntent?.error !== undefined
? { op: 'workspace', message: `Workspace creation failed: ${workspaceIntent.error}` }
: intent.error === undefined
? null
: { op: 'session', message: `Session creation failed: ${intent.error.message}` }
const workspaceRow = (
<>
<WorkspaceChip
buttonRef={pickerAnchor}
label={workspaceLabel}
menuOpen={pickerOpen}
onClick={() => { setPickerOpen(open => !open) }}
/>
{renderSlot('conversation.empty.workspace', {
open: pickerOpen,
anchorRef: pickerAnchor,
onPick: (workspaceId) => {
setPickerOpen(false)
startSession(workspaceId, intent.prompt)
},
onClose: () => { setPickerOpen(false) },
})}
</>
)
return (
<EmptyHero
workspaceRow={workspaceRow}
draft={intent.prompt}
disabled={busy}
{...(status === undefined ? {} : { status })}
error={error}
onDraftChange={updateSessionPrompt}
onSend={() => { sendSession() }}
/>
)
}

View File

@@ -9,6 +9,7 @@
height: 100%;
min-width: 0;
padding: 24px;
margin-bottom: -70px;
}
/* Cap matches InputBar card width (800). Glow may paint past the sides. */
@@ -87,7 +88,7 @@
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
max-width: fit-content;
min-height: 28px;
padding: 0 8px;
border: none;

View File

@@ -1,3 +1,13 @@
/* One-glyph font: maps ONLY U+FFFC to a blank 4em-advance glyph (every other
codepoint falls through to the next family). Loaded first in the composer
font stack, it gives the placeholder a real cell width INSIDE the textarea,
so the backdrop chip (same char, same stack) matches it by construction —
the two layers cannot drift and the chip gets a usable label cell. */
@font-face {
font-family: 'DshChipCell';
src: url('data:font/ttf;base64,AAEAAAAKAIAAAwAgT1MvMkT8SmIAAAEoAAAAYGNtYXAADQBPAAABkAAAADRnbHlmAAAAAAAAAcwAAAABaGVhZCwtPGoAAACsAAAANmhoZWEDIg7bAAAA5AAAACRobXR4EZQAAAAAAYgAAAAIbG9jYQAAAAAAAAHEAAAABm1heHAAAwACAAABCAAAACBuYW1lvljk2gAAAdAAAABscG9zdNNweNQAAAI8AAAALQABAAAAAQAAdia1tV8PPPUAAwPoAAAAAOaLfcUAAAAA5ot9xQAAAAAAAAAAAAAAAwACAAAAAAAAAAEAAAMg/zgAAA+gAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAACAAEAAAACAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAwjKAZAABQAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAPz8/PwAA//z//AMg/zgAAAMgAMgAAAAAAAAAAAAAAAAAAAAgAAAB9AAAD6AAAAAAAAIAAAADAAAAFAADAAEAAAAUAAQAIAAAAAQABAABAAD//P//AAD//P//AAUAAQAAAAAAAAAAAAAAAAAAAAAAAAAEADYAAQAAAAAAAQALAAAAAQAAAAAAAgAHAAsAAwABBAkAAQAWABIAAwABBAkAAgAOAChEc2hDaGlwQ2VsbFJlZ3VsYXIARABzAGgAQwBoAGkAcABDAGUAbABsAFIAZQBnAHUAbABhAHIAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAABAgZvYmpyZXAAAAA=') format('truetype');
}
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
viewport bottom inside the centered message column; textarea on top, action
row below, one primary circle button bottom-right. Input width rides the
@@ -35,12 +45,30 @@
color: var(--dsw-alias-label-secondary);
}
.notice {
width: 100%;
max-width: 800px;
margin-bottom: 6px;
padding: 4px 8px;
border-radius: 8px;
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
font-size: 12px;
line-height: 18px;
}
.noticeError {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
}
.error {
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
}
.card {
position: relative; /* overlay anchor positioning context */
display: flex;
flex-direction: column;
/* figma Input 75:8208: 12px between the text area and the button row; 10px
@@ -67,6 +95,14 @@
padding: 10px 12px 0;
}
/* Floating overlay anchor (menu / popupSelect shell): entries position
themselves against the card (bottom: 100% + gap); closed entries render null. */
.overlayAnchor {
position: absolute;
inset: 0 0 auto;
height: 0;
}
/* Mirror-div auto-grow wrapper: the hidden mirror is in normal flow and sets the height
(min 2 lines / max 14 lines); the textarea rides it absolutely. Mirror and textarea
MUST share font, line-height, padding and wrapping rules or heights diverge. */
@@ -74,6 +110,48 @@
position: relative;
}
/* Decoration backdrop: same metrics as the textarea, transparent glyphs; only
the highlight backgrounds and the ghost hint show through the transparent
textarea background above it. */
.backdrop {
position: absolute;
inset: 0;
overflow: hidden;
color: transparent;
pointer-events: none;
}
.hlToken {
border-radius: 4px;
/* GOAL 稿 amber token emphasis (state warn pair; glyphs stay the textarea's). */
background: var(--dsw-alias-state-warn-tertiary);
color: transparent;
}
.hlSegment {
border-radius: 4px;
background: var(--dsw-alias-interactive-bg-hover);
color: transparent;
}
.hint {
color: var(--dsw-alias-label-caption);
}
/* Machine pending dot (adjudicating / submitting). */
.pending {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--dsw-alias-state-business-primary);
animation: input-pending 1s ease-in-out infinite alternate;
}
@keyframes input-pending {
from { opacity: 0.35; }
to { opacity: 1; }
}
.input {
position: absolute;
inset: 0;
@@ -90,9 +168,15 @@
}
.input,
.mirror {
/* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. */
.mirror,
.backdrop {
/* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these
metrics or the highlight ranges drift off the glyphs. */
padding: 4px 12px 0 16px;
/* DshChipCell first: ONLY U+FFFC resolves there (4em blank cell — the chip
slot); everything else falls through to the app stack. All three layers
share the stack, so placeholder advances agree by construction. */
font-family: 'DshChipCell', var(--dsw-font-family);
font-size: inherit;
line-height: inherit;
white-space: pre-wrap;
@@ -241,3 +325,80 @@
background: var(--dsw-alias-button-primary-dimmed);
color: var(--dsw-alias-brand-text);
}
.retry {
margin-left: 8px;
padding: 1px 8px;
border: 1px solid currentColor;
border-radius: 4px;
background: transparent;
color: inherit;
font-size: 12px;
cursor: pointer;
}
/* Plain-text reference highlight (decision 21): a pure range mark over the
draft's own glyphs — advance untouched, so the two layers cannot drift.
Chip family colors; clone keeps rounded ends on soft-wrap fragments. */
.textRef {
color: transparent;
background-color: transparent;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
position: relative;
}
.textRef:after {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border-radius: 6px;
background: rgba(97, 135, 216, 0.22);
transform: translate(-2px, -1px);
padding: 2px 4px;
}
/* Reference chip: rendered in the backdrop at the placeholder offset. Hard
alignment constraint: the chip's advance must equal the textarea's U+FFFC
advance EXACTLY or every glyph after it drifts (caret/selection follow the
textarea character stream). The ::before renders the same U+FFFC through
the same font stack (DshChipCell 4em cell), so both layers agree by
construction — no measured widths. The label overlays the cell, clipped
with an ellipsis; the full name rides the title tooltip. */
.chip {
position: relative;
border-radius: 6px;
background: rgba(97, 135, 216, 0.22);
}
.chip::before {
content: '\FFFC';
color: transparent;
}
.chipLabel {
/* Compensated-scale centering: overflow clipping happens BEFORE transform,
so the box is laid out at 1/0.72 of the cell and scaled back down — the
clip edge then lands on the visual cell edge, not mid-glyph. */
position: absolute;
left: 50%;
top: 50%;
width: calc(100% / 0.72 - 10px);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
color: var(--dsw-alias-label-primary);
white-space: nowrap;
transform: translate(-50%, -50%) scale(0.72);
}
.chipInvalid {
background: rgba(216, 97, 97, 0.2);
text-decoration: line-through;
opacity: 0.7;
}

View File

@@ -1,60 +1,47 @@
// Shared empty-state and resident composer. Running retains the draft, locks
// the textarea, and exposes only Stop. Bottom controls are local visual state.
/** The default composer body: the 'conversation.composer.bar' slot entry
* (decision 20). Machine state arrives through the standard provide channel
* (useInput + inputActions); the keyboard/DOM command face and stop arrive
* through this entry's own inject; layout-phase inputs (variant, placeholder,
* region-slot content) ride the owner props. Session facts
* (running/removed/promptError) are self-selected via useSession. */
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerBarProps } from '../contract/slots.ts'
import { deriveDecorations } from '../input/decorations.ts'
import css from './InputBar.module.css'
/** Prompt failure surface (mirrors the session snapshot's promptError shape). */
/** Prompt failure surface (derived from promptError). */
export interface InputBarError {
op: 'workspace' | 'session' | 'send' | 'stop'
op: 'send' | 'stop'
message: string
}
export interface InputBarProps {
draft: string
running: boolean
disabled: boolean
error: InputBarError | null
/** Observable async phase for browser fixtures and assistive technology. */
status?: string
/** Hero = empty-state centered card; composer = resident bottom bar. */
variant: 'hero' | 'composer'
placeholder?: string
accessory?: ReactNode
onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void
onStop: () => void
onAdd?: () => void
addLabel?: string
}
export type InputBarProps = ComposerBarProps
interface SelectOption {
id: string
label: string
}
const PLAN_OPTIONS: readonly SelectOption[] = [
{ id: 'plan', label: 'Plan' },
{ id: 'agent', label: 'Agent' },
]
const READONLY_OPTIONS: readonly SelectOption[] = [
const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
{ id: 'readonly', label: 'Read-only' },
{ id: 'readwrite', label: 'Read-write' },
]
const MODEL_OPTIONS: readonly SelectOption[] = [
{ id: 'v4-pro-high', label: 'DeepSeek-V4-Pro High' },
{ id: 'v4-pro', label: 'DeepSeek-V4-Pro' },
]
export function InputBar({
draft, running, disabled, error, status, variant, placeholder, accessory,
onDraftChange, onSend, onStop, onAdd, addLabel = 'Add attachment',
useSession, useInput, inputActions, keyboard, stop, renderSlot,
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
const notice = useSyncExternalStore(keyboard.notices.subscribe, keyboard.notices.getSnapshot)
const promptError = useSession(s => s.promptError)
const running = useSession(s => s.running)
const disabled = useSession(s => s.removed)
// Prompt failures are ordinary failures (no create/attach transaction
// exists anymore): the strip renders promptError, the draft stays in the
// machine, and the user resubmits.
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
const draft = input.draft
const empty = draft.trim() === ''
const inputRef = useRef<HTMLTextAreaElement | null>(null)
// IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders;
@@ -69,33 +56,146 @@ export function InputBar({
}, 10)
}
// Placeholder chrome: selection is local until plan/mode/model seams land.
const [planId, setPlanId] = useState('plan')
// Placeholder chrome: Access selection stays local until its seam lands
// (plan/model are real seats now — the named single slots below).
const [readonlyId, setReadonlyId] = useState('readonly')
const [modelId, setModelId] = useState('v4-pro-high')
// Locked while running: the browser drops keystrokes AND focus on a disabled
// textarea — no sending mid-turn, stop or wait.
const locked = disabled || running
// Queue cut 1: running input stays free; locked = session disabled only.
// The transient machine locks (adjudicating pending / submitting) render
// read-only — the draft stays visible and focused, keystrokes drop.
const locked = disabled
const machineBusy = input.phase === 'adjudicating' || input.phase === 'submitting'
// Unlock (mount / session switch / turn end) returns focus to the box.
// Unlock (mount / session switch) returns focus to the box.
useEffect(() => {
if (!locked) inputRef.current?.focus()
}, [locked])
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key !== 'Enter') return
if (composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return
if (e.shiftKey) return // native newline
if (e.ctrlKey || e.metaKey) {
// execCommand keeps the browser undo stack intact, unlike a setState splice.
// 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
const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
if (keyboard.arbitrate(e.key === 'ArrowUp' ? 'up' : 'down', composing) === 'consumed') e.preventDefault()
return
}
if (e.key === 'Escape') {
// Escape layering: an open overlay closes; claimed without an overlay
// does NOT release (backspacing the token is the only exit gesture).
keyboard.dismissPopup()
if (keyboard.arbitrate('escape', composing) === 'consumed') e.preventDefault()
return
}
if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z' || e.key === 'y')) {
// The machine owns the undo/redo log (chip transactions have semantics
// the browser stack cannot represent); never let the native stack run.
e.preventDefault()
document.execCommand('insertText', false, '\n')
if (machineBusy || locked) return
const redo = e.key === 'y' || (e.shiftKey && (e.key === 'z' || e.key === 'Z'))
if (redo) keyboard.redo()
else keyboard.undo()
return
}
if (e.key === ' ') {
if (composing) return
if (keyboard.space()) e.preventDefault() // claim token already carries the trailing separator
return
}
if (e.key !== 'Enter') return
if (composing) return
// Menu-open Enter picks the highlight through arbitration; a no-highlight
// menu passes down to the machine's own adjudication.
const arbitrated = keyboard.arbitrate('enter', composing)
if (arbitrated !== 'pass') {
e.preventDefault()
return
}
if (e.ctrlKey || e.metaKey) {
// Newline as a machine transaction (the machine owns undo history; an
// execCommand write would fork a second, browser-owned history).
e.preventDefault()
if (!machineBusy && !locked) {
const el = e.currentTarget
const sel = selectionOf(el)
keyboard.newline(sel)
const caret = sel.start + 1
requestAnimationFrame(() => { el.setSelectionRange(caret, caret) })
}
return
}
e.preventDefault()
if (e.repeat) return // held-down Enter must not machine-gun sends
if (!empty && !locked) onSend('queue')
if (locked || machineBusy) return
inputActions.submit('queue')
}
const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock
const next = e.target.value
keyboard.setDraft(next)
keyboard.track(next, e.target.selectionStart ?? next.length)
}
// ---- chip atomicity (DOM layer; the machine sees only transactions) ----
// Placeholders occupy exactly one char, so caret positions are always
// BETWEEN them — what needs normalizing is deletion (whole chip per
// Backspace/Delete via native single-char semantics, which U+FFFC already
// gives us) and selection endpoints: Shift-extension snapping is native
// too (one char = one step). Mouse selection of a chip is handled in the
// backdrop click handler below. Undo/redo must NOT reach the browser: the
// machine owns the transaction log.
const selectionOf = (el: HTMLTextAreaElement) => ({
start: el.selectionStart ?? 0,
end: el.selectionEnd ?? el.selectionStart ?? 0,
})
const onCopyOrCut = (e: React.ClipboardEvent<HTMLTextAreaElement>, cut: boolean): void => {
const el = e.currentTarget
const { start, end } = selectionOf(el)
if (start === end) return
const slice = draft.slice(start, end)
const touched = input.occurrences.filter(o => o.offset >= start && o.offset < end)
if (touched.length === 0 && !cut) return // plain copy of plain text: native path is fine
e.preventDefault()
// Expand placeholders to their owner clipboard projections.
let text = ''
let cursor = start
for (const o of touched) {
text += draft.slice(cursor, o.offset) + o.clipboardText
cursor = o.offset + 1
}
text += draft.slice(cursor, end)
e.clipboardData.setData('text/plain', text)
if (cut && !machineBusy && !locked) {
keyboard.setDraft(draft.slice(0, start) + draft.slice(end), { start, end, insertedLength: 0 })
requestAnimationFrame(() => { el.setSelectionRange(start, start) })
}
void slice
}
const onPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>): void => {
if (machineBusy || locked) return
const text = e.clipboardData.getData('text/plain')
if (text === '') return
e.preventDefault()
const el = e.currentTarget
const sel = selectionOf(el)
// Sync components stay empty at this layer: hot-snapshot matching needs
// the Slash roster, which lives behind keyboard.track — the paste attempt
// opens in the machine and the controller upgrades tokens as matches
// land (paste-upgrade). The DOM layer only starts the transaction.
keyboard.pasteBegin(text, sel)
const caret = sel.start + text.length
requestAnimationFrame(() => { el.setSelectionRange(caret, caret) })
keyboard.track(keyboard.snapshot.draft, caret)
}
const onSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>): void => {
// Any caret/selection gesture ends a live paste attempt (the machine
// cannot observe DOM selection). Cheap no-op when none is live.
if (keyboard.snapshot.paste !== undefined) keyboard.invalidatePaste()
void e
}
// Button presses steal focus from the textarea; suppress at mousedown so typing continues seamlessly.
@@ -107,51 +207,132 @@ export function InputBar({
const primaryLabel = running ? 'Stop generating' : 'Send message'
const onPrimary = (): void => {
if (running) {
onStop()
stop()
return
}
/* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */
if (!empty && !disabled) onSend('queue')
if (!empty && !disabled && !machineBusy) inputActions.submit('queue')
}
const renderSelect = (
aria: string,
value: string,
options: readonly SelectOption[],
onPick: (id: string) => void,
): ReactNode => (
// Access placeholder select (the one remaining local-chrome control).
const accessSelect: ReactNode = (
<select
className={css.select}
aria-label={aria}
value={value}
aria-label="Access mode"
value={readonlyId}
disabled={locked}
onChange={(e: ChangeEvent<HTMLSelectElement>) => { onPick(e.target.value) }}
onChange={(e: ChangeEvent<HTMLSelectElement>) => { setReadonlyId(e.target.value) }}
>
{options.map(opt => (
{READONLY_OPTIONS.map(opt => (
<option key={opt.id} value={opt.id}>{opt.label}</option>
))}
</select>
)
// 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, keyboard.lexicon())
const backdrop: ReactNode[] = []
{
// Segment boundaries: the token range end, every chip offset, and every
// text-ref range (decision 21) — merged in draft order (the sources never
// overlap: chips sit on placeholders, text-refs on plain tokens, the
// claim token only leads).
let cursor = 0
const pushPlain = (upTo: number): void => {
if (upTo > cursor) backdrop.push(draft.slice(cursor, upTo))
cursor = upTo
}
if (deco.token !== null) {
backdrop.push(
<mark key="token" className={css.hlToken} data-decoration="token">
{draft.slice(deco.token.start, deco.token.end)}
</mark>,
)
cursor = deco.token.end
}
type Boundary =
| { at: number; kind: 'chip'; chip: (typeof deco.chips)[number] }
| { at: number; kind: 'text-ref'; ref: (typeof deco.textRefs)[number] }
const boundaries: Boundary[] = [
...deco.chips.map(chip => ({ at: chip.offset, kind: 'chip' as const, chip })),
...deco.textRefs.map(ref => ({ at: ref.start, kind: 'text-ref' as const, ref })),
].sort((a, b) => a.at - b.at)
for (const b of boundaries) {
if (b.at < cursor) continue // claim-token overlap: the leading mark wins
pushPlain(b.at)
if (b.kind === 'chip') {
const chip = b.chip
backdrop.push(
// The cell's ::before renders U+FFFC itself so its advance equals the
// textarea's placeholder exactly (same char, same font); the label is
// a clipped overlay that never affects layout.
<span
key={`chip-${chip.occurrenceId}`}
className={clsx(css.chip, chip.invalid && css.chipInvalid)}
data-decoration="chip"
data-occurrence={chip.occurrenceId}
data-invalid={chip.invalid || undefined}
title={chip.label}
>
<span className={css.chipLabel}>{chip.label}</span>
</span>,
)
cursor = chip.offset + 1 // the placeholder char the chip stands for
} else {
// Plain-range highlight (decision 21): the glyphs stay the
// textarea's (advance untouched); the mark paints the chip look.
backdrop.push(
<mark key={`ref-${b.ref.start}`} className={css.textRef} data-decoration="text-ref">
{draft.slice(b.ref.start, b.ref.end)}
</mark>,
)
cursor = b.ref.end
}
}
pushPlain(draft.length)
if (deco.hint !== null) {
backdrop.push(<span key="hint" className={css.hint} data-decoration="hint">{deco.hint}</span>)
}
}
return (
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
{status !== undefined && <div className={css.status} role="status">{status}</div>}
{error !== null && <div className={css.error} role="alert">{error.message}</div>}
{error !== null && (
<div className={css.error} role="alert">
{error.message}
</div>
)}
{notice !== null && (
<div className={clsx(css.notice, notice.level === 'error' && css.noticeError)} role="status">
{notice.text}
</div>
)}
<div className={css.card}>
{overlay !== undefined && <div className={css.overlayAnchor}>{overlay}</div>}
{accessory !== undefined && <div className={css.accessory}>{accessory}</div>}
{/* Mirror-div auto-grow: the hidden mirror renders draft+'\n' and stretches the wrapper
(min/max capped in CSS); the absolutely-positioned textarea rides its height. Counting
rows by '\n' cannot see soft wraps. */}
<div className={css.grow}>
<div aria-hidden className={css.backdrop} data-input-backdrop>{backdrop}</div>
<textarea
ref={inputRef}
className={css.input}
value={draft}
disabled={locked}
placeholder={placeholder ?? (disabled ? 'Session unavailable' : running ? 'Generating a response…' : 'Message the agent')}
readOnly={machineBusy}
data-phase={input.phase}
placeholder={placeholder ?? (disabled ? 'Session unavailable' : 'Message the agent')}
rows={2}
onChange={(e) => onDraftChange(e.target.value)}
onChange={onChange}
onKeyDown={onKeyDown}
onSelect={onSelect}
onCopy={e => { onCopyOrCut(e, false) }}
onCut={e => { onCopyOrCut(e, true) }}
onPaste={onPaste}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
/>
@@ -171,18 +352,21 @@ export function InputBar({
<IconPlusOutline16 size={14} />
</button>
<div className={css.modes}>
{renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)}
{renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)}
{renderSlot('conversation.input.plan', { locked })}
{accessSelect}
</div>
{leftItems}
</div>
<div className={css.trailing}>
{renderSelect('Model', modelId, MODEL_OPTIONS, setModelId)}
{rightItems}
{renderSlot('conversation.input.model', { locked })}
{machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />}
<button
type="button"
className={clsx(css.primary, running && css.stopping)}
aria-label={primaryLabel}
title={primaryLabel}
disabled={!running && (empty || disabled)}
disabled={!running && (empty || disabled || machineBusy)}
onMouseDown={keepFocus}
onClick={onPrimary}
>

View File

@@ -20,7 +20,7 @@ import type {
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { createChatStore } from '../src/client/stores.ts'
@@ -53,20 +53,21 @@ async function bench() {
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT],
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, blank: false, updatedAt: 1 } },
current: ROOT,
intent: undefined,
phase: 'ready',
})
const sessionFake = {
sessionId: ROOT,
open: vi.fn(() => Promise.resolve()),
loadOlder: vi.fn(() => Promise.resolve()),
updatePendingPrompt: vi.fn(),
retryPendingPrompt: vi.fn(),
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
// Observable face (the input machine's queue read face rides it).
getSnapshot: () => ({ queue: [] }),
subscribe: () => () => {},
}
const scopes = new Map<SessionId, Context>()
const mint = (id: SessionId): Context => {
@@ -77,24 +78,31 @@ async function bench() {
}
return scoped
}
type TestProvider = {
resolve(binding: { sessionId: SessionId; session: typeof sessionFake; ctx: Context }): {
hooks?: Record<string, unknown>; props?: Record<string, unknown>
}
}
const providers: TestProvider[] = []
const sessionsFake = {
list: listStore,
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
scope: (id: SessionId) => mint(id),
cell: () => undefined,
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
scopeOf,
sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),
open: vi.fn(),
updateIntent: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
const workspaceStore = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const workspacesFake = {
list: workspaceStore,
startSession: vi.fn(),
sendSession: vi.fn(),
connectWorkspace: vi.fn(async () => ROOT),
}
ctx.provide('workspaces', workspacesFake)
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
@@ -107,9 +115,8 @@ async function bench() {
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, (_p: { renderSlot?: unknown }) => null)
@@ -122,15 +129,23 @@ async function bench() {
slots.install({ renderRoot: (h) => { host = h; return null } })
slots.renderSlot('root', {})
const hostFace = host!
const entryOf = (key: 'conversation' | 'conversation.view' | 'details' | 'conversation.empty') => hostFace.entriesOf(key)[0]!
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') => hostFace.entriesOf(key)[0]!
/** Resolve store instance + call the inject the way the outlet would. */
const conversationSurface = (id: SessionId) => {
const entry = entryOf('conversation')
const entry = entryOf('conversation.session')
const instance = hostFace.storeOf(entry, id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationInjected)(
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)(
id, instance.actions)
return { instance, injected }
}
const residentSurface = (id: SessionId | undefined) => {
const entry = entryOf('conversation')
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
}
const composerSurface = (id: SessionId | undefined) => {
const entry = entryOf('conversation.composer.bar')
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected)(id)
}
/** Same resolution for the chat entry riding the view ring. */
const chatViewSurface = (id: SessionId) => {
const entry = entryOf('conversation.view')
@@ -139,12 +154,19 @@ async function bench() {
id, instance.actions)
return { instance, injected }
}
const emptySurface = () => {
const entry = entryOf('conversation.empty')
return (entry.inject as unknown as () => EmptyStateInjected)()
/** Materialize the input provide contribution the way the runtime does. */
const inputSurface = (id: SessionId) => {
const contribution = providers[0]!.resolve(sessionsFake.binding(id))
const state = contribution.hooks!['input'] as {
getSnapshot(): { draft: string }; subscribe(fn: () => void): () => void
}
const actions = contribution.props!['inputActions'] as {
setDraft(text: string): void; submit(mode?: 'queue' | 'steer'): void
}
return { state, actions }
}
return {
ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, emptySurface,
ctx, slots, hostFace, entryOf, conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
sessionFake, sessionsFake, workspacesFake, layoutFake, mint,
}
}
@@ -163,52 +185,60 @@ describe('conversation slot inject surface', () => {
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
})
it('send trims, optimistically clears through actions, restores on failure without clobbering new typing', async () => {
it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => {
const b = await bench()
const { instance, injected } = b.conversationSurface(ROOT)
// Whitespace-only: no send, and the (whitespace) draft is not cleared.
instance.actions.setDraft(' ')
injected.send(' ', 'queue')
const { injected } = b.conversationSurface(ROOT)
const { state, actions } = b.inputSurface(ROOT)
// Whitespace-only: the machine treats it as empty — no prompt, draft kept.
actions.setDraft(' ')
actions.submit('queue')
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
expect(instance.store.getSnapshot().draft).toBe(' ')
expect(state.getSnapshot().draft).toBe(' ')
// Success: cleared and stays cleared.
instance.actions.setDraft('hello')
injected.send('hello', 'queue')
expect(instance.store.getSnapshot().draft).toBe('')
actions.setDraft('hello')
actions.submit('queue')
expect(state.getSnapshot().draft).toBe('')
await Promise.resolve()
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
// Failure: restored (draft still empty when the rejection lands).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
instance.actions.setDraft('retry me')
injected.send('retry me', 'queue')
actions.setDraft('retry me')
actions.submit('queue')
await vi.waitFor(() => {
expect(instance.store.getSnapshot().draft).toBe('retry me')
expect(state.getSnapshot().draft).toBe('retry me')
})
// Failure landing after new typing: no clobber (restoreDraft fills empty only).
// Failure landing after new typing: no clobber (restore fills empty only).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
injected.send('retry me', 'queue')
instance.actions.setDraft('typed during flight')
actions.submit('queue')
actions.setDraft('typed during flight')
await new Promise(r => setTimeout(r, 0))
expect(instance.store.getSnapshot().draft).toBe('typed during flight')
expect(state.getSnapshot().draft).toBe('typed during flight')
// The provide contribution is idempotent per session: one shell identity.
expect(b.inputSurface(ROOT).state).toBe(state)
// The draft mirror rides the conversation inject face.
const mirrored: string[] = []
const unbind = injected.bindDraftMirror(text => mirrored.push(text))
actions.setDraft('mirrored text')
expect(mirrored).toEqual(['mirrored text'])
unbind()
// Stop failure is swallowed (promptError owns the surface).
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
injected.stop()
b.composerSurface(ROOT).stop()
await new Promise(r => setTimeout(r, 0))
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
})
it('inject fails loud when the session resolves no scope or the scope lacks the service', async () => {
const b = await bench()
const entry = b.entryOf('conversation')
const instance = b.hostFace.storeOf(entry, ROOT) as ChatInstance
const injectFn = entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationInjected
const entry = b.entryOf('conversation.composer.bar')
const injectFn = entry.inject as unknown as (sessionId: SessionId) => ComposerBarInjected
// Unknown session: sessions.scope answers nothing.
;(b.sessionsFake.scope as unknown) = () => undefined
expect(() => injectFn(ROOT, instance.actions)).toThrow(/resolved no scope/)
expect(() => injectFn(ROOT).stop()).toThrow(/resolved no scope/)
// A scope minted outside the service tree: no conversation service on it.
const foreign = new Context()
;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
expect(() => injectFn(ROOT, instance.actions)).toThrow(/unavailable through the session scope/)
expect(() => injectFn(ROOT).stop()).toThrow(/unavailable through the session scope/)
})
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
@@ -223,15 +253,28 @@ describe('conversation slot inject surface', () => {
expect(conv.instance).toBe(instance)
})
it('routes navigation through SessionsService and the retained prompt through the scoped Session', async () => {
it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
const resident = b.residentSurface(ROOT)
injected.open(ROOT)
injected.updateSessionPrompt('revised')
injected.retrySessionPrompt()
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
expect(b.sessionFake.updatePendingPrompt).toHaveBeenCalledWith('revised')
expect(b.sessionFake.retryPendingPrompt).toHaveBeenCalledOnce()
// Same-session connect (the picked workspace resolves to this session):
// no draft movement, plain re-open.
const { state, actions } = b.inputSurface(ROOT)
actions.setDraft('carry me')
resident.selectWorkspace('workspace-1' as never)
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledTimes(2) })
expect(b.workspacesFake.connectWorkspace).toHaveBeenCalledWith('workspace-1')
expect(state.getSnapshot().draft).toBe('carry me')
// Cross-session connect: the draft MOVES — the old machine empties, the
// new session's machine receives the text, then navigation lands there.
const OTHER = 'other-1' as SessionId
b.workspacesFake.connectWorkspace.mockResolvedValueOnce(OTHER)
resident.selectWorkspace('workspace-2' as never)
await vi.waitFor(() => { expect(b.sessionsFake.open).toHaveBeenCalledWith(OTHER) })
expect(state.getSnapshot().draft).toBe('')
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
})
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
@@ -266,23 +309,9 @@ describe('details inject surface', () => {
injected.closeDetails()
expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
// The shared handle: details resolves the SAME instance conversation writes.
const conv = b.hostFace.storeOf(b.entryOf('conversation'), ROOT)
const conv = b.hostFace.storeOf(b.entryOf('conversation.session'), ROOT)
const details = b.hostFace.storeOf(entry, ROOT)
expect(details).toBe(conv)
})
it('empty state injects the runtime intent actions and remains storeless', async () => {
const b = await bench()
const entry = b.entryOf('conversation.empty')
expect(entry.store).toBeUndefined()
const injected = b.emptySurface()
injected.startSession(undefined, 'fresh')
injected.startSession('workspace-1' as never, 'retargeted')
injected.updateSessionPrompt('typed')
injected.sendSession()
expect(b.workspacesFake.startSession).toHaveBeenNthCalledWith(1, undefined, 'fresh')
expect(b.workspacesFake.startSession).toHaveBeenNthCalledWith(2, 'workspace-1', 'retargeted')
expect(b.sessionsFake.updateIntent).toHaveBeenCalledWith('typed')
expect(b.workspacesFake.sendSession).toHaveBeenCalledOnce()
})
})

View File

@@ -26,18 +26,19 @@ async function bench() {
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
byId: {
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, updatedAt: 1 },
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, updatedAt: 2 },
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, blank: false, updatedAt: 1 },
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, blank: false, updatedAt: 2 },
},
current: undefined,
intent: undefined,
phase: 'ready',
} as SessionListState)
const sessionsFake = {
list: listStore,
binding: vi.fn(),
scope: () => undefined,
cell: () => undefined,
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
provide: vi.fn(() => () => {}),
create: vi.fn(),
open: vi.fn(),
updateIntent: vi.fn(),
@@ -57,9 +58,8 @@ async function bench() {
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, (_p: { renderSlot?: unknown }) => null)
@@ -68,7 +68,7 @@ async function bench() {
}
/** First stored entry for a key (inject/store live directly on StoredEntry). */
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.view' | 'details' | 'conversation.empty') {
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') {
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
}
@@ -91,23 +91,24 @@ describe('apply wiring', () => {
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
})
it('occupies the three slots + the ring; session entries share one store handle, empty injects runtime actions', async () => {
it('occupies the slots + the ring; session entries share one store handle', async () => {
const b = await bench()
await b.fiber.await()
const conversation = renderEntryOf(b.slots, 'conversation')
const conversationSession = renderEntryOf(b.slots, 'conversation.session')
const chatView = renderEntryOf(b.slots, 'conversation.view')
const details = renderEntryOf(b.slots, 'details')
const empty = renderEntryOf(b.slots, 'conversation.empty')
expect(conversation?.inject).toBeTypeOf('function')
expect(chatView?.inject).toBeTypeOf('function')
expect(details?.inject).toBeTypeOf('function')
expect(empty?.inject).toBeTypeOf('function')
// The shared handle: one apply-built store value on ALL session entries.
expect(conversation?.store).toBeDefined()
expect(details?.store).toBe(conversation?.store)
expect(chatView?.store).toBe(conversation?.store)
// The empty slot is storeless (local state + useSessions derivation).
expect(empty?.store).toBeUndefined()
// The shared handle: one apply-built store value on ALL session entries
// (the session-maybe 'conversation' shell carries no store by design).
expect(conversationSession?.store).toBeDefined()
expect(details?.store).toBe(conversationSession?.store)
expect(chatView?.store).toBe(conversationSession?.store)
// The hero workspace picker hole rides the conversation entry's children
// declaration (the empty-state occupant is gone).
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
})
it('mounts the bash sample as a keyed entry through the load-order seam', async () => {
@@ -130,7 +131,6 @@ describe('apply wiring', () => {
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
expect(b.slots.entries('details')).toHaveLength(0)
expect(b.slots.entries('conversation.empty')).toHaveLength(0)
expect(b.ctx.get('conversation')).toBeUndefined()
})
})

View File

@@ -0,0 +1,248 @@
// @vitest-environment jsdom
// Code Mode sub-call acceptance on the REAL machinery stack (same bench as
// chat-toolview-slot.spec): a run_code result renders the 'code' variant row
// (description summary, program body), its logged sub-dispatches render as
// always-visible nested rows through the SAME keyed toolview hole — the bash
// sub-call lands in the bash sample plugin's registration exactly like a
// top-level bash row, unregistered sub-tools fall back to GenericToolCard —
// and a sub-row click opens details for the sub-callId. Running parents
// (runningCalls) nest their so-far dispatches the same way.
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
CodeSubCall, ConversationSnapshot, RunningToolCall, SessionId, SessionListState,
ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
const SID = 's1' as SessionId
afterEach(cleanup)
beforeEach(() => {
localStorage.clear()
})
const PROGRAM = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\nreturn listing'
const RUN_CODE_ARGS = JSON.stringify({ code: PROGRAM, description: 'List the notes directory' })
const codeResult = (seq: number, callId: string): ToolResultNode => ({
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name: 'run_code', argsRaw: RUN_CODE_ARGS },
callTime: seq * 1_000 - 500,
content: [{ type: 'text', text: 'demo.txt' }], isError: false, callView: null, resultView: null,
})
const runningCode = (callId: string): RunningToolCall => ({
callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000, callView: null,
})
const subCall = (seq: number, parent: string, n: number, name: string, args: object, resultText: string, isError = false): CodeSubCall => ({
kind: 'tool-result', seq, time: seq * 1_000,
callId: `${parent}:code:${n}`,
call: { name, argsRaw: JSON.stringify(args) },
callTime: seq * 1_000,
content: [{ type: 'text', text: resultText }], isError, callView: null, resultView: null,
})
function snapshotWith(
nodes: ToolResultNode[],
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>,
runningCalls: RunningToolCall[] = [],
): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} as ConversationSnapshot
}
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
function AppRoot({ renderSlot }: AppRootProps) {
return <>{renderSlot('conversation', {})}</>
}
/** Same real-stack bench as the toolview-slot spec: SlotsService + renderer + this package's apply; fakes only at service seams. */
async function bench(snapshot: ConversationSnapshot) {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
const list = createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
current: SID,
phase: 'ready',
})
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
// Provide-channel contributions land in this bundle the way the runtime
// materializes them; the renderer host serves it through provideInfo.
const provided: { hooks: Record<string, unknown>; props: Record<string, unknown> } = { hooks: {}, props: {} }
const sessionsFake = {
list,
binding: (id: SessionId) => (id === SID
? { sessionId: SID, session, ctx: { effect: () => {}, on: () => () => {} } }
: undefined),
scope: () => ({ get: () => scoped }),
scopeOf: () => SID,
provide: (descriptor: { resolve: (binding: unknown) => { hooks?: Record<string, unknown>; props?: Record<string, unknown> } }) => {
const contribution = descriptor.resolve(sessionsFake.binding(SID))
Object.assign(provided.hooks, contribution.hooks ?? {})
Object.assign(provided.props, contribution.props ?? {})
return () => {}
},
provideInfo: (id: string) => (id === SID
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
: undefined),
maybeProvideInfo: (id: string | undefined) => (id === SID
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
: { hooks: provided.hooks, props: provided.props }),
create: vi.fn(),
open: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
sendSession: vi.fn(),
})
ctx.provide('layout', layout)
ctx.provide('i18n', { bind: () => (key: string) => key })
slots.install(createSlotRenderer())
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
},
}, AppRoot)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, session, layout }
}
function mountApp(slots: SlotsService) {
return render(<>{slots.renderSlot('root', {})}</>)
}
describe('run_code sub-calls through the real chat machinery', () => {
it('renders the code-variant parent row with the description summary and nested sub-rows', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
subCall(12, parent, 2, 'mystery', { n: 1 }, 'ok'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
// Parent row: the code variant with the model-authored description.
const codeRoot = view.container.querySelector('[data-variant="code"]')
expect(codeRoot).not.toBeNull()
expect(view.getByText('Code')).toBeTruthy()
expect(view.getByText('List the notes directory')).toBeTruthy()
// Nested rows are ALWAYS visible (no parent expand needed): the bash
// sub-call landed in the bash sample plugin's keyed registration — the
// exact component a native top-level bash row uses — and the unregistered
// sub-tool fell back to GenericToolCard at the same render site.
const nest = view.container.querySelector('[data-subcalls]')
expect(nest).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('List notes')).toBeTruthy()
expect(view.getByText('Tool call')).toBeTruthy()
})
it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => {
const parent = 'call-64'
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))
const view = mountApp(b.slots)
// The code row is expandable via its leading control (body = the program).
const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]')
expect(toggle).not.toBeNull()
fireEvent.click(toggle!)
// Shiki splits the program into token spans inside one <pre class="shiki">:
// assert the whole text and the highlighted tree rather than one node.
const pre = view.container.querySelector('pre.shiki')
expect(pre).not.toBeNull()
expect(pre!.textContent).toContain('const listing = await tools.bash')
expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(3)
})
it('an isError sub-call renders the error state dot exactly like a failed native row', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'mystery', { n: 1 }, 'Error: boom', true),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="error"]')
expect(nested).not.toBeNull()
})
it('a sub-row click opens details for the sub-callId', async () => {
const parent = 'call-64'
const dispatches = new Map([[parent, [
subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const view = mountApp(b.slots)
view.getByText('List notes').click()
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
})
it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => {
const parent = 'call-live'
const dispatches = new Map([[parent, [
subCall(21, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]])
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
const view = mountApp(b.slots)
const running = view.container.querySelector('[data-variant="code"][data-state="running"]')
expect(running).not.toBeNull()
const nest = view.container.querySelector('[data-subcalls]')
expect(nest).not.toBeNull()
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
})
it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => {
const parent = 'call-live'
const runningSub: CodeSubCall = {
callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}',
turn: 0, step: 0, time: 21_000, callView: null,
}
const dispatches = new Map([[parent, [runningSub]]])
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
const view = mountApp(b.slots)
// The nested row derives 'running' from the RunningToolCall shape — the
// same StateDot ring a native in-flight row wears.
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]')
expect(nested).not.toBeNull()
})
it('an ordinary tool row renders no sub-call nest', async () => {
const parent = 'call-64'
const plain: ToolResultNode = {
kind: 'tool-result', seq: 10, time: 10_000, callId: parent,
call: { name: 'mystery', argsRaw: '{"n":1}' },
callTime: 9_500,
content: [], isError: false, callView: null, resultView: null,
}
const b = await bench(snapshotWith([plain], new Map()))
const view = mountApp(b.slots)
expect(view.container.querySelector('[data-subcalls]')).toBeNull()
})
})

View File

@@ -26,9 +26,9 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
@@ -123,11 +123,10 @@ describe('bash sample row', () => {
return createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
byId: {
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 },
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, blank: false, updatedAt: 0 },
},
current: undefined,
intent: undefined,
phase: 'ready',
} as SessionListState)
}
@@ -160,7 +159,7 @@ describe('bash sample row', () => {
const orphan = 'late-child' as SessionId
store.update((d) => {
d.ids.push(orphan)
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, updatedAt: 0 }
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, blank: false, updatedAt: 0 }
})
const view = render(<BashRow {...rowProps(orphan, { store })} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()

View File

@@ -39,16 +39,16 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} as ConversationSnapshot
}
/** Test-owned AppFrame role: declares the layout-owned children and renders the conversation area under the framework session provider. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details' | 'conversation.empty'>
function AppRoot({ renderSlot, SessionProvider }: AppRootProps) {
return <SessionProvider>{() => renderSlot('conversation', {})}</SessionProvider>
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
function AppRoot({ renderSlot }: AppRootProps) {
return <>{renderSlot('conversation', {})}</>
}
/**
@@ -65,28 +65,60 @@ async function bench(nodes: ToolResultNode[]) {
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
const list = createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, updatedAt: 1 } },
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
current: SID,
intent: undefined,
phase: 'ready',
})
// Identity-stable cell: the renderer caches hooks per source and inject
// results per cell, both by object identity.
const cell = { sessionId: SID, session }
// Identity-stable provide bundle: the renderer caches hooks per source and
// inject results per bundle, both by object identity. Registered providers
// (the package's input contribution) materialize into it lazily, once.
const providers: ((binding: object) => { hooks?: object; props?: object })[] = []
let info: { sessionId: SessionId; hooks: object; props: object } | undefined
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
const actxFake = { get: () => scoped, effect: () => {}, on: () => () => {} }
const bindingOf = (id: SessionId) => ({
sessionId: id,
ctx: actxFake,
session: {
sessionId: id,
loadOlder: vi.fn(),
prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })),
// Observable face for the input machine's queue read face.
getSnapshot: () => session.getSnapshot(),
subscribe: (fn: () => void) => session.subscribe(fn),
},
})
ctx.provide('sessions', {
list,
binding: (id: SessionId) => ({ sessionId: id, session: { loadOlder: vi.fn() } }),
scope: () => ({ get: () => scoped }),
cell: (id: string) => (id === SID ? cell : undefined),
binding: bindingOf,
scope: () => actxFake,
provideInfo: (id: string) => {
if (id !== SID) return undefined
if (info === undefined) {
const hooks: Record<string, unknown> = { session }
const props: Record<string, unknown> = {}
for (const provider of providers) {
const c = provider(bindingOf(SID))
Object.assign(hooks, c.hooks ?? {})
Object.assign(props, c.props ?? {})
}
info = { sessionId: SID, hooks, props }
}
return info
},
maybeProvideInfo(id: string | undefined) {
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
},
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
scopeOf: () => SID,
create: vi.fn(),
open: vi.fn(),
updateIntent: vi.fn(),
})
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
@@ -99,9 +131,8 @@ async function bench(nodes: ToolResultNode[]) {
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, AppRoot)
@@ -194,18 +225,20 @@ describe('registrant load-order seam', () => {
const slots = ctx.get('slots') as SlotsService
ctx.provide('sessions', {
list: createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
ids: [], byId: {}, current: undefined, phase: 'ready',
}),
binding: () => undefined,
scope: () => undefined,
cell: () => undefined,
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
provide: () => () => {},
create: vi.fn(),
open: vi.fn(),
updateIntent: vi.fn(),
})
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
startSession: vi.fn(),
@@ -216,10 +249,9 @@ describe('registrant load-order seam', () => {
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'conversation': { kind: 'single', scope: 'session-maybe' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
},
}, AppRoot)
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject

View File

@@ -28,9 +28,9 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
@@ -77,13 +77,13 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
/** Empty sessions-list hook for the global standard-kit seat. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
const store = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return bindSnapshotSelector(store)
@@ -109,6 +109,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
useSession: bindSnapshotSelector(source),
useSessions: emptySessions(),
useWorkspaces: emptyWorkspaces(),
useInput: (() => { throw new Error('unused') }) as never,
inputActions: { setDraft: () => {}, submit: () => {} } as never,
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
renderSlot,

View File

@@ -87,9 +87,8 @@ describe('tails', () => {
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 } },
byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
current: undefined,
intent: undefined,
phase: 'ready',
} as SessionListState)
const props = {

View File

@@ -18,9 +18,9 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
} as ConversationSnapshot
}
@@ -65,9 +65,9 @@ describe('render branch tails', () => {
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const view = render(
@@ -76,6 +76,8 @@ describe('render branch tails', () => {
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSessions={bindSnapshotSelector(emptyList)}
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
useInput={(() => { throw new Error('unused') }) as never}
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
@@ -84,4 +86,42 @@ describe('render branch tails', () => {
expect(view.getByText('详情')).toBeTruthy()
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
})
it('DetailsPanel resolves a run_code sub-callId to its full logged args and output', () => {
localStorage.clear()
const snap = snapshotBase()
const longText = 'x'.repeat(1_000)
snap.codeDispatches = new Map([['p1', [{
kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
callTime: 8_000,
content: [{ type: 'text', text: longText }], isError: false, callView: null, resultView: null,
}]]])
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 8, callId: 'p1:code:1', toolName: 'read' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSessions={bindSnapshotSelector(emptyList)}
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
useInput={(() => { throw new Error('unused') }) as never}
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
// Sub-call material: the sub-tool name titles the panel, args pretty-print,
// and the COMPLETE logged output renders (no truncation anywhere).
expect(view.getByText('read')).toBeTruthy()
expect(view.getByText(/notes\/demo\.txt/)).toBeTruthy()
expect(view.getByText(longText)).toBeTruthy()
})
})

View File

@@ -1,21 +1,100 @@
// @vitest-environment jsdom
// InputBar behavior: Enter-send semantics (IME guard, shift newline,
// ctrl/meta insert, repeat suppression), the running lock with stop-only
// action, unlock refocus, error strip copy, and the focus-keeping mousedown.
// InputBar behavior over the machine wiring: Enter-send semantics (IME guard,
// shift newline, ctrl/meta insert, repeat suppression), queue-cut-1 running
// semantics (input stays free; primary turns stop), the machine pending lock,
// decoration backdrop, error/notice strips, and the focus-keeping mousedown.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
afterEach(cleanup)
function setup(over?: Partial<InputBarProps>) {
const SCTX = {} as ClientContext
const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
...overrides,
}
}
interface BenchOptions {
planEntry?: React.ReactNode
modelEntry?: React.ReactNode
/** Hot text-ref lexicon (injects a minimal slash stub exposing only lexicon()). */
lexicon?: ReadonlyMap<'/' | '@', readonly string[]>
draft?: string
running?: boolean
disabled?: boolean
promptError?: ConversationSnapshot['promptError']
variant?: 'hero' | 'composer'
placeholder?: string
accessory?: React.ReactNode
overlay?: React.ReactNode
leftItems?: React.ReactNode
rightItems?: React.ReactNode
}
/** Real machine behind the bar entry: sink spy, no slash pipeline (plain text goes straight to the sink). */
function bench(over?: BenchOptions) {
const sink = vi.fn()
const lex = over?.lexicon
type ShellDeps = ConstructorParameters<typeof SessionInputShell>[0]
const shell = new SessionInputShell({
actx: SCTX,
defaultSink: sink,
// Lexicon-only stub: adjudication untouched (undefined slash methods are
// never reached — these benches drive plain-draft flows only).
...(lex !== undefined
? { slash: (() => ({ lexicon: () => lex })) as unknown as NonNullable<ShellDeps['slash']> }
: {}),
})
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
const session = createSnapshotStore<ConversationSnapshot>(snapshotOf({
running: over?.running ?? false,
removed: over?.disabled ?? false,
promptError: over?.promptError ?? null,
}))
const stop = vi.fn()
const slotCalls: { key: string; owner: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
slotCalls.push({ key, owner })
if (key === 'conversation.input.plan') return over?.planEntry ?? null
if (key === 'conversation.input.model') return over?.modelEntry ?? null
return null
}) as InputBarProps['renderSlot']
const props: InputBarProps = {
draft: 'hello', running: false, disabled: false, error: null,
variant: 'composer',
onDraftChange: vi.fn(), onSend: vi.fn(), onStop: vi.fn(),
...over,
sessionId: SID,
SessionProvider: ({ children }) => children(SID),
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
})) as InputBarProps['useSessions'],
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})) as InputBarProps['useWorkspaces'],
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
stop,
renderSlot,
variant: over?.variant ?? 'composer',
...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}),
...(over?.accessory !== undefined ? { accessory: over.accessory } : {}),
...(over?.overlay !== undefined ? { overlay: over.overlay } : {}),
...(over?.leftItems !== undefined ? { leftItems: over.leftItems } : {}),
...(over?.rightItems !== undefined ? { rightItems: over.rightItems } : {}),
}
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')!
@@ -23,147 +102,280 @@ function setup(over?: Partial<InputBarProps>) {
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${over?.running === true ? 'Stop generating' : 'Send message'}"]`,
)!
return { view, textarea, button, props }
return { view, textarea, button, props, sink, shell, wiring: shell, session, stop, slotCalls }
}
describe('Enter semantics', () => {
it('plain Enter sends queue mode; repeat and empty are suppressed', () => {
const { textarea, props } = setup()
it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(props.onSend).toHaveBeenCalledWith('queue')
expect(sink).toHaveBeenCalledWith('hello', 'queue')
fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
expect(props.onSend).toHaveBeenCalledTimes(1)
const empty = setup({ draft: ' ' })
expect(sink).toHaveBeenCalledTimes(1)
const empty = bench({ draft: ' ' })
fireEvent.keyDown(empty.textarea, { key: 'Enter' })
expect(empty.props.onSend).not.toHaveBeenCalled()
expect(empty.sink).not.toHaveBeenCalled()
})
it('non-Enter keys and Shift+Enter fall through to native behavior', () => {
const { textarea, props } = setup()
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.keyDown(textarea, { key: 'a' })
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
expect(props.onSend).not.toHaveBeenCalled()
expect(sink).not.toHaveBeenCalled()
})
it('Ctrl/Meta+Enter inserts a newline through execCommand instead of sending', () => {
const exec = vi.fn()
;(document as unknown as { execCommand: typeof exec }).execCommand = exec
const { textarea, props } = setup()
it('Shift+Enter newline wins even inside IME composition (unconditional precedence)', () => {
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.compositionStart(textarea)
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true })
expect(sink).not.toHaveBeenCalled() // and not preventDefault'd: native newline
})
it('Ctrl/Meta+Enter inserts a newline through the machine (no browser execCommand)', () => {
const { textarea, shell, sink } = bench({ draft: 'hello' })
textarea.setSelectionRange(5, 5)
fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
expect(exec).toHaveBeenCalledWith('insertText', false, '\n')
expect(props.onSend).not.toHaveBeenCalled()
expect(shell.snapshot.draft).toBe('hello\n')
expect(sink).not.toHaveBeenCalled()
})
it('composition Enter never sends: ref guard, isComposing, and keyCode 229 paths', async () => {
it('platform undo/redo chords route to the machine, never the browser stack', () => {
const { textarea, shell } = bench({ draft: '' })
fireEvent.change(textarea, { target: { value: 'first' } })
fireEvent.change(textarea, { target: { value: 'first second' } })
fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true })
expect(shell.snapshot.draft).not.toBe('first second')
fireEvent.keyDown(textarea, { key: 'z', ctrlKey: true, shiftKey: true })
expect(shell.snapshot.draft).toBe('first second')
})
it('composition Enter never sends: ref guard, isComposing, and keyCode 229 paths', () => {
vi.useFakeTimers()
try {
const { textarea, props } = setup()
const { textarea, sink } = bench({ draft: 'hello' })
fireEvent.compositionStart(textarea)
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(props.onSend).not.toHaveBeenCalled()
expect(sink).not.toHaveBeenCalled()
fireEvent.compositionEnd(textarea)
// Safari delivers the closing keydown before the deferred clear.
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(props.onSend).not.toHaveBeenCalled()
expect(sink).not.toHaveBeenCalled()
vi.advanceTimersByTime(20)
fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 229 })
expect(props.onSend).not.toHaveBeenCalled()
expect(sink).not.toHaveBeenCalled()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(props.onSend).toHaveBeenCalledTimes(1)
expect(sink).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
})
describe('running lock and primary button', () => {
it('running locks the textarea and turns the primary into stop', () => {
const { textarea, button, props } = setup({ running: true })
expect(textarea.disabled).toBe(true)
describe('running and lock semantics (queue cut 1)', () => {
it('running keeps the input free (typing + Enter queue) while the primary turns stop', () => {
const { textarea, button, stop, sink } = bench({ running: true, draft: '排队消息' })
expect(textarea.disabled).toBe(false) // running no longer locks
fireEvent.change(textarea, { target: { value: '排队消息2' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
expect(button.getAttribute('aria-label')).toBe('Stop generating')
fireEvent.click(button)
expect(props.onStop).toHaveBeenCalledTimes(1)
expect(props.onSend).not.toHaveBeenCalled()
expect(stop).toHaveBeenCalledTimes(1)
})
it('disabled (session removed) locks the textarea and chrome', () => {
const { textarea, view } = bench({ disabled: true })
expect(textarea.disabled).toBe(true)
expect(textarea.placeholder).toBe('Session unavailable')
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
})
it('idle primary sends and disables on empty draft', () => {
const { button, props } = setup()
const { button, sink } = bench({ draft: 'go' })
fireEvent.click(button)
expect(props.onSend).toHaveBeenCalledWith('queue')
const empty = setup({ draft: '' })
expect(sink).toHaveBeenCalledWith('go', 'queue')
const empty = bench()
expect(empty.button.disabled).toBe(true)
})
it('unlock refocuses the textarea; mousedown on the button keeps focus', () => {
const { view, props } = setup({ running: true })
view.rerender(<InputBar {...props} running={false} />)
const textarea = view.container.querySelector('textarea')!
const first = bench({ disabled: true, draft: 'x' })
act(() => { first.session.set(snapshotOf({ removed: false })) })
const textarea = first.view.container.querySelector('textarea')!
expect(document.activeElement).toBe(textarea)
textarea.blur()
fireEvent.mouseDown(view.container.querySelector('button[aria-label="Send message"]')!)
fireEvent.mouseDown(first.view.container.querySelector('button[aria-label="Send message"]')!)
expect(document.activeElement).toBe(textarea)
})
it('disabled state shows the unavailable placeholder; typing forwards drafts', () => {
const { textarea } = setup({ disabled: true, draft: '' })
it('typing forwards through the machine (draft state echoes back)', () => {
const { textarea, wiring } = bench()
fireEvent.change(textarea, { target: { value: 'typed' } })
expect(wiring.state.getSnapshot().draft).toBe('typed')
expect((textarea as HTMLTextAreaElement).value).toBe('typed')
})
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
const { textarea } = bench({ disabled: true })
expect(textarea.placeholder).toBe('Session unavailable')
const live = setup({ draft: '' })
const live = bench()
expect(live.textarea.placeholder).toBe('Message the agent')
fireEvent.change(live.textarea, { target: { value: 'typed' } })
expect(live.props.onDraftChange).toHaveBeenCalledWith('typed')
const runningPh = setup({ running: true, draft: '' })
expect(runningPh.textarea.placeholder).toBe('Generating a response…')
const custom = setup({ placeholder: 'Custom placeholder' })
const custom = bench({ placeholder: 'Custom placeholder' })
expect(custom.textarea.placeholder).toBe('Custom placeholder')
})
})
describe('error strip and variants', () => {
it('renders send and stop failure copy', () => {
const send = setup({ error: { op: 'send', message: 'boom' } })
expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom')
const stop = setup({ error: { op: 'stop', message: 'halt' } })
expect(stop.view.container.querySelector('[role="alert"]')?.textContent).toBe('halt')
describe('machine pending lock', () => {
it('submitting renders read-only textarea, pending dot, and a disabled primary', () => {
const { view, shell } = bench()
// Drive the machine into submitting through a claim + enter.
act(() => {
shell.setDraft('/goal ')
shell.beginCommand(
{
token: '/goal ',
submit: () => new Promise<never>(() => {}), // never settles: stays submitting
},
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
)
shell.submit('queue')
})
expect(shell.snapshot.phase).toBe('submitting')
const textarea = view.container.querySelector('textarea')!
expect(textarea.readOnly).toBe(true)
expect(view.container.querySelector('[data-input-pending]')).not.toBeNull()
expect(view.container.querySelector<HTMLButtonElement>('button[aria-label="Send message"]')!.disabled).toBe(true)
})
})
describe('decorations', () => {
it('claimed token renders the mirror highlight and the blank-args hint', () => {
const { view, shell } = bench()
act(() => {
shell.setDraft('/goal ')
shell.beginCommand(
{ token: '/goal ', hint: '目标内容', submit: () => Promise.resolve({ kind: 'success' as const }) },
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
)
})
const token = view.container.querySelector('[data-decoration="token"]')
expect(token?.textContent).toBe('/goal ')
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标内容')
// Args typed: the hint disappears, the token highlight stays.
act(() => { shell.setDraft('/goal 发布') })
expect(view.container.querySelector('[data-decoration="hint"]')).toBeNull()
expect(view.container.querySelector('[data-decoration="token"]')).not.toBeNull()
})
it('an inserted reference renders as a chip at its placeholder offset', () => {
const { view, shell } = bench()
act(() => {
shell.setDraft('参考 @w1 内容')
shell.insertReference(
{ source: 'subagent', ref: 'w1', label: '@w1', clipboardText: '@w1' },
{ start: 3, end: 6, draftRev: shell.snapshot.draftRev },
)
})
const chip = view.container.querySelector('[data-decoration="chip"]')
expect(chip?.textContent).toBe('@w1')
expect(shell.snapshot.occurrences).toHaveLength(1)
// The draft carries exactly one placeholder char where the token was.
expect(shell.snapshot.draft).toBe('参考 \uFFFC 内容')
})
it('a lexicon-matched plain token renders the text-ref mark (decision 21)', () => {
const lexicon = new Map<'/' | '@', readonly string[]>([['/', ['fixture-demo']]])
const { view, shell } = bench({ lexicon })
act(() => { shell.setDraft('use /fixture-demo now') })
const mark = view.container.querySelector('[data-decoration="text-ref"]')
expect(mark?.textContent).toBe('/fixture-demo')
// Editing the token out of match shape drops the decoration.
act(() => { shell.setDraft('use /fixture-dem now') })
expect(view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
})
})
describe('insertText (decision 21 scoped event body)', () => {
it('splices plain text over the span and reports success as true', () => {
const { shell } = bench({ draft: '/fix' })
const ok = shell.insertText('/fixture-demo ', { start: 0, end: 4, draftRev: shell.snapshot.draftRev })
expect(ok).toBe(true)
expect(shell.snapshot.draft).toBe('/fixture-demo ')
expect(shell.snapshot.occurrences).toEqual([])
})
it('a stale draftRev refuses whole: false, draft untouched', () => {
const { shell } = bench({ draft: '/fix' })
const span = { start: 0, end: 4, draftRev: shell.snapshot.draftRev }
act(() => { shell.setDraft('/fixX') })
expect(shell.insertText('/fixture-demo ', span)).toBe(false)
expect(shell.snapshot.draft).toBe('/fixX')
})
})
describe('strips and variants', () => {
it('derives the failure strip from promptError (ordinary failure — no transaction UI, no Retry)', () => {
const send = bench({ promptError: { op: 'send', error: { code: 'agent-busy', message: 'boom', details: { reason: 'boom' } } } })
expect(send.view.container.querySelector('[role="alert"]')?.textContent).toBe('boom (agent-busy)')
expect(send.view.queryByRole('button', { name: 'Retry' })).toBeNull()
})
it('renders the notice strip from the machine notice store', () => {
const { view, shell } = bench()
act(() => { shell.notify('error', '命令失败了') })
expect(view.getByText('命令失败了')).toBeTruthy()
})
it('hero variant adds the hero class and accessory row renders', () => {
const { view } = setup({ variant: 'hero', accessory: <i data-testid="acc" /> })
const { view } = bench({ variant: 'hero', accessory: <i data-testid="acc" /> })
expect(view.getByTestId('acc')).toBeTruthy()
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
})
it('renders overlay anchor and left/right slot items', () => {
const { view } = bench({
overlay: <i data-testid="ov" />,
leftItems: <i data-testid="li" />,
rightItems: <i data-testid="ri" />,
})
expect(view.getByTestId('ov')).toBeTruthy()
expect(view.getByTestId('li')).toBeTruthy()
expect(view.getByTestId('ri')).toBeTruthy()
})
})
describe('placeholder chrome', () => {
it('renders attach / Plan / Read-only / model controls', () => {
const { view } = setup()
describe('placeholder chrome and control seats', () => {
it('renders attach + Access placeholder; plan/model seats render EMPTY without entries (B ruling)', () => {
const { view, slotCalls } = bench()
expect(view.getByLabelText('Add attachment')).toBeTruthy()
expect((view.getByLabelText('Plan mode') as HTMLSelectElement).value).toBe('plan')
expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly')
expect((view.getByLabelText('Model') as HTMLSelectElement).value).toBe('v4-pro-high')
// Both seats dispatched, nothing rendered.
expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model'])
expect(view.queryByLabelText('Plan mode')).toBeNull()
expect(view.queryByLabelText('Model')).toBeNull()
})
it('native select change updates the selected option', () => {
const { view } = setup()
const plan = view.getByLabelText('Plan mode') as HTMLSelectElement
fireEvent.change(plan, { target: { value: 'agent' } })
expect(plan.value).toBe('agent')
const access = view.getByLabelText('Access mode') as HTMLSelectElement
fireEvent.change(access, { target: { value: 'readwrite' } })
expect(access.value).toBe('readwrite')
it('a registered entry fills its seat and receives the locked owner prop', () => {
const { view, slotCalls } = bench({
disabled: true,
planEntry: <i data-testid="plan-entry" />,
modelEntry: <i data-testid="model-entry" />,
})
expect(view.getByTestId('plan-entry')).toBeTruthy()
expect(view.getByTestId('model-entry')).toBeTruthy()
// The bar hands its chrome disable state to the filling entry.
expect(slotCalls.every(c => (c.owner as { locked: boolean }).locked === true)).toBe(true)
cleanup()
const live = bench({ running: true })
expect(live.slotCalls.every(c => (c.owner as { locked: boolean }).locked === false)).toBe(true)
})
it('model select can drop the High option', () => {
const { view } = setup()
const model = view.getByLabelText('Model') as HTMLSelectElement
fireEvent.change(model, { target: { value: 'v4-pro' } })
expect(model.value).toBe('v4-pro')
expect(model.selectedOptions[0]?.textContent).toBe('DeepSeek-V4-Pro')
})
it('running locks the chrome selects and attach control', () => {
const { view } = setup({ running: true })
it('disabled locks the Access placeholder and attach control (running does not)', () => {
const { view } = bench({ disabled: true })
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('Plan mode') as HTMLSelectElement).disabled).toBe(true)
expect((view.getByLabelText('Model') as HTMLSelectElement).disabled).toBe(true)
expect((view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(true)
cleanup()
const live = bench({ running: true })
expect((live.view.getByLabelText('Access mode') as HTMLSelectElement).disabled).toBe(false)
})
})

View File

@@ -0,0 +1,846 @@
/**
* InputMachine unit account (design §9.1, eng. plan §3.9-3.12): the submit
* plane carried over from the InputCore era (adjudication, span CAS, drift
* guard, anti-backwash), plus the occurrence table (shift / whole-chip
* deletion / same-name independence), the self-managed undo log (typing
* coalescing, paste two-stage undo, redo chain), consume-token guards, the
* paste attempt lifecycle, projectClipboard, and the decoration projection.
* Pure event sequences — no React, no DOM, no ambient clock.
*/
import { describe, expect, it } from 'vitest'
import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { InputEffect, SubmitAttempt } from '../src/client/input/contract.ts'
import { InputMachine, PLACEHOLDER, projectClipboard } from '../src/client/input/machine.ts'
import { deriveDecorations, scanTextRefs } from '../src/client/input/decorations.ts'
const P = PLACEHOLDER
function claimOf(name: string, hint?: string): CommandClaim {
return {
token: `/${name} `,
...(hint !== undefined ? { hint } : {}),
submit: async () => ({ kind: 'success' }),
}
}
function refOf(name: string, source = 'skill'): ReferenceInsert {
return { source, ref: name, label: name, clipboardText: `/${name}` }
}
function spanOf(m: InputMachine, start: number, end: number): TokenSpan {
return { start, end, draftRev: m.state.draftRev }
}
function effectAt<T extends InputEffect['type']>(
effects: readonly InputEffect[], index: number, type: T,
): Extract<InputEffect, { type: T }> {
const e = effects[index]
expect(e?.type).toBe(type)
return e as Extract<InputEffect, { type: T }>
}
/** Drive plain → adjudicating and hand back the minted attempt. */
function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
m.dispatch({ type: 'draft-changed', draft })
const fx = m.dispatch({ type: 'enter', mode })
return effectAt(fx, 0, 'adjudicate').attempt
}
/** Drive plain → claimed → submitting and hand back attempt + claim. */
function enterSubmitting(m: InputMachine, name: string, args: string): { attempt: SubmitAttempt; claim: CommandClaim } {
const claim = claimOf(name)
m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` })
m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) })
m.dispatch({ type: 'draft-changed', draft: claim.token + args })
const fx = m.dispatch({ type: 'enter', mode: 'queue' })
return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
}
function staleAttempt(): SubmitAttempt {
return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '' }
}
describe('input-machine: plain × enter', () => {
it('empty and whitespace-only drafts produce nothing', () => {
const m = new InputMachine()
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
m.dispatch({ type: 'draft-changed', draft: ' \n ' })
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
expect(m.state.phase).toBe('plain')
})
it('non-command text falls to the default sink with the given mode', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'hello world' })
expect(m.dispatch({ type: 'enter', mode: 'steer' }))
.toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'steer' }])
expect(m.state.phase).toBe('plain')
})
it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/goal x' })
const fx = m.dispatch({ type: 'enter', mode: 'queue' })
const eff = effectAt(fx, 0, 'adjudicate')
expect(eff.draft).toBe('/goal x')
expect(eff.attempt.draftSnapshot).toBe('/goal x')
expect(eff.attempt.signal.aborted).toBe(false)
expect(m.state.phase).toBe('adjudicating')
})
it('leading is judged after trim including newlines', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' })
expect(m.dispatch({ type: 'enter', mode: 'queue' })[0]?.type).toBe('adjudicate')
})
it('a non-whitespace prefix before "/" is not leading — default sink', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' })
expect(m.dispatch({ type: 'enter', mode: 'queue' }))
.toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }])
})
})
describe('input-machine: adjudication outcomes', () => {
it('{claim} moves to submitting; args split on the first whitespace, newlines kept', () => {
const m = new InputMachine()
const attempt = enterAdjudicating(m, '/goal x\ny')
const fx = m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
const eff = effectAt(fx, 0, 'begin-submit')
expect(eff.args).toBe('x\ny')
expect(eff.attempt.seq).toBe(attempt.seq)
expect(m.state.phase).toBe('submitting')
expect(m.state.claim).toEqual({ token: '/goal ' })
})
it('bare "/goal" claim yields empty args; leading whitespace snapshot yields trimmed args', () => {
const a = new InputMachine()
const attemptA = enterAdjudicating(a, '/goal')
expect(effectAt(a.dispatch({ type: 'adjudicated', attempt: attemptA, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('')
const b = new InputMachine()
const attemptB = enterAdjudicating(b, '\n\n/goal x')
expect(effectAt(b.dispatch({ type: 'adjudicated', attempt: attemptB, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('x')
})
it('undefined outcome falls back to the default sink preserving the enter mode', () => {
const m = new InputMachine()
const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
.toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }])
expect(m.state.phase).toBe('plain')
})
it("'handled' lands plain with zero effects (popup shell path)", () => {
const m = new InputMachine()
const attempt = enterAdjudicating(m, '/model')
expect(m.dispatch({ type: 'adjudicated', attempt, outcome: 'handled' })).toEqual([])
expect(m.state.phase).toBe('plain')
expect(m.state.draft).toBe('/model')
})
it('adjudication failure notices and keeps the draft — no silent downgrade', () => {
const m = new InputMachine()
const attempt = enterAdjudicating(m, '/goal x')
expect(m.dispatch({ type: 'adjudication-failed', attempt, message: 'warmup failed' }))
.toEqual([{ type: 'notice', level: 'error', text: 'warmup failed' }])
expect(m.state.phase).toBe('plain')
expect(m.state.draft).toBe('/goal x')
})
it('enter is a no-op while adjudicating (pending lock)', () => {
const m = new InputMachine()
enterAdjudicating(m, '/goal x')
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
expect(m.state.phase).toBe('adjudicating')
})
it('a stale attempt on adjudicated/adjudication-failed is dropped: same state, zero effects', () => {
const m = new InputMachine()
enterAdjudicating(m, '/goal x')
expect(m.dispatch({ type: 'adjudicated', attempt: staleAttempt(), outcome: { claim: claimOf('goal') } })).toEqual([])
expect(m.dispatch({ type: 'adjudication-failed', attempt: staleAttempt(), message: 'x' })).toEqual([])
expect(m.state.phase).toBe('adjudicating')
})
it('an adjudicated result arriving after release is dropped (anti-backwash)', () => {
const m = new InputMachine()
const attempt = enterAdjudicating(m, '/goal x')
m.dispatch({ type: 'release' })
expect(m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })).toEqual([])
expect(m.state.phase).toBe('plain')
})
})
describe('input-machine: begin-command CAS', () => {
it('valid span replaces it with the token and enters claimed; success = draftRev advance', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
const before = m.state.draftRev
const fx = m.dispatch({ type: 'begin-command', claim: claimOf('goal', 'objective'), span: spanOf(m, 0, 3) })
expect(fx).toEqual([])
expect(m.state.draftRev).toBeGreaterThan(before)
expect(m.state.draft).toBe('/goal ')
expect(m.state.phase).toBe('claimed')
expect(m.state.claim).toEqual({ token: '/goal ', hint: 'objective' })
})
it('a leading-whitespace prefix is dropped so the startsWith watch holds', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '\n\n/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 2, 5) })
expect(m.state.draft).toBe('/goal ')
m.dispatch({ type: 'draft-changed', draft: '/goal x' })
expect(m.state.phase).toBe('claimed')
})
it('a stale draftRev no-ops the whole action — no state change, no revision bump', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
const span = spanOf(m, 0, 3)
m.dispatch({ type: 'draft-changed', draft: '/goX' })
const rev = m.state.draftRev
expect(m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span })).toEqual([])
expect(m.state).toMatchObject({ phase: 'plain', draft: '/goX', draftRev: rev })
})
it('a non-whitespace prefix before the span no-ops (leading-trigger contract)', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'x /go' })
expect(m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 2, 5) })).toEqual([])
expect(m.state.phase).toBe('plain')
})
it('claimed overwrites in place — no stack', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
m.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(m, 0, 6) })
expect(m.state.draft).toBe('/model ')
expect(m.state.claim?.token).toBe('/model ')
expect(m.state.phase).toBe('claimed')
})
it('submitting rejects begin-command (lock)', () => {
const m = new InputMachine()
enterSubmitting(m, 'goal', 'x')
expect(m.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(m, 0, 6) })).toEqual([])
expect(m.state.claim?.token).toBe('/goal ')
expect(m.state.phase).toBe('submitting')
})
it('undo reverts the claim transaction and the watch releases the claim', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
m.dispatch({ type: 'undo' })
expect(m.state).toMatchObject({ draft: '/go', phase: 'plain' })
expect(m.state.claim).toBeUndefined()
})
})
describe('input-machine: insert-ref and the occurrence table', () => {
it('valid span becomes one placeholder + one occurrence with cached projections', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'see @wor now' })
const fx = m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 4, 8) })
expect(fx).toEqual([])
expect(m.state.draft).toBe(`see ${P} now`)
expect(m.state.occurrences).toEqual([{
occurrenceId: 1, source: 'subagent', ref: 'worker-1', offset: 4,
label: 'worker-1', clipboardText: '/worker-1',
}])
expect(m.state.phase).toBe('plain')
})
it('same-named references stay independent: distinct occurrenceIds, one deletion leaves the other', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/alp' })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
m.dispatch({ type: 'draft-changed', draft: `${P} and /alp`, editRange: { start: 1, end: 1, insertedLength: 9 } })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 6, 10) })
expect(m.state.draft).toBe(`${P} and ${P}`)
expect(m.state.occurrences.map(o => o.occurrenceId)).toEqual([1, 2])
// Delete the first chip whole; the second survives with its own identity.
m.dispatch({ type: 'draft-changed', draft: ` and ${P}`, editRange: { start: 0, end: 1, insertedLength: 0 } })
expect(m.state.occurrences).toEqual([expect.objectContaining({ occurrenceId: 2, offset: 5 })])
})
it('claimed stays claimed across an inline insert (inline "@" during command args)', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
m.dispatch({ type: 'draft-changed', draft: '/goal ask @wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 10, 14) })
expect(m.state.draft).toBe(`/goal ask ${P}`)
expect(m.state.phase).toBe('claimed')
expect(m.state.occurrences).toHaveLength(1)
})
it('a stale draftRev no-ops: no draft change, no occurrence', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'see @wor' })
const span = spanOf(m, 4, 8)
m.dispatch({ type: 'draft-changed', draft: 'see @work' })
expect(m.dispatch({ type: 'insert-ref', reference: refOf('w'), span })).toEqual([])
expect(m.state.occurrences).toEqual([])
})
})
describe('input-machine: occurrence reconciliation on draft edits', () => {
/** Machine with one chip at offset 4 inside `see ${P} now`. */
function withChip(): InputMachine {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'see @wor now' })
m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 4, 8) })
return m
}
it('an edit before the placeholder shifts the offset by the length delta (explicit editRange)', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: `I see ${P} now`, editRange: { start: 0, end: 0, insertedLength: 2 } })
expect(m.state.occurrences[0]?.offset).toBe(6)
m.dispatch({ type: 'draft-changed', draft: `see ${P} now`, editRange: { start: 0, end: 2, insertedLength: 0 } })
expect(m.state.occurrences[0]?.offset).toBe(4)
})
it('an edit after the placeholder leaves the offset alone', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: `see ${P} later`, editRange: { start: 6, end: 9, insertedLength: 5 } })
expect(m.state.occurrences[0]?.offset).toBe(4)
})
it('a deletion covering the placeholder removes the whole occurrence', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: 'see now', editRange: { start: 4, end: 5, insertedLength: 0 } })
expect(m.state.occurrences).toEqual([])
expect(m.state.draft).toBe('see now')
})
it('a replacement spanning the placeholder removes the occurrence and keeps the replacement text', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: 'see all of it now', editRange: { start: 4, end: 5, insertedLength: 9 } })
expect(m.state.occurrences).toEqual([])
})
it('without editRange the prefix/suffix diff scan recovers the edit (shift path)', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: `see there ${P} now` })
expect(m.state.occurrences[0]?.offset).toBe(10)
})
it('without editRange the diff scan detects placeholder deletion', () => {
const m = withChip()
m.dispatch({ type: 'draft-changed', draft: 'see now' })
expect(m.state.occurrences).toEqual([])
})
it('an identical draft is a no-op: no revision bump, no undo entry', () => {
const m = withChip()
const rev = m.state.draftRev
expect(m.dispatch({ type: 'draft-changed', draft: m.state.draft })).toEqual([])
expect(m.state.draftRev).toBe(rev)
})
})
describe('input-machine: newline transaction (F1)', () => {
it('inserts \\n at the caret and shifts trailing occurrences', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'ab @wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 3, 7) })
m.dispatch({ type: 'newline', selection: { start: 2, end: 2 } })
expect(m.state.draft).toBe(`ab\n ${P}`)
expect(m.state.occurrences[0]?.offset).toBe(4)
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe(`ab ${P}`)
})
it('replaces a selection, breaks the claim prefix when leading, and rejects out-of-bounds', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
expect(m.dispatch({ type: 'newline', selection: { start: 0, end: 99 } })).toEqual([])
expect(m.state.phase).toBe('claimed')
m.dispatch({ type: 'newline', selection: { start: 0, end: 0 } })
expect(m.state.draft).toBe('\n/goal ')
expect(m.state.phase).toBe('plain')
expect(m.state.claim).toBeUndefined()
})
})
describe('input-machine: consume-token guards', () => {
it('span guard: CAS pass deletes the token — success observable as a draftRev advance', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/model rest' })
const before = m.state.draftRev
m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
expect(m.state.draftRev).toBeGreaterThan(before)
expect(m.state.draft).toBe('rest')
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('/model rest')
})
it('span guard: a stale draftRev refuses — no deletion, no revision bump', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/model' })
const span = spanOf(m, 0, 6)
m.dispatch({ type: 'draft-changed', draft: '/model x' })
const rev = m.state.draftRev
expect(m.dispatch({ type: 'consume-token', guard: { kind: 'span', span } })).toEqual([])
expect(m.state).toMatchObject({ draft: '/model x', draftRev: rev })
})
it('bare-token guard: trimmed equality clears the draft; mismatch refuses', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: ' /model \n' })
m.dispatch({ type: 'consume-token', guard: { kind: 'bare-token', token: '/model' } })
expect(m.state.draft).toBe('')
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe(' /model \n')
m.dispatch({ type: 'draft-changed', draft: '/model extra' })
const rev = m.state.draftRev
expect(m.dispatch({ type: 'consume-token', guard: { kind: 'bare-token', token: '/model' } })).toEqual([])
expect(m.state).toMatchObject({ draft: '/model extra', draftRev: rev })
})
it('a chip elsewhere in the draft shifts across a span consume', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/model @wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 7, 11) })
m.dispatch({ type: 'consume-token', guard: { kind: 'span', span: spanOf(m, 0, 7) } })
expect(m.state.draft).toBe(P)
expect(m.state.occurrences[0]?.offset).toBe(0)
})
})
describe('input-machine: undo / redo', () => {
it('the default constant clock coalesces contiguous single-char typing into one transaction', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
m.dispatch({ type: 'draft-changed', draft: 'ab', editRange: { start: 1, end: 1, insertedLength: 1 } })
m.dispatch({ type: 'draft-changed', draft: 'abc', editRange: { start: 2, end: 2, insertedLength: 1 } })
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('')
m.dispatch({ type: 'redo' })
expect(m.state.draft).toBe('abc')
})
it('the merge window splits typing runs: within merges, beyond opens a new transaction', () => {
let t = 0
const m = new InputMachine({ mergeWindowMs: 1000, now: () => t })
m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
t = 900
m.dispatch({ type: 'draft-changed', draft: 'ab', editRange: { start: 1, end: 1, insertedLength: 1 } })
t = 2500 // beyond the window from the previous char
m.dispatch({ type: 'draft-changed', draft: 'abc', editRange: { start: 2, end: 2, insertedLength: 1 } })
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('ab')
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('')
})
it('non-contiguous or multi-char edits never merge into a typing run', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
m.dispatch({ type: 'draft-changed', draft: 'ba', editRange: { start: 0, end: 0, insertedLength: 1 } })
m.dispatch({ type: 'draft-changed', draft: 'baXY', editRange: { start: 2, end: 2, insertedLength: 2 } })
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('ba')
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('a')
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('')
})
it('a new transaction cuts the redo chain', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'a', editRange: { start: 0, end: 0, insertedLength: 1 } })
m.dispatch({ type: 'undo' })
m.dispatch({ type: 'draft-changed', draft: 'z', editRange: { start: 0, end: 0, insertedLength: 1 } })
expect(m.dispatch({ type: 'redo' })).toEqual([])
expect(m.state.draft).toBe('z')
})
it('undo on an empty log and redo on an empty chain are no-ops', () => {
const m = new InputMachine()
expect(m.dispatch({ type: 'undo' })).toEqual([])
expect(m.dispatch({ type: 'redo' })).toEqual([])
})
it('the log ring caps at 100 transactions', () => {
let t = 0
const m = new InputMachine({ mergeWindowMs: 0, now: () => (t += 10) })
let draft = ''
for (let i = 0; i < 110; i += 1) {
draft += 'x'
m.dispatch({ type: 'draft-changed', draft, editRange: { start: i, end: i, insertedLength: 1 } })
}
for (let i = 0; i < 100; i += 1) m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('x'.repeat(10))
expect(m.dispatch({ type: 'undo' })).toEqual([])
expect(m.state.draft).toBe('x'.repeat(10))
})
it('undo restores the occurrence table with the draft (chip resurrection)', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '@wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 0, 4) })
m.dispatch({ type: 'draft-changed', draft: '', editRange: { start: 0, end: 1, insertedLength: 0 } })
expect(m.state.occurrences).toEqual([])
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe(P)
expect(m.state.occurrences).toHaveLength(1)
})
it('a committed submit clears the log: undo cannot resurrect sent content', () => {
const m = new InputMachine()
const { attempt } = enterSubmitting(m, 'goal', 'x')
m.dispatch({ type: 'submit-settled', attempt, ok: true })
expect(m.state.draft).toBe('')
expect(m.dispatch({ type: 'undo' })).toEqual([])
expect(m.state.draft).toBe('')
})
})
describe('input-machine: paste plane', () => {
it('paste replaces the selection as one transaction and opens a match attempt', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'abc' })
m.dispatch({ type: 'paste-begin', text: 'XY', selection: { start: 1, end: 2 }, generation: 7 })
expect(m.state.draft).toBe('aXYc')
expect(m.state.paste).toEqual({ attemptId: 1, insertedRange: { start: 1, end: 3 }, generation: 7 })
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('abc')
})
it('pasted text is sanitized: raw U+FFFC never enters the draft as a fake chip', () => {
const m = new InputMachine()
m.dispatch({ type: 'paste-begin', text: `x${P}y`, selection: { start: 0, end: 0 } })
expect(m.state.draft).toBe('xy')
expect(m.state.occurrences).toEqual([])
})
it('sync hot-snapshot components mint inside the SAME transaction: one undo returns to pre-paste', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'hi ' })
m.dispatch({
type: 'paste-begin', text: '/alpha x', selection: { start: 3, end: 3 },
components: [{ start: 0, end: 6, reference: refOf('alpha') }],
})
expect(m.state.draft).toBe(`hi ${P} x`)
expect(m.state.occurrences).toEqual([expect.objectContaining({ ref: 'alpha', offset: 3 })])
expect(m.state.paste?.insertedRange).toEqual({ start: 3, end: 6 })
m.dispatch({ type: 'undo' })
expect(m.state).toMatchObject({ draft: 'hi ', occurrences: [] })
})
it('async upgrade is an INDEPENDENT transaction: undo #1 → token text, undo #2 → pre-paste', () => {
const m = new InputMachine()
m.dispatch({ type: 'paste-begin', text: '/alpha rest', selection: { start: 0, end: 0 } })
expect(m.state.paste?.attemptId).toBe(1)
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
expect(m.state.draft).toBe(`${P} rest`)
expect(m.state.occurrences).toHaveLength(1)
m.dispatch({ type: 'undo' })
expect(m.state).toMatchObject({ draft: '/alpha rest', occurrences: [] })
m.dispatch({ type: 'undo' })
expect(m.state.draft).toBe('')
})
it('the attempt survives upgrades: successive tokens re-CAS against the advanced revision', () => {
const m = new InputMachine()
m.dispatch({ type: 'paste-begin', text: '/alpha /beta', selection: { start: 0, end: 0 } })
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 7 })
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 2, 7), reference: refOf('beta') })
expect(m.state.draft).toBe(`${P} ${P}`)
expect(m.state.occurrences.map(o => o.ref)).toEqual(['alpha', 'beta'])
expect(m.state.paste?.insertedRange).toEqual({ start: 0, end: 3 })
})
it('a stale span CAS drops one upgrade without ending the attempt', () => {
const m = new InputMachine()
m.dispatch({ type: 'paste-begin', text: '/alpha /beta', selection: { start: 0, end: 0 } })
const preSpan = spanOf(m, 7, 12)
m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })
expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: preSpan, reference: refOf('beta') })).toEqual([])
expect(m.state.occurrences).toHaveLength(1)
expect(m.state.paste).toBeDefined()
})
it('any new input transaction ends the attempt; late upgrades drop whole', () => {
const m = new InputMachine()
m.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
m.dispatch({ type: 'draft-changed', draft: '/alpha!', editRange: { start: 6, end: 6, insertedLength: 1 } })
expect(m.state.paste).toBeUndefined()
expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })).toEqual([])
expect(m.state.occurrences).toEqual([])
})
it('invalidate-paste (caret/selection/slash activity) and submit start end the attempt', () => {
const a = new InputMachine()
a.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
a.dispatch({ type: 'invalidate-paste' })
expect(a.state.paste).toBeUndefined()
const b = new InputMachine()
b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } })
b.dispatch({ type: 'enter', mode: 'queue' })
expect(b.state.paste).toBeUndefined()
})
it('a mismatched attemptId is dropped (superseded paste)', () => {
const m = new InputMachine()
m.dispatch({ type: 'paste-begin', text: '/alpha', selection: { start: 0, end: 0 } })
m.dispatch({ type: 'paste-begin', text: ' /beta', selection: { start: 6, end: 6 } })
expect(m.state.paste?.attemptId).toBe(2)
expect(m.dispatch({ type: 'paste-upgrade', attemptId: 1, span: spanOf(m, 0, 6), reference: refOf('alpha') })).toEqual([])
expect(m.state.occurrences).toEqual([])
})
})
describe('input-machine: set-invalid styling bits', () => {
it('flags exactly the listed occurrences without a transaction', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/alp' })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
m.dispatch({ type: 'draft-changed', draft: `${P} /bet`, editRange: { start: 1, end: 1, insertedLength: 5 } })
m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, 2, 6) })
const rev = m.state.draftRev
m.dispatch({ type: 'set-invalid', invalidIds: [1] })
expect(m.state.draftRev).toBe(rev)
expect(m.state.occurrences.map(o => o.invalid === true)).toEqual([true, false])
// Recovery: the same source/ref resolving again clears the bit.
m.dispatch({ type: 'set-invalid', invalidIds: [] })
expect(m.state.occurrences.every(o => o.invalid === undefined)).toBe(true)
})
it('a no-change call keeps the table reference (no spurious publish)', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/alp' })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
const table = m.state.occurrences
expect(m.dispatch({ type: 'set-invalid', invalidIds: [] })).toEqual([])
expect(m.state.occurrences).toBe(table)
})
})
describe('input-machine: projectClipboard', () => {
it('expands each placeholder to its occurrence clipboardText in draft order', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'use /alp' })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 4, 8) })
m.dispatch({ type: 'draft-changed', draft: `use ${P} then /bet`, editRange: { start: 5, end: 5, insertedLength: 10 } })
m.dispatch({ type: 'insert-ref', reference: refOf('beta'), span: spanOf(m, 11, 15) })
expect(m.state.draft).toBe(`use ${P} then ${P}`)
expect(projectClipboard(m.state)).toBe('use /alpha then /beta')
})
it('is the identity on a chip-free draft', () => {
expect(projectClipboard({ draft: 'plain text', occurrences: [] })).toBe('plain text')
})
})
describe('decorations: scanTextRefs (decision 21)', () => {
const LEX: ReadonlyMap<'/' | '@', readonly string[]> = new Map([
['/', ['commit-helper', 'fixture-demo']],
['@', ['worker-1']],
])
it('matches lexicon tokens at line start and after whitespace, in draft order', () => {
expect(scanTextRefs('/commit-helper then @worker-1 ok', LEX)).toEqual([
{ start: 0, end: 14, trigger: '/' },
{ start: 20, end: 29, trigger: '@' },
])
})
it('a cold (empty) lexicon scans nothing', () => {
expect(scanTextRefs('/commit-helper', new Map())).toEqual([])
})
it('names off the lexicon do not match; triggers are routed per lexicon list', () => {
expect(scanTextRefs('/unknown @commit-helper', LEX)).toEqual([])
})
it('word boundary: a trigger glued to text never matches', () => {
expect(scanTextRefs('x/commit-helper', LEX)).toEqual([])
expect(scanTextRefs('a@worker-1', LEX)).toEqual([])
})
it('tokens never cross a newline; a token straight after one matches', () => {
expect(scanTextRefs('line\n/commit-helper', LEX)).toEqual([
{ start: 5, end: 19, trigger: '/' },
])
})
it('deriveDecorations threads the lexicon through as textRefs', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: 'use /commit-helper now' })
expect(deriveDecorations(m.state, LEX).textRefs).toEqual([
{ start: 4, end: 18, trigger: '/' },
])
})
})
describe('input-machine: decorations', () => {
it('projects chips from the occurrence table with identity, offset, label, and invalid bit', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/alp' })
m.dispatch({ type: 'insert-ref', reference: refOf('alpha'), span: spanOf(m, 0, 4) })
m.dispatch({ type: 'set-invalid', invalidIds: [1] })
expect(deriveDecorations(m.state)).toEqual({
token: null,
chips: [{ occurrenceId: 1, offset: 0, label: 'alpha', invalid: true }],
textRefs: [],
hint: null,
})
})
it('claim token range and ghost hint show while claimed with blank args; args clear the hint', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal', 'objective'), span: spanOf(m, 0, 3) })
expect(deriveDecorations(m.state)).toEqual({
token: { start: 0, end: 6 },
chips: [],
textRefs: [],
hint: 'objective',
})
m.dispatch({ type: 'draft-changed', draft: '/goal x' })
expect(deriveDecorations(m.state)).toMatchObject({ token: { start: 0, end: 6 }, hint: null })
})
it('the token range persists through submitting; a hintless claim never ghosts', () => {
const m = new InputMachine()
enterSubmitting(m, 'goal', '')
expect(deriveDecorations(m.state)).toEqual({ token: { start: 0, end: 6 }, chips: [], textRefs: [], hint: null })
})
})
describe('input-machine: claimed lifecycle', () => {
it('breaking startsWith(token) auto-releases back to plain', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
m.dispatch({ type: 'draft-changed', draft: '/goal make' })
expect(m.state.phase).toBe('claimed')
m.dispatch({ type: 'draft-changed', draft: '/goa make' })
expect(m.state.phase).toBe('plain')
expect(m.state.claim).toBeUndefined()
expect(m.state.draft).toBe('/goa make')
})
it('explicit release returns to plain when nothing is in flight', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '/go' })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
expect(m.dispatch({ type: 'release' })).toEqual([])
expect(m.state.phase).toBe('plain')
expect(m.state.claim).toBeUndefined()
})
it('enter begins the submit transaction: args = draft minus token, multi-line legal', () => {
const m = new InputMachine()
const { attempt, claim } = enterSubmitting(m, 'goal', 'line1\nline2')
expect(attempt.draftSnapshot).toBe('/goal line1\nline2')
m.dispatch({ type: 'submit-settled', attempt, ok: true })
expect(m.state.draft).toBe('')
expect(claim.token).toBe('/goal ')
})
})
describe('input-machine: submitting transaction', () => {
it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => {
const m = new InputMachine()
enterSubmitting(m, 'goal', 'x')
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([])
expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' })
})
it('commit clears draft and occurrences, releases the claim, and relays the outcome text', () => {
const m = new InputMachine()
m.dispatch({ type: 'draft-changed', draft: '@wor' })
m.dispatch({ type: 'insert-ref', reference: refOf('worker-1', 'subagent'), span: spanOf(m, 0, 4) })
m.dispatch({ type: 'draft-changed', draft: `${P}/go`, editRange: { start: 1, end: 1, insertedLength: 3 } })
m.dispatch({ type: 'draft-changed', draft: '/go', editRange: { start: 0, end: 1, insertedLength: 0 } })
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
m.dispatch({ type: 'draft-changed', draft: '/goal go' })
const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } })
expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }])
expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] })
expect(m.state.claim).toBeUndefined()
})
it('rollback with an undeviated draft keeps the snapshot and re-enters claimed (same claim)', () => {
const m = new InputMachine()
const { attempt } = enterSubmitting(m, 'goal', 'x')
const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
expect(m.state).toMatchObject({ phase: 'claimed', draft: '/goal x' })
expect(m.state.claim?.token).toBe('/goal ')
})
it('rollback with a deviated draft only notices — the newer input wins', () => {
const m = new InputMachine()
const { attempt } = enterSubmitting(m, 'goal', 'x')
m.dispatch({ type: 'draft-changed', draft: 'fresh typing' })
const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
expect(m.state).toMatchObject({ phase: 'plain', draft: 'fresh typing' })
expect(m.state.claim).toBeUndefined()
})
it('enter-path rollback cannot re-enter claimed when the snapshot never carried the bare token prefix', () => {
// '\n\n/goal x' round-trips through adjudication; the whitespace prefix
// would instantly break the claimed watch, so rollback lands plain.
const m = new InputMachine()
const attempt = enterAdjudicating(m, '\n\n/goal x')
m.dispatch({ type: 'adjudicated', attempt, outcome: { claim: claimOf('goal') } })
const fx = m.dispatch({ type: 'submit-settled', attempt, ok: false, message: 'boom' })
expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }])
expect(m.state).toMatchObject({ phase: 'plain', draft: '\n\n/goal x' })
})
it('a stale settle after rollback + resubmit is dropped (anti-backwash)', () => {
const m = new InputMachine()
const { attempt: first } = enterSubmitting(m, 'goal', 'x')
m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' })
const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
expect(second.seq).not.toBe(first.seq)
expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([])
expect(m.state.phase).toBe('submitting')
m.dispatch({ type: 'submit-settled', attempt: second, ok: true })
expect(m.state.draft).toBe('')
})
it('release mid-flight aborts the attempt and later settles are dropped', () => {
const m = new InputMachine()
const { attempt } = enterSubmitting(m, 'goal', 'x')
expect(m.dispatch({ type: 'release' })).toEqual([])
expect(attempt.signal.aborted).toBe(true)
expect(m.state.phase).toBe('plain')
expect(m.dispatch({ type: 'submit-settled', attempt, ok: true })).toEqual([])
expect(m.state.draft).toBe('/goal x')
})
})
describe('input-machine: per-session isolation', () => {
it('one instance per session: A submitting never locks B; settles land on their own instance', () => {
const a = new InputMachine()
const b = new InputMachine()
const { attempt } = enterSubmitting(a, 'goal', 'from A')
// B stays fully live while A holds its lock.
b.dispatch({ type: 'draft-changed', draft: '/mo' })
b.dispatch({ type: 'begin-command', claim: claimOf('model'), span: spanOf(b, 0, 3) })
expect(b.state.phase).toBe('claimed')
expect(a.state.phase).toBe('submitting')
// A's commit falls back to A alone.
a.dispatch({ type: 'submit-settled', attempt, ok: true })
expect(a.state).toMatchObject({ phase: 'plain', draft: '' })
expect(b.state).toMatchObject({ phase: 'claimed', draft: '/model ' })
})
})

View File

@@ -0,0 +1,193 @@
// @vitest-environment jsdom
/**
* Impact-matrix projection tests (design §5.2 影响矩阵, row by row): what each
* phase projects onto the InputBar — enter routing, visuals (token color /
* hint / pending), edit freedom, and the published currency's claim seat.
* React over jsdom per the client testing discipline; the machine is real.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
afterEach(cleanup)
const SCTX = {} as ClientContext
const SID = 's1' as SessionId
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
})
const props: InputBarProps = {
sessionId: SID,
SessionProvider: ({ children }) => children(SID),
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
})) as InputBarProps['useSessions'],
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})) as InputBarProps['useWorkspaces'],
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
variant: 'composer',
}
return render(<InputBar {...props} />)
}
function bench(over?: { running?: boolean; disabled?: boolean; submit?: (args: string) => Promise<SubmitOutcome> }) {
const sink = vi.fn()
const shell = new SessionInputShell({ actx: SCTX, defaultSink: sink })
const wiring = shell
const view = mountBar(shell, over)
const textarea = view.container.querySelector('textarea')!
const claim = (token = '/goal ', hint = '目标') => {
act(() => {
shell.setDraft(token)
shell.beginCommand(
{
token, hint,
submit: over?.submit ?? (() => Promise.resolve({ kind: 'success' as const, source: 'command', name: 'goal' })),
},
{ start: 0, end: token.length, draftRev: shell.snapshot.draftRev },
)
})
}
return { view, textarea, shell, wiring, sink, claim }
}
describe('matrix row: plain', () => {
it('enter falls to the default sink; no claim on the currency; edits free', () => {
const { textarea, shell, sink } = bench()
fireEvent.change(textarea, { target: { value: '普通消息' } })
expect(shell.snapshot.claim).toBeUndefined()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('普通消息', 'queue')
expect(shell.snapshot.phase).toBe('plain')
})
})
describe('matrix row: claimed', () => {
it('publishes the claim currency, colors the token, hints while args are blank, and edits stay free', () => {
const { view, textarea, shell, claim } = bench()
claim()
expect(shell.snapshot.claim).toEqual({ token: '/goal ', hint: '目标' })
expect(view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
expect(view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标')
expect((textarea as HTMLTextAreaElement).readOnly).toBe(false)
// Free editing beyond the token: hint drops, claim holds.
fireEvent.change(textarea, { target: { value: '/goal 发布版本' } })
expect(shell.snapshot.phase).toBe('claimed')
expect(view.container.querySelector('[data-decoration="hint"]')).toBeNull()
})
it('enter routes to claim.submit (command lane, never the queue sink)', async () => {
const submit = vi.fn(() => Promise.resolve({ kind: 'success' as const, text: '完成', source: 'command', name: 'goal' }))
const { view, textarea, sink, claim } = bench({ submit })
claim()
fireEvent.change(textarea, { target: { value: '/goal 发布' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).not.toHaveBeenCalled()
await vi.waitFor(() => { expect(submit).toHaveBeenCalledWith('发布', SCTX) })
// Commit: draft cleared, notice surfaced, back to plain.
await vi.waitFor(() => { expect((textarea as HTMLTextAreaElement).value).toBe('') })
expect(view.getByText('完成')).toBeTruthy()
})
it('backspacing the token auto-releases to plain and the visuals vanish (scenario H)', () => {
const { view, textarea, shell, claim } = bench()
claim()
fireEvent.change(textarea, { target: { value: '/goa 发布' } }) // token broken
expect(shell.snapshot.phase).toBe('plain')
expect(shell.snapshot.claim).toBeUndefined()
expect(view.container.querySelector('[data-decoration="token"]')).toBeNull()
})
})
describe('matrix row: submitting', () => {
it('locks enter, renders pending + read-only, keeps the claim snapshot on the currency', async () => {
const submit = vi.fn(() => new Promise<SubmitOutcome>(() => {})) // never settles
const { view, textarea, shell, sink, claim } = bench({ submit })
claim()
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(shell.snapshot.phase).toBe('submitting')
expect(shell.snapshot.claim).toBeDefined()
expect((textarea as HTMLTextAreaElement).readOnly).toBe(true)
expect(view.container.querySelector('[data-input-pending]')).not.toBeNull()
// Enter is dead inside the lock (submit dispatch is microtask-deferred).
await vi.waitFor(() => { expect(submit).toHaveBeenCalledTimes(1) })
fireEvent.keyDown(textarea, { key: 'Enter' })
await Promise.resolve()
expect(submit).toHaveBeenCalledTimes(1)
expect(sink).not.toHaveBeenCalled()
})
it('rollback with unchanged draft returns to claimed with the notice; drifted draft only notices', async () => {
let rejectSubmit!: (e: Error) => void
const submit = vi.fn(() => new Promise<SubmitOutcome>((_res, rej) => { rejectSubmit = rej }))
const first = bench({ submit })
first.claim()
fireEvent.keyDown(first.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(submit).toHaveBeenCalled() })
act(() => { rejectSubmit(new Error('执行失败')) })
await vi.waitFor(() => { expect(first.shell.snapshot.phase).toBe('claimed') })
expect((first.textarea as HTMLTextAreaElement).value).toBe('/goal ')
expect(first.view.getByText('执行失败')).toBeTruthy()
cleanup()
// Drift: typing during flight wins; no restore, plain, notice only.
const submit2 = vi.fn(() => new Promise<SubmitOutcome>((_res, rej) => { rejectSubmit = rej }))
const second = bench({ submit: submit2 })
second.claim()
fireEvent.keyDown(second.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(submit2).toHaveBeenCalled() })
act(() => { second.shell.setDraft('用户飞行中打的新稿') })
act(() => { rejectSubmit(new Error('晚到失败')) })
await vi.waitFor(() => { expect(second.shell.snapshot.phase).toBe('plain') })
expect((second.textarea as HTMLTextAreaElement).value).toBe('用户飞行中打的新稿')
expect(second.view.getByText('晚到失败')).toBeTruthy()
})
})
describe('matrix row: locked (session disabled)', () => {
it('disables the textarea and chrome; the machine currency is untouched', () => {
const { view, textarea, shell } = bench({ disabled: true })
expect((textarea as HTMLTextAreaElement).disabled).toBe(true)
expect((view.getByLabelText('Add attachment') as HTMLButtonElement).disabled).toBe(true)
expect(shell.snapshot.phase).toBe('plain')
})
it('running does NOT lock (queue cut 1): typing and enter-queue stay live', () => {
const { textarea, sink } = bench({ running: true })
expect((textarea as HTMLTextAreaElement).disabled).toBe(false)
fireEvent.change(textarea, { target: { value: '排队' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
expect(sink).toHaveBeenCalledWith('排队', 'queue')
})
})
describe('matrix row: takeover (orthogonal axis)', () => {
it('the machine state survives outside the render tree (claim lives on the shell, not the DOM)', () => {
const { view, shell, claim } = bench()
claim()
// Takeover hides the composer (overlay chain keeps it mounted-but-hidden);
// even a full unmount keeps the claim: state lives on the resident shell.
view.unmount()
expect(shell.snapshot.phase).toBe('claimed')
expect(shell.snapshot.claim?.token).toBe('/goal ')
expect(shell.snapshot.draft).toBe('/goal ')
})
})

View File

@@ -0,0 +1,264 @@
// @vitest-environment jsdom
/**
* Scenario-chain integration (design §8 A/C/D/H/I): the real per-session
* SlashController pipeline over a real session scope (SessionsService over
* a listed host session) + a command source implementing the decision
* table's relevant cells + the real SessionInput machine (scoped-event
* listeners wired the way the hub does) + the real InputBar. ui-command
* itself is not a dependency of this package; the source below is the
* decision-table contract at the SlashSource seam.
*/
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
afterEach(cleanup)
/** Directory row driving kind derivation (input? = leadingInput, else execute). */
interface FakeCommand {
name: string
description: string
input?: { hint: string }
}
/** T6 decision-table source over an in-memory directory (menu/space/enter columns for leadingInput + execute). */
function commandSource(commands: FakeCommand[], execute: (line: string) => Promise<SubmitOutcome>) {
const resolve = (name: string): FakeCommand | undefined => commands.find(c => c.name === name)
const leadingClaim = (desc: FakeCommand): CommandClaim => ({
token: `/${desc.name} `,
...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
submit: args => execute(`/${desc.name} ${args}`),
})
const executed: string[] = []
return {
executed,
source: {
trigger: '/' as const,
name: 'command',
candidates: (_session: ClientSessionContext, req: { query: string; position: string }) =>
Promise.resolve(commands
.filter(c => c.name.startsWith(req.query))
.filter(c => req.position === 'leading' || c.input === undefined)
.map(c => ({ name: c.name, description: c.description, ...(c.input !== undefined ? { hint: c.input.hint } : {}) }))),
onPick: (pick: { candidate: { name: string } }): PickOutcome => {
const desc = resolve(pick.candidate.name)
if (desc === undefined) return undefined
if (desc.input !== undefined) return { claim: leadingClaim(desc) }
executed.push(`/${desc.name}`)
void execute(`/${desc.name}`)
return 'handled'
},
matchSpace: (_session: ClientSessionContext, token: string): PickOutcome => {
const desc = resolve(token.slice(1))
if (desc?.input === undefined) return undefined
return { claim: leadingClaim(desc) }
},
matchEnter: (_session: ClientSessionContext, line: string): Promise<PickOutcome> => {
const trimmed = line.trim()
const ws = trimmed.search(/\s/)
const token = ws === -1 ? trimmed : trimmed.slice(0, ws)
const desc = resolve(token.slice(1))
if (desc === undefined) return Promise.resolve(undefined)
if (desc.input !== undefined) return Promise.resolve({ claim: leadingClaim(desc) })
if (ws !== -1) return Promise.resolve(undefined) // execute with trailing → default sink
executed.push(trimmed)
void execute(trimmed)
return Promise.resolve('handled')
},
},
}
}
const COMMANDS: FakeCommand[] = [
{ name: 'goal', description: '设定目标', input: { hint: '目标内容' } },
{ name: 'compact', description: '压缩上下文' },
]
/** Real scope bench: SessionsService over one listed session + SlashController + shell listeners (the hub wiring shape). */
async function scopedBench(register?: (slash: SlashService) => void) {
const ctx = new Context()
const api = new FakeApiClient()
api.onWorkspaceList = () => Promise.resolve(ok({ items: [] }))
const sessionId = 'scenario-s1' as Parameters<SessionsService['open']>[0]
api.onList = () => Promise.resolve(ok({
items: [{ sessionId, updatedAt: 1, running: false, blank: false, cwd: '/w/a' }],
}) as never)
const sessions = new SessionsService(ctx, api) // provides 'sessions' itself
await sessions.refresh()
await Promise.resolve() // manager notifier flush
await ctx.plugin(SlashService).await()
const slash = ctx.get('slash') as SlashService
register?.(slash)
const actx = sessions.scope(sessionId)! as ClientContext
const controller = slash.sessionOf(actx)
const sink = vi.fn()
const shell = new SessionInputShell({ actx, slash: () => controller, defaultSink: sink })
// The hub's listener wiring, verbatim.
actx.on('slash/input-begin-command', req => shell.beginCommand(req.claim, req.span) ? true : undefined)
actx.on('slash/input-insert-reference', req => shell.insertReference(req.reference, req.span) ? true : undefined)
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
})
const barProps: InputBarProps = {
sessionId,
SessionProvider: ({ children }) => children(sessionId),
useSession: bindSnapshotSelector(sessionStore),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
})) as InputBarProps['useSessions'],
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})) as InputBarProps['useWorkspaces'],
useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions,
keyboard: shell,
renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(),
variant: 'composer',
}
const view = render(<InputBar {...barProps} />)
const textarea = view.container.querySelector('textarea')! as HTMLTextAreaElement
const type = (text: string): void => {
fireEvent.change(textarea, { target: { value: text } })
}
return { ctx, slash, controller, shell, wiring, view, textarea, type, sink }
}
async function bench(executeImpl?: (line: string) => Promise<SubmitOutcome>) {
const execute = vi.fn(executeImpl ?? ((line: string) =>
Promise.resolve({ kind: 'success' as const, text: `已执行 ${line}` })))
const { source, executed } = commandSource(COMMANDS, execute)
const base = await scopedBench((slash) => { slash.registerSource(source as never) })
return { ...base, execute, executed }
}
describe('scenario A: menu-pick /goal, type args, enter submits', () => {
it('runs the whole claim chain through the real pipeline', async () => {
const b = await bench()
b.type('/go')
// Candidates land async; the menu opens with the goal row.
await vi.waitFor(() => {
const menu = b.controller.menu.getSnapshot()
expect(menu.open).toBe(true)
expect(menu.groups[0]?.items.map(i => i.name)).toContain('goal')
})
// Pointer pick (menu path executes through the bound target inside the pipeline).
act(() => { b.controller.pick('command', 0) })
expect(b.shell.snapshot.phase).toBe('claimed')
expect(b.textarea.value).toBe('/goal ')
expect(b.view.container.querySelector('[data-decoration="token"]')?.textContent).toBe('/goal ')
expect(b.view.container.querySelector('[data-decoration="hint"]')?.textContent).toBe('目标内容')
// Continue typing args; hint drops; claim holds.
b.type('/goal 发布 v1')
expect(b.shell.snapshot.phase).toBe('claimed')
// Enter: submitting → command execute → commit clears.
fireEvent.keyDown(b.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 发布 v1') })
await vi.waitFor(() => { expect(b.textarea.value).toBe('') })
expect(b.shell.snapshot.phase).toBe('plain')
expect(b.view.getByText('已执行 /goal 发布 v1')).toBeTruthy()
expect(b.sink).not.toHaveBeenCalled()
})
})
describe('scenario C: pasted /goal xxx + enter (menu never opened)', () => {
it('adjudicates on enter, claims and submits in one stroke', async () => {
const b = await bench()
// Paste lands whole; caret at end means detectTrigger sees no token under
// the caret mid-whitespace — menu stays closed; enter runs adjudication.
act(() => { b.shell.setDraft('/goal 尽快发布') })
fireEvent.keyDown(b.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(b.execute).toHaveBeenCalledWith('/goal 尽快发布') })
await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') })
expect(b.textarea.value).toBe('')
expect(b.sink).not.toHaveBeenCalled()
})
})
describe('scenario D: execute-kind /compact', () => {
it('menu pick executes immediately without touching the draft machine phase', async () => {
const b = await bench()
b.type('/comp')
await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) })
act(() => { b.controller.pick('command', 0) })
// 'handled': no claim, machine still plain; the source ran the detached execute.
expect(b.shell.snapshot.phase).toBe('plain')
expect(b.executed).toContain('/compact')
})
it('bare /compact + enter executes; trailing text falls to the default sink (scenario I twin)', async () => {
const b = await bench()
act(() => { b.shell.setDraft('/compact') })
fireEvent.keyDown(b.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(b.executed).toContain('/compact') })
// 'handled' flows back as the adjudicated event one microtask later.
await vi.waitFor(() => { expect(b.shell.snapshot.phase).toBe('plain') })
cleanup()
const b2 = await bench()
act(() => { b2.shell.setDraft('/compact 现在') })
fireEvent.keyDown(b2.textarea, { key: 'Enter' })
// execute with trailing → matchEnter answers undefined → default sink.
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') })
expect(b2.executed).toHaveLength(0)
})
})
describe('scenario H: backspace breaks the token', () => {
it('claim releases automatically; the enter after that goes through adjudication again', async () => {
const b = await bench()
b.type('/goal')
await vi.waitFor(() => { expect(b.controller.menu.getSnapshot().open).toBe(true) })
// Space adjudication claims (space column, leadingInput).
fireEvent.keyDown(b.textarea, { key: ' ' })
expect(b.shell.snapshot.phase).toBe('claimed')
// Backspace into the token: watch break → plain, visuals gone.
b.type('/goa ')
expect(b.shell.snapshot.phase).toBe('plain')
expect(b.view.container.querySelector('[data-decoration="token"]')).toBeNull()
})
})
describe('scenario I: unknown /xyz + enter', () => {
it('adjudication misses in one hop and the whole line rides the default sink', async () => {
const b = await bench()
act(() => { b.shell.setDraft('/xyz 干点啥') })
fireEvent.keyDown(b.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') })
expect(b.shell.snapshot.phase).toBe('plain')
expect(b.execute).not.toHaveBeenCalled()
})
it('adjudication failure (source warmup throw) notices and keeps the draft', async () => {
const b = await scopedBench((slash) => {
slash.registerSource({
trigger: '/', name: 'command',
candidates: () => Promise.resolve([]),
onPick: () => undefined,
matchEnter: () => Promise.reject(new Error('目录预热失败')),
} as never)
})
act(() => { b.shell.setDraft('/plan 上线') })
fireEvent.keyDown(b.textarea, { key: 'Enter' })
await vi.waitFor(() => { expect(b.view.getByText('目录预热失败')).toBeTruthy() })
// Never a silent downgrade: draft retained, sink untouched.
expect(b.textarea.value).toBe('/plan 上线')
expect(b.sink).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,99 @@
// @vitest-environment jsdom
/**
* QueueDock rendering (web input-triggers queue cut 1): empty queue renders
* nothing, rows render one preview line each keyed by rpcId, and the strip
* follows queue changes through the useSession selector.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { useSyncExternalStore } from 'react'
import type { ConversationSnapshot, QueuedMessage, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { InputState } from '../src/client/input/contract.ts'
import { QueueDock, queueDockEntry } from '../src/client/queue/QueueDock.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
/** Minimal live source backing the useSession stub (queue swaps notify subscribers). */
function liveSession(initial: ConversationSnapshot) {
let snapshot = initial
const listeners = new Set<() => void>()
const useSession: SnapshotSelectorHook<ConversationSnapshot> = sel =>
useSyncExternalStore(
(fn) => {
listeners.add(fn)
return () => listeners.delete(fn)
},
() => sel(snapshot),
)
return {
useSession,
push(next: ConversationSnapshot): void {
snapshot = next
for (const fn of [...listeners]) fn()
},
}
}
/** InputZone owner stub (the dock reads useSession only; the zone fields satisfy the owner share). */
const INPUT_STATE: InputState = { draft: '', draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
function kitFor(snapshot: ConversationSnapshot) {
return {
sessionId: SID,
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
useWorkspaces: (() => { throw new Error('unused') }) as never,
useInput: (() => { throw new Error('unused') }) as never,
inputActions: { setDraft: () => {}, submit: () => {} } as never,
session: snapshot,
input: INPUT_STATE,
}
}
describe('QueueDock', () => {
it('renders null while the queue is empty', () => {
const snap = snapshotWith([])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
expect(container.innerHTML).toBe('')
})
it('renders one preview row per queued message with the count strip', () => {
const snap = snapshotWith([
{ key: 'p-1', preview: '第一条排队消息' },
{ key: 'p-2', preview: 'second queued line' },
])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
expect(container.textContent).toContain('已排队 2 条')
const rows = [...container.querySelectorAll('li')]
expect(rows.map(r => r.textContent)).toEqual(['第一条排队消息', 'second queued line'])
})
it('follows queue changes: retirement empties the strip back to null', () => {
const snap = snapshotWith([{ key: 'p-1', preview: '在场' }])
const source = liveSession(snap)
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
expect(container.textContent).toContain('在场')
act(() => { source.push(snapshotWith([])) })
expect(container.innerHTML).toBe('')
})
it('ships the registrant plugin shape (list entry into conversation.input.dock)', () => {
// Registration itself runs under T5's slot declaration; here we pin the
// frozen registration surface so the wiring layer can mount it verbatim.
expect(queueDockEntry.name).toBe('conversation-queue-dock')
expect(queueDockEntry.inject).toEqual(['slots', 'conversation'])
expect(typeof queueDockEntry.apply).toBe('function')
})
})

View File

@@ -20,13 +20,15 @@ function bench(): Bench {
const ctx = new Context()
ctx.provide('sessions', {
list: createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
ids: [], byId: {}, current: undefined, phase: 'ready',
}),
cell: () => undefined,
provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
provide: () => () => {},
})
ctx.provide('workspaces', {
list: createSnapshotStore<WorkspaceListState>({
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
}),
})
@@ -40,17 +42,20 @@ function bench(): Bench {
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'conversation': { kind: 'single', scope: 'session-maybe' },
'conversation.session': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
},
}, (_p: { renderSlot?: unknown }) => null)
slots.register({ name: 'conversation', store: chat }, () => null)
// apply.ts mounts the shared chat handle only under session-scope slots
// (the session-maybe 'conversation' shell carries no store).
slots.register({ name: 'conversation.session', store: chat }, () => null)
slots.register({ name: 'details', store: chat }, () => null)
return { slots, chat }
}
/** Resolve the store instance the renderer would hand a slot's component for a session. */
function storeFor(b: Bench, slot: 'conversation' | 'details', sessionId: SessionId) {
function storeFor(b: Bench, slot: 'conversation.session' | 'details', sessionId: SessionId) {
const host = renderHost(b)
const entry = host.entriesOf(slot)[0]!
return host.storeOf(entry, sessionId)! as ReturnType<ReturnType<typeof createChatStore>['create']>
@@ -79,7 +84,7 @@ describe('selection survives on the store seat', () => {
it('one session, two slots: conversation writes, details reads the SAME instance', () => {
const b = bench()
const conv = storeFor(b, 'conversation', sid('s1'))
const conv = storeFor(b, 'conversation.session', sid('s1'))
const details = storeFor(b, 'details', sid('s1'))
conv.actions.select({ turnSeq: 3, callId: 'c1' })
expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
@@ -90,8 +95,8 @@ describe('selection survives on the store seat', () => {
it('sessions are isolated: s2 selection never bleeds into s1', () => {
const b = bench()
const one = storeFor(b, 'conversation', sid('s1'))
const two = storeFor(b, 'conversation', sid('s2'))
const one = storeFor(b, 'conversation.session', sid('s1'))
const two = storeFor(b, 'conversation.session', sid('s2'))
expect(two).not.toBe(one)
one.actions.select({ turnSeq: 1, callId: 'a' })
two.actions.select({ turnSeq: 9, callId: 'z' })
@@ -104,14 +109,14 @@ describe('selection survives on the store seat', () => {
const id = sid('s1')
const projection = createSnapshotStore({ displayTitle: 's1' })
const store = storeFor(b, 'conversation', id)
const store = storeFor(b, 'conversation.session', id)
store.actions.select({ turnSeq: 3, callId: 'c1' })
store.actions.setDraft('half-typed')
projection.set({ displayTitle: 'proj-a' })
expect(projection.getSnapshot().displayTitle).toBe('proj-a')
const after = storeFor(b, 'conversation', id)
const after = storeFor(b, 'conversation.session', id)
expect(after).toBe(store)
expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
expect(after.store.getSnapshot().draft).toBe('half-typed')
@@ -120,7 +125,7 @@ describe('selection survives on the store seat', () => {
it('session death buries the instance and its persisted draft', () => {
const b = bench()
const doomed = storeFor(b, 'conversation', sid('s1'))
const doomed = storeFor(b, 'conversation.session', sid('s1'))
doomed.actions.setDraft('to be buried')
doomed.actions.select({ turnSeq: 1 })
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
@@ -131,7 +136,7 @@ describe('selection survives on the store seat', () => {
// Persisted residue is gone with the session...
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
// ...and a re-created same-id session starts from a FRESH instance.
const reborn = storeFor(b, 'conversation', sid('s1'))
const reborn = storeFor(b, 'conversation.session', sid('s1'))
expect(reborn).not.toBe(doomed)
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
})

View File

@@ -23,11 +23,9 @@ async function bench(withSessions = true) {
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
const loadOlder = vi.fn(() => Promise.resolve())
const updatePendingPrompt = vi.fn()
const retryPendingPrompt = vi.fn()
const sessions = {
binding: (sessionId: SessionId) => ({
sessionId, session: { prompt, cancel, loadOlder, updatePendingPrompt, retryPendingPrompt },
sessionId, session: { prompt, cancel, loadOlder },
}),
scopeOf,
} as unknown as SessionsService
@@ -35,22 +33,18 @@ async function bench(withSessions = true) {
await ctx.plugin(ConversationService).await()
const root = ctx.get('conversation') as ConversationService
const scoped = ctx.plugin(() => {}).ctx.extend({ [SCOPE_TAG]: sid('s1') }).get('conversation') as ConversationService
return { root, scoped, prompt, cancel, loadOlder, updatePendingPrompt, retryPendingPrompt }
return { root, scoped, prompt, cancel, loadOlder }
}
describe('ConversationService', () => {
it('routes ordinary and retained-prompt operations through the public Session binding', async () => {
it('routes operations through the public Session binding', async () => {
const b = await bench()
await b.scoped.send('hello', 'steer')
await b.scoped.cancel()
await b.scoped.loadOlder()
b.scoped.updatePendingPrompt('revised')
b.scoped.retryPendingPrompt()
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
expect(b.cancel).toHaveBeenCalledOnce()
expect(b.loadOlder).toHaveBeenCalledOnce()
expect(b.updatePendingPrompt).toHaveBeenCalledWith('revised')
expect(b.retryPendingPrompt).toHaveBeenCalledOnce()
})
it('folds Session business failures into callback rejections', async () => {

View File

@@ -1,4 +1,7 @@
// @vitest-environment jsdom
// ConversationRoot skeleton behavior: the ONE resident composer across the
// hero (blank session) and active phases — same textarea DOM node, machine-
// owned draft, and the hero workspace picker (switching = retargetWorkspace).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
@@ -6,11 +9,22 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { EmptyStateProps } from '../src/client/skeleton/EmptyState.tsx'
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { createChatStore } from '../src/client/stores.ts'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
import type { ComposerBarOwnerProps } from '../src/client/contract/slots.ts'
/** Machine-backed wiring over a sink spy. */
function fakeWiring() {
const sink = vi.fn()
const shell = new SessionInputShell({ actx: {} as ClientContext, defaultSink: sink })
return { wiring: shell, sink, shell }
}
afterEach(cleanup)
beforeEach(() => { localStorage.clear() })
@@ -26,180 +40,169 @@ function workspace(id = 'w1'): WorkspaceView {
}
}
type SessionIntent = NonNullable<SessionListState['intent']>
type WorkspaceIntent = NonNullable<WorkspaceListState['intent']>
const workspaceState = (
items: readonly WorkspaceView[], workspaceIntent?: WorkspaceIntent,
): WorkspaceListState => ({
items, intent: workspaceIntent, state: 'idle', phase: 'ready', error: null,
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
items, state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
function mountEmpty(
intent: SessionIntent,
items: readonly WorkspaceView[] = [],
localWorkspace?: WorkspaceIntent,
) {
const updateSessionPrompt = vi.fn()
const sendSession = vi.fn()
const startSession = vi.fn()
let pickerOwner: unknown
const sessionState: SessionListState = {
ids: [], byId: {}, current: intent.sessionId, intent, phase: 'ready',
}
const workspaceIntent = intent.target.kind === 'workspace-intent'
? localWorkspace ?? { name: 'workspace', phase: 'ready' as const }
: undefined
const view = render(
<EmptyState
useSessions={hook(sessionState)}
useWorkspaces={hook(workspaceState(items, workspaceIntent))}
updateSessionPrompt={updateSessionPrompt}
sendSession={sendSession}
startSession={startSession}
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as EmptyStateProps['renderSlot']}
/>,
)
return { view, updateSessionPrompt, sendSession, startSession, pickerOwner: () => pickerOwner }
}
describe('EmptyState', () => {
it('reads the Workspace and Session intents from runtime projections', () => {
const b = mountEmpty({
sessionId: sid('local-1'), target: { kind: 'workspace-intent' },
prompt: 'draft', phase: 'ready',
})
expect(b.view.getByRole('button', { name: 'Choose workspace' }).textContent).toContain('workspace')
fireEvent.click(b.view.getByRole('button', { name: 'Add attachment' }))
expect((b.pickerOwner() as { open: boolean }).open).toBe(false)
fireEvent.change(b.view.getByPlaceholderText('Describe what you want to build'), { target: { value: 'build it' } })
expect(b.updateSessionPrompt).toHaveBeenCalledWith('build it')
fireEvent.click(b.view.getByRole('button', { name: 'Send message' }))
expect(b.sendSession).toHaveBeenCalledOnce()
})
it('uses useWorkspaces for the selected label and preserves the prompt when retargeting', () => {
const first = workspace('first')
const b = mountEmpty({
sessionId: sid('local-2'), target: { kind: 'workspace', workspaceId: first.workspaceId },
prompt: 'keep me', phase: 'ready',
}, [first])
expect(b.view.getByRole('button', { name: 'Choose workspace' }).textContent).toContain('first')
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
const owner = b.pickerOwner() as { onPick(id: WorkspaceId): void }
owner.onPick(wid('second'))
expect(b.startSession).toHaveBeenCalledWith(wid('second'), 'keep me')
})
it('exposes materialization phase and failure text', () => {
const creating = mountEmpty({
sessionId: sid('local-3'), target: { kind: 'workspace-intent' },
prompt: 'x', phase: 'ready',
}, [], { name: 'workspace', phase: 'creating' })
expect(creating.view.getByRole('status').textContent).toBe('Creating workspace…')
cleanup()
const workspaceFailed = mountEmpty({
sessionId: sid('local-3'), target: { kind: 'workspace-intent' },
prompt: 'x', phase: 'ready',
}, [], { name: 'workspace', phase: 'ready', error: 'offline' })
expect(workspaceFailed.view.getByRole('alert').textContent).toBe('Workspace creation failed: offline')
cleanup()
const failed = mountEmpty({
sessionId: sid('local-3'), target: { kind: 'workspace', workspaceId: wid('w1') },
prompt: 'x', phase: 'ready', error: { step: 'session', message: 'offline' },
}, [workspace()])
expect(failed.view.getByRole('alert').textContent).toBe('Session creation failed: offline')
})
})
function conversationSnapshot(
composerPhase: ConversationSnapshot['composerPhase'],
pendingPrompt: ConversationSnapshot['pendingPrompt'] = null,
): ConversationSnapshot {
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, composerPhase, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt, lastAgentError: null,
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null,
...overrides,
}
}
function mountConversation(pendingPrompt: ConversationSnapshot['pendingPrompt'] = null) {
function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }]) {
const root = sid('root')
const sessions = createSnapshotStore<SessionListState>({
ids: [root, SID],
byId: {
[root]: { id: root, displayTitle: 'Root', running: false, updatedAt: 1 },
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, updatedAt: 2 },
[root]: { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 },
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, blank: false, updatedAt: 2 },
},
current: SID,
intent: undefined,
phase: 'ready',
})
const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState([{ ...workspace('one'), sessionIds: [SID] }]))
const session = createSnapshotStore<ConversationSnapshot>(conversationSnapshot(
pendingPrompt === null ? 'active' : 'blank', pendingPrompt,
))
const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState(workspaceRows))
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
const useSession = bindSnapshotSelector(session)
const chat = createChatStore().create()
chat.actions.setDraft('ordinary draft')
const send = vi.fn()
const { wiring, sink } = fakeWiring()
const useInput = bindSnapshotSelector(wiring.state)
const inputActions = wiring.actions
const stop = vi.fn()
const open = vi.fn()
const updateSessionPrompt = vi.fn()
const retrySessionPrompt = vi.fn()
const renderSlot = ((_key: string, _owner: object, opts?: { only?: string }) => (
<div data-testid={`view-${opts?.only ?? 'all'}`} />
)) as ConversationRootProps['renderSlot']
const retargetWorkspace = vi.fn()
const slotCalls: string[] = []
let pickerOwner: unknown
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
slotCalls.push(key)
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
if (key === 'conversation.session') {
return (
<ConversationSession
sessionId={SID}
SessionProvider={({ children }) => children(SID)}
useSession={useSession}
useSessions={props.useSessions}
useWorkspaces={props.useWorkspaces}
useInput={useInput}
inputActions={inputActions}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot as never}
views={{ list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 }}
bindDraftMirror={write => wiring.bindMirror(write)}
open={open}
/>
)
}
if (key === 'conversation.composer.bar') {
// The real entry, mounted the way the outlet composes it: standard kit
// (shared with the root's props below) + this entry's inject + owner.
const bar = owner as ComposerBarOwnerProps
return (
<InputBar
sessionId={SID}
SessionProvider={({ children }) => children(SID)}
useSession={useSession}
useSessions={props.useSessions}
useWorkspaces={props.useWorkspaces}
useInput={useInput}
inputActions={inputActions}
keyboard={wiring}
stop={stop}
renderSlot={(() => null) as InputBarProps['renderSlot']}
{...bar}
/>
)
}
return <div data-testid={`view-${opts?.only ?? key}`} />
}) as ConversationRootProps['renderSlot']
const renderSlotChain = ((_key, _owner, opts) => opts?.fallback ?? null) as ConversationRootProps['renderSlotChain']
const SessionProvider: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
const props: ConversationRootProps = {
sessionId: SID,
useSession: bindSnapshotSelector(session),
SessionProvider: ({ children }) => children(SID),
useSession,
useSessions: bindSnapshotSelector(sessions),
useWorkspaces: bindSnapshotSelector(workspaces),
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
useInput,
inputActions,
renderSlot,
renderSlotChain,
SessionProvider,
views: { list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 },
send,
stop,
open,
updateSessionPrompt,
retrySessionPrompt,
selectWorkspace: retargetWorkspace,
}
const view = render(<ConversationRoot {...props} />)
return { view, chat, send, open, updateSessionPrompt, retrySessionPrompt }
return {
view, chat, sink, open, retargetWorkspace, session, slotCalls,
pickerOwner: () => pickerOwner,
rerender: () => { view.rerender(<ConversationRoot {...props} />) },
}
}
describe('ConversationRoot draft ownership', () => {
it('keeps ordinary per-Session composer text in the chat store and selects through runtime actions', () => {
const b = mountConversation()
describe('ConversationRoot resident composer', () => {
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')
expect((box as HTMLTextAreaElement).value).toBe('ordinary draft')
fireEvent.change(box, { target: { value: 'ordinary revised' } })
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.send).toHaveBeenCalledWith('ordinary revised', 'queue')
expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue')
fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
expect(b.open).toHaveBeenCalledWith(sid('root'))
})
it('reads a retained prompt from useSession and edits/retries it through the scoped Session', () => {
const b = mountConversation({
workspaceId: wid('one'), text: 'retry me', phase: 'failed',
retry: 'send', error: 'offline',
})
it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
// Hero chrome present, view ring absent.
expect(b.view.getByText("Let's start building")).toBeTruthy()
expect(b.view.queryByTestId('view-chat')).toBeNull()
// The same machine-backed textarea is live in the hero, and the
// persistence mirror stays bound (ConversationSession mounts chrome-less
// for blank sessions): hero typing reaches the chat store.
const box = b.view.getByRole('textbox')
expect((box as HTMLTextAreaElement).value).toBe('retry me')
expect(b.view.getByRole('alert').textContent).toBe('Message send failed: offline')
fireEvent.change(box, { target: { value: 'revised prompt' } })
expect(b.updateSessionPrompt).toHaveBeenCalledWith('revised prompt')
expect(b.chat.store.getSnapshot().draft).toBe('ordinary draft')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.retrySessionPrompt).toHaveBeenCalledOnce()
expect(b.send).not.toHaveBeenCalled()
fireEvent.change(box, { target: { value: 'draft in hero' } })
expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
// Picker: open through the chip; a pick switches to the other
// workspace's blank session (draft carry is apply-layer wiring).
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
expect(owner.open).toBe(true)
owner.onPick(wid('second'))
expect(b.retargetWorkspace).toHaveBeenCalledWith(wid('second'))
})
it('textarea DOM identity survives the hero → active flip', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
const before = b.view.getByRole('textbox')
fireEvent.change(before, { target: { value: 'kept across flip' } })
// First message landed: content exists, phase leaves blank.
b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
b.rerender()
const after = b.view.getByRole('textbox')
expect(after).toBe(before)
expect((after as HTMLTextAreaElement).value).toBe('kept across flip')
expect(b.view.queryByText("Let's start building")).toBeNull()
expect(b.view.getByTestId('view-chat')).toBeTruthy()
})
it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => {
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
const chip = b.view.getByRole('button', { name: 'Choose workspace' })
expect((chip as HTMLButtonElement).disabled).toBe(false)
expect(b.slotCalls).toContain('conversation.hero.workspace')
})
it('prompt failure renders the promptError strip (ordinary failure, no transaction UI)', () => {
const b = mount(conversationSnapshot({
promptError: { op: 'send', error: { code: 'offline', message: 'Message send failed' } as never },
}))
expect(b.view.getByRole('alert').textContent).toContain('Message send failed (offline)')
expect(b.view.queryByRole('button', { name: 'Retry' })).toBeNull()
})
})

View File

@@ -23,6 +23,9 @@
{
"path": "../runtime"
},
{
"path": "../ui-slash"
},
{
"path": "../ui-layout"
},