Merge remote-tracking branch 'origin/master' into web2-todo

# Conflicts:
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/runtime/src/client/index.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
#	packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
#	packages/client/ui-conversation/tests/chat-view.spec.tsx
#	packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/client/ui-trajectory/tests/views.spec.tsx
This commit is contained in:
imccyu
2026-07-27 10:07:51 +08:00
887 changed files with 28254 additions and 9617 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: f5e2603e8ad588740bea3be4f1a195d658721f0c
README.zh.md: 900d3d1608da3c86078dc56ec819b1f851e63de7
README.md: b9ec555f158722ea1f41e01c4b3f7131d3fe3467
README.zh.md: b1e3c1f4331148ebf1c58b4bcd4869270bb44311

View File

@@ -8,7 +8,7 @@ The no-session hero renders the frontend Session Intent from the Session list pr
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.
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).

View File

@@ -8,7 +8,7 @@
视图环本身就是 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 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>` 或 `Edit · <path>` 摘要,同时保留共享的行到详情交互。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>` 或 `Edit · <path>` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。
工具行同样是 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 会拒绝没有任何渲染方的声明)。

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,16 +5,19 @@ 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 { todoToolview } from './toolviews/todo-row.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']
@@ -50,50 +53,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
@@ -125,7 +178,7 @@ 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)
@@ -133,6 +186,10 @@ export function apply(ctx: Context): void {
// The todo_write row rides the same seam (a product registration, not a sample).
ctx.plugin(todoToolview)
// 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,
@@ -141,13 +198,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'
@@ -45,10 +45,39 @@ type RenderToolRow = ChatViewSlotProps['renderSlot']
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
/** 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
@@ -57,6 +86,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,
@@ -68,17 +101,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}>
@@ -92,6 +140,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>
@@ -116,6 +166,7 @@ function StreamingTail({ useSession, onGrow }: {
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
const nodes = useSession((s) => s.nodes)
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})`)
@@ -203,7 +254,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}
@@ -211,6 +263,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
results={item.results}
onOpenDetails={openDetails}
selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches}
/>
)
}
@@ -250,6 +303,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

@@ -32,3 +32,18 @@
.contextRow {
padding: 2px 0;
}
/* 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

@@ -4,6 +4,7 @@
// streaming because unchanged nodes keep their references.
import { memo } from 'react'
import type { ReactNode } from 'react'
import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -25,6 +26,38 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
return { text: texts.join(''), rest }
}
/**
* 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 }: MessageItemProps) {
switch (node.kind) {
case 'user':
@@ -34,7 +67,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
<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,185 +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 { TodoPanel } from './TodoPanel.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>
<TodoPanel useSession={useSession} />
{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 and the todo row as keyed entries 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, todos: [],
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], 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, todos: [],
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], 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, todos: [],
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
}
}
@@ -72,13 +72,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)
@@ -104,6 +104,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, todos: [],
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], 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, todos: [],
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], todos: [], 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"
},