Merge remote-tracking branch 'origin/master' into worktree-i18n-update-workflow
# Conflicts: # .agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml # .agents/skills/dsh-translate-docs/SKILL.md # docs/i18n/README.i18n.yaml
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -5,15 +5,18 @@ import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ViewTab } from './contract/views.ts'
|
||||
import type {
|
||||
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
|
||||
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
|
||||
} from './contract/slots.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
import { queueDockEntry } from './queue/QueueDock.tsx'
|
||||
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession } from './skeleton/ConversationSession.tsx'
|
||||
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
import { EmptyState } from './skeleton/EmptyState.tsx'
|
||||
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
|
||||
@@ -49,50 +52,100 @@ export function apply(ctx: Context): void {
|
||||
return tabs
|
||||
}
|
||||
|
||||
// Conversation occupant. Declaring the view ring here is claiming it:
|
||||
// ConversationRoot is the only component authorized to render the ring.
|
||||
// The per-session input machine registry (InputService face; published as
|
||||
// ctx.conversation.input by the service below sharing this one instance).
|
||||
const inputHub = new InputHub(ctx)
|
||||
|
||||
// Decision 19/20: the input machine feeds every session-scope slot
|
||||
// component through the standard provide channel — the 'input' hook plus
|
||||
// the two public actions. Materialization is the shell creation trigger
|
||||
// (per-session lazy; scope disposer tears down).
|
||||
ctx.effect(() => sessions.provide({
|
||||
hooks: ['input'],
|
||||
props: ['inputActions'],
|
||||
resolve: (binding) => {
|
||||
const shell = inputHub.shellFor(binding)
|
||||
return {
|
||||
hooks: { input: shell.state },
|
||||
props: { inputActions: shell.actions },
|
||||
}
|
||||
},
|
||||
}), 'ui-conversation: input standard-kit provider')
|
||||
|
||||
// Resident current-session-optional shell. It owns the stable Hero/composer
|
||||
// frame while strict session slots fill only their session-bound regions.
|
||||
slots.register({
|
||||
name: 'conversation',
|
||||
// The composer chain rides the same declaration table: takeover plugins
|
||||
// register selector-routed replacements of the InputBar.
|
||||
children: {
|
||||
'conversation.view': { kind: 'list', scope: 'session' },
|
||||
'conversation.session': { kind: 'single', scope: 'session' },
|
||||
'conversation.composer': { kind: 'chain', scope: 'session' },
|
||||
'conversation.composer.bar': { kind: 'single', scope: 'session' },
|
||||
'conversation.input.overlay': { kind: 'list', scope: 'session' },
|
||||
'conversation.input.dock': { kind: 'list', scope: 'session' },
|
||||
'conversation.composer.dock': { kind: 'list', scope: 'session' },
|
||||
'conversation.input.left': { kind: 'list', scope: 'session' },
|
||||
'conversation.input.right': { kind: 'list', scope: 'session' },
|
||||
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
|
||||
selectWorkspace: (workspaceId) => {
|
||||
void workspaces.connectWorkspace(workspaceId).then((nextId) => {
|
||||
if (sessionId !== undefined && nextId !== sessionId) {
|
||||
const from = inputHub.shell(sessionId)
|
||||
const draft = from.snapshot.draft
|
||||
if (draft !== '') {
|
||||
inputHub.shell(nextId).setDraft(draft)
|
||||
from.setDraft('')
|
||||
}
|
||||
}
|
||||
sessions.open(nextId)
|
||||
}).catch(() => {
|
||||
// Failure leaves the current Hero state available to retry.
|
||||
})
|
||||
},
|
||||
}),
|
||||
}, ConversationRoot)
|
||||
|
||||
// The strict session subtree owns only per-session store and view content;
|
||||
// the resident parent keeps Hero and composer layout identity stable.
|
||||
slots.register({
|
||||
name: 'conversation.session',
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ConversationInjected => {
|
||||
// History pull is NOT triggered here: the runtime sessions service opens
|
||||
// the event window when the watch lands on the session (cell/binding
|
||||
// resolution) — an inject factory assembles callbacks, it has no side
|
||||
// effect on session state.
|
||||
const scoped = scopedConversation(sessions, sessionId)
|
||||
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
|
||||
views: {
|
||||
list: viewTabs,
|
||||
subscribe: fn => slots.subscribe('conversation.view', fn),
|
||||
version: () => slots.getVersion('conversation.view'),
|
||||
},
|
||||
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
|
||||
open: (id) => { sessions.open(id) },
|
||||
}),
|
||||
}, ConversationSession)
|
||||
|
||||
// The default composer body: its own single slot inside the composer
|
||||
// chain's fallback (decision 20). Public machine surface arrives via the
|
||||
// provide channel above; the keyboard command face and the stop/retry
|
||||
// verbs ride this inject (package-internal — hub and bar are one plugin).
|
||||
slots.register({
|
||||
name: 'conversation.composer.bar',
|
||||
// The two named control seats in the bar's tool row (plan left, model
|
||||
// right); empty until their owning plugins register (B ruling).
|
||||
children: {
|
||||
'conversation.input.plan': { kind: 'single', scope: 'session' },
|
||||
'conversation.input.model': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
inject: (sessionId: SessionId): ComposerBarInjected => {
|
||||
return {
|
||||
views: {
|
||||
list: viewTabs,
|
||||
subscribe: fn => slots.subscribe('conversation.view', fn),
|
||||
version: () => slots.getVersion('conversation.view'),
|
||||
},
|
||||
send: (text, mode) => {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === '') return
|
||||
// Optimistic clear with failure restore (choreography lives with the
|
||||
// sender; the business failure also lands in snapshot.promptError).
|
||||
// The store write path stays inside the declared actions set:
|
||||
// restoreDraft itself no-ops once the user typed something new.
|
||||
actions.clearDraft()
|
||||
void scoped.send(trimmed, mode).catch(() => { actions.restoreDraft(trimmed) })
|
||||
},
|
||||
keyboard: inputHub.keyboard(sessionId),
|
||||
stop: () => {
|
||||
scoped.cancel().catch(() => {
|
||||
scopedConversation(sessions, sessionId).cancel().catch(() => {
|
||||
// Stop failure surfaces via snapshot.promptError; nothing to restore.
|
||||
})
|
||||
},
|
||||
open: (sessionId) => { sessions.open(sessionId) },
|
||||
updateSessionPrompt: (text) => { scoped.updatePendingPrompt(text) },
|
||||
retrySessionPrompt: () => { scoped.retryPendingPrompt() },
|
||||
}
|
||||
},
|
||||
}, ConversationRoot)
|
||||
}, InputBar)
|
||||
|
||||
// The chat view: first entry of the ring this package just declared.
|
||||
// Declaring the keyed toolview hole here is claiming it: ChatView is the
|
||||
@@ -124,11 +177,15 @@ export function apply(ctx: Context): void {
|
||||
// toolview registrants using `inject: ['conversation']` as their load-order
|
||||
// seam: the service being present implies the chat entry (and with it the
|
||||
// 'conversation.chat.toolview' declaration) is on the ledger.
|
||||
ctx.plugin(ConversationService)
|
||||
ctx.plugin(ConversationService, { input: inputHub })
|
||||
|
||||
// The bash sample rides that exact seam, in third-party posture.
|
||||
ctx.plugin(bashToolviewSample)
|
||||
|
||||
// The read-only queue dock entry (T9 file territory) rides the same
|
||||
// registration seam into the input dock declared above.
|
||||
ctx.plugin(queueDockEntry)
|
||||
|
||||
slots.register({
|
||||
name: 'details',
|
||||
store: chatStore,
|
||||
@@ -137,13 +194,4 @@ export function apply(ctx: Context): void {
|
||||
}),
|
||||
}, DetailsPanel)
|
||||
|
||||
slots.register({
|
||||
name: 'conversation.empty',
|
||||
children: { 'conversation.empty.workspace': { kind: 'single', scope: 'root' } },
|
||||
inject: (): EmptyStateInjected => ({
|
||||
startSession: (workspaceId, prompt) => { workspaces.startSession(workspaceId, prompt) },
|
||||
updateSessionPrompt: (text) => { sessions.updateIntent(text) },
|
||||
sendSession: () => { workspaces.sendSession() },
|
||||
}),
|
||||
}, EmptyState)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -87,14 +87,9 @@ button.leading {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The code variant's expanded body is the run_code program: monospace on the
|
||||
markdown code-block fill so the program reads as code, not prose. */
|
||||
.root[data-variant='code'] .body {
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
padding: 6px 8px;
|
||||
margin-left: 22px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
/* 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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
269
packages/client/ui-conversation/src/client/input/contract.ts
Normal file
269
packages/client/ui-conversation/src/client/input/contract.ts
Normal 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 }
|
||||
105
packages/client/ui-conversation/src/client/input/decorations.ts
Normal file
105
packages/client/ui-conversation/src/client/input/decorations.ts
Normal 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 }
|
||||
}
|
||||
446
packages/client/ui-conversation/src/client/input/facade.ts
Normal file
446
packages/client/ui-conversation/src/client/input/facade.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
146
packages/client/ui-conversation/src/client/input/hub.ts
Normal file
146
packages/client/ui-conversation/src/client/input/hub.ts
Normal 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
|
||||
}
|
||||
}
|
||||
570
packages/client/ui-conversation/src/client/input/machine.ts
Normal file
570
packages/client/ui-conversation/src/client/input/machine.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
24
packages/client/ui-conversation/src/client/queue/store.ts
Normal file
24
packages/client/ui-conversation/src/client/queue/store.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,182 +1,92 @@
|
||||
// ConversationRoot: the conversation slot's skeleton (figma Header 39:27730 +
|
||||
// Tab_Group + view area + composer). Pure component — everything arrives via
|
||||
// props: the framework standard kit (useSession/sessionId/useSessions), the
|
||||
// declared chat store's useStore/actions, the injected business face, and the
|
||||
// renderSlot share for the declared 'conversation.view' child slot (views are
|
||||
// slot entries; the active one renders via the list `only` filter) plus the
|
||||
// renderSlotChain share for the 'conversation.composer' takeover chain.
|
||||
// Breadcrumbs derive from useSessions with a pure parentId walk; the active
|
||||
// view id lives in the chat store's `view` field (per-session by store scope).
|
||||
// Resident conversation skeleton. Hero chrome, composer positioning, and the
|
||||
// chain stay mounted across no-session/session transitions. Only the inert
|
||||
// input body swaps for the strict session InputBar.
|
||||
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSlotProps } from '../contract/slots.ts'
|
||||
import { InputBar } from './InputBar.tsx'
|
||||
import type { InputBarError } from './InputBar.tsx'
|
||||
import { EmptyHero, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
|
||||
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
|
||||
import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
|
||||
import { DisabledInputBar } from './DisabledInputBar.tsx'
|
||||
import css from './ConversationRoot.module.css'
|
||||
|
||||
/** Full props = the automatic shares & injected share — composed by reference
|
||||
* from the contract, never re-typed here (share-ownership rule). */
|
||||
/** Full props composed from the slot contract. */
|
||||
export type ConversationRootProps = ConversationSlotProps
|
||||
|
||||
/** Breadcrumb chain: walk parentId links (root ancestor first, self last;
|
||||
* empty when unknown; a broken link stops the walk). Pure twin of the
|
||||
* sessions service's ancestry — components derive, they don't subscribe. */
|
||||
function deriveAncestry(list: SessionListState, id: SessionId): readonly SessionSummary[] {
|
||||
const chain: SessionSummary[] = []
|
||||
let cursor: SessionId | undefined = id
|
||||
while (cursor !== undefined) {
|
||||
const summary: SessionSummary | undefined = list.byId[cursor]
|
||||
if (summary === undefined || chain.includes(summary)) break
|
||||
chain.unshift(summary)
|
||||
cursor = summary.parentId
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
export function ConversationRoot({
|
||||
sessionId, useSession, useSessions, useWorkspaces, useStore, actions, renderSlot, renderSlotChain,
|
||||
views, send, stop, open, updateSessionPrompt, retrySessionPrompt,
|
||||
sessionId, useSession, useSessions, useWorkspaces, useInput,
|
||||
renderSlot, renderSlotChain, selectWorkspace,
|
||||
}: ConversationRootProps) {
|
||||
useSyncExternalStore(views.subscribe, views.version)
|
||||
const tabs = views.list()
|
||||
// The store's persisted view id may be stale (view plugin unloaded); the
|
||||
// slot ledger is the runtime validator — unknown ids fall to the first view.
|
||||
const activeId = useStore(s => s.view) ?? 'chat'
|
||||
const active = tabs.find(v => v.id === activeId) ?? tabs[0]
|
||||
|
||||
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
|
||||
const pendingPrompt = useSession(s => s.pendingPrompt ?? undefined)
|
||||
const storedDraft = useStore(s => s.draft)
|
||||
const draft = pendingPrompt?.text ?? storedDraft
|
||||
const sessionRunning = useSession(s => s.running)
|
||||
const running = sessionRunning || pendingPrompt?.phase === 'sending'
|
||||
const removed = useSession(s => s.removed)
|
||||
const promptError = useSession(s => s.promptError)
|
||||
const turns = useSession(s => countTurns(s))
|
||||
const pending = useSession(s => s.pending)
|
||||
const openState = useSession(s => s.openState)
|
||||
const composerPhase = useSession(s => s.composerPhase)
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
const workspaceTitle = useWorkspaces(state =>
|
||||
state.items.find(workspace => workspace.sessionIds.includes(sessionId))?.title)
|
||||
const error: InputBarError | null = pendingPrompt?.error !== undefined
|
||||
? {
|
||||
op: pendingPrompt.retry === 'connect' ? 'session' : 'send',
|
||||
message: pendingPrompt.retry === 'connect'
|
||||
? `Workspace attach failed: ${pendingPrompt.error}`
|
||||
: `Message send failed: ${pendingPrompt.error}`,
|
||||
}
|
||||
: promptError === null
|
||||
? null
|
||||
: { op: promptError.op, message: `${promptError.error.message} (${promptError.error.code})` }
|
||||
const status = pendingPrompt?.phase === 'sending'
|
||||
? pendingPrompt.retry === 'connect' ? 'Attaching session to workspace…' : 'Sending message…'
|
||||
: undefined
|
||||
const setDraft = (text: string): void => {
|
||||
if (pendingPrompt === undefined) actions.setDraft(text)
|
||||
else updateSessionPrompt(text)
|
||||
}
|
||||
const submit = (mode: 'queue' | 'steer'): void => {
|
||||
if (pendingPrompt === undefined) send(draft, mode)
|
||||
else retrySessionPrompt()
|
||||
}
|
||||
const pending = useSession(s => s.pending) ?? []
|
||||
const session = useSession(s => s)
|
||||
const inputState = useInput(s => s)
|
||||
const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
|
||||
const workspaces = useWorkspaces(s => s)
|
||||
|
||||
// Blank-session guidance: phase-derived (the runtime snapshot owns the
|
||||
// predicate — see ComposerPhase). Only `blank` renders the hero; `engaging`
|
||||
// and `active` fall through to the conversation view, so an in-flight
|
||||
// first send never bounces back here. Gated on the OPEN window: phase has
|
||||
// no jurisdiction over loading/error frames (ChatView renders those).
|
||||
if (openState === 'open' && composerPhase === 'blank') {
|
||||
return (
|
||||
<EmptyHero
|
||||
workspaceRow={<WorkspaceChip label={workspaceTitle ?? workspaceLabel(cwd ?? '')} locked />}
|
||||
draft={draft}
|
||||
disabled={removed || pendingPrompt?.phase === 'sending'}
|
||||
error={error}
|
||||
{...(status === undefined ? {} : { status })}
|
||||
onDraftChange={setDraft}
|
||||
onSend={submit}
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const pickerAnchor = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))
|
||||
const zone: InputZone | undefined =
|
||||
session === undefined || inputState === undefined ? undefined : { session, input: inputState }
|
||||
|
||||
const heroWorkspaceRow = (
|
||||
<>
|
||||
<WorkspaceChip
|
||||
buttonRef={pickerAnchor}
|
||||
label={
|
||||
sessionId === undefined
|
||||
? workspaceLabel('')
|
||||
: workspaces.items.find(w => w.sessionIds.includes(sessionId))?.title ?? workspaceLabel(cwd ?? '')
|
||||
}
|
||||
menuOpen={pickerOpen}
|
||||
onClick={() => { setPickerOpen(open => !open) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{renderSlot('conversation.hero.workspace', {
|
||||
open: pickerOpen,
|
||||
anchorRef: pickerAnchor,
|
||||
onPick: (workspaceId) => {
|
||||
setPickerOpen(false)
|
||||
selectWorkspace(workspaceId)
|
||||
},
|
||||
onClose: () => { setPickerOpen(false) },
|
||||
})}
|
||||
</>
|
||||
)
|
||||
|
||||
const inputBar = sessionId === undefined
|
||||
? <DisabledInputBar />
|
||||
: renderSlot('conversation.composer.bar', {
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
...(hero ? { placeholder: 'Describe what you want to build' } : {}),
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
})
|
||||
|
||||
// The default composer doubles as the chain's all-decline fallback: a
|
||||
// pending wait with no registered takeover must still leave the input usable.
|
||||
const composerBar = (
|
||||
<InputBar
|
||||
draft={draft}
|
||||
running={running}
|
||||
disabled={removed}
|
||||
error={error}
|
||||
{...(status === undefined ? {} : { status })}
|
||||
variant="composer"
|
||||
onDraftChange={setDraft}
|
||||
onSend={submit}
|
||||
onStop={stop}
|
||||
/>
|
||||
<div className={clsx(css.composerStack, hero && css.composerHero)}>
|
||||
{hero && <HeroShell />}
|
||||
{hero && heroWorkspaceRow}
|
||||
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
|
||||
{!hero && zone !== undefined && renderSlot('conversation.composer.dock', zone)}
|
||||
{inputBar}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<header className={css.header}>
|
||||
<div className={css.crumbRow}>
|
||||
<nav className={css.crumbs} aria-label="Session hierarchy">
|
||||
{ancestry.map((s, i) => {
|
||||
const last = i === ancestry.length - 1
|
||||
return (
|
||||
<span key={s.id} className={css.crumbSeg}>
|
||||
{i > 0 && <span className={css.crumbSep}>/</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.crumb, last && css.crumbCurrent)}
|
||||
disabled={last}
|
||||
onClick={() => { open(s.id) }}
|
||||
>
|
||||
{s.displayTitle}
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
|
||||
<span className={css.meta}>· {turns} turns</span>
|
||||
</nav>
|
||||
{/* Header button row (Fork / Session log / I/O Details): a P-I visual
|
||||
placeholder registry slot is deferred — buttons land with their features. */}
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
<div className={css.tabs} role="tablist">
|
||||
{tabs.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={v.id === active?.id}
|
||||
className={clsx(css.tab, v.id === active?.id && css.tabActive)}
|
||||
onClick={() => { actions.setView(v.id) }}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className={css.viewArea}>
|
||||
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
|
||||
</div>
|
||||
|
||||
{renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })}
|
||||
<div className={css.root} data-phase={hero ? 'hero' : 'active'}>
|
||||
{/* Mounted for every real session, hero included: ConversationSession
|
||||
renders no chrome while blank but owns the draft-persistence mirror
|
||||
bind — unmounting it in the hero would lose pre-first-send text on
|
||||
a refresh or scope rebuild. */}
|
||||
{sessionId !== undefined && renderSlot('conversation.session', {})}
|
||||
{renderSlotChain(
|
||||
'conversation.composer',
|
||||
{ interactions: pending },
|
||||
{ fallback: composerBar, overlay: true },
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Turn count = user message nodes in the window (display meta; exact host count deferred). */
|
||||
function countTurns(s: { nodes: readonly { kind: string }[] }): number {
|
||||
let n = 0
|
||||
for (const node of s.nodes) if (node.kind === 'user') n += 1
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -89,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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'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}
|
||||
|
||||
@@ -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() }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,18 +26,19 @@ async function bench() {
|
||||
const listStore = createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT, CHILD],
|
||||
byId: {
|
||||
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, updatedAt: 1 },
|
||||
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, updatedAt: 2 },
|
||||
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, blank: false, updatedAt: 1 },
|
||||
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, blank: false, updatedAt: 2 },
|
||||
},
|
||||
current: undefined,
|
||||
intent: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
const sessionsFake = {
|
||||
list: listStore,
|
||||
binding: vi.fn(),
|
||||
scope: () => undefined,
|
||||
cell: () => undefined,
|
||||
provideInfo: () => undefined,
|
||||
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
|
||||
provide: vi.fn(() => () => {}),
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
@@ -57,9 +58,8 @@ async function bench() {
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'conversation.empty': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
|
||||
@@ -68,7 +68,7 @@ async function bench() {
|
||||
}
|
||||
|
||||
/** First stored entry for a key (inject/store live directly on StoredEntry). */
|
||||
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.view' | 'details' | 'conversation.empty') {
|
||||
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') {
|
||||
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
|
||||
}
|
||||
|
||||
@@ -91,23 +91,24 @@ describe('apply wiring', () => {
|
||||
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
|
||||
})
|
||||
|
||||
it('occupies the three slots + the ring; session entries share one store handle, empty injects runtime actions', async () => {
|
||||
it('occupies the slots + the ring; session entries share one store handle', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.await()
|
||||
const conversation = renderEntryOf(b.slots, 'conversation')
|
||||
const conversationSession = renderEntryOf(b.slots, 'conversation.session')
|
||||
const chatView = renderEntryOf(b.slots, 'conversation.view')
|
||||
const details = renderEntryOf(b.slots, 'details')
|
||||
const empty = renderEntryOf(b.slots, 'conversation.empty')
|
||||
expect(conversation?.inject).toBeTypeOf('function')
|
||||
expect(chatView?.inject).toBeTypeOf('function')
|
||||
expect(details?.inject).toBeTypeOf('function')
|
||||
expect(empty?.inject).toBeTypeOf('function')
|
||||
// The shared handle: one apply-built store value on ALL session entries.
|
||||
expect(conversation?.store).toBeDefined()
|
||||
expect(details?.store).toBe(conversation?.store)
|
||||
expect(chatView?.store).toBe(conversation?.store)
|
||||
// The empty slot is storeless (local state + useSessions derivation).
|
||||
expect(empty?.store).toBeUndefined()
|
||||
// The shared handle: one apply-built store value on ALL session entries
|
||||
// (the session-maybe 'conversation' shell carries no store by design).
|
||||
expect(conversationSession?.store).toBeDefined()
|
||||
expect(details?.store).toBe(conversationSession?.store)
|
||||
expect(chatView?.store).toBe(conversationSession?.store)
|
||||
// The hero workspace picker hole rides the conversation entry's children
|
||||
// declaration (the empty-state occupant is gone).
|
||||
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
|
||||
})
|
||||
|
||||
it('mounts the bash sample as a keyed entry through the load-order seam', async () => {
|
||||
@@ -130,7 +131,6 @@ describe('apply wiring', () => {
|
||||
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
|
||||
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
|
||||
expect(b.slots.entries('details')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.empty')).toHaveLength(0)
|
||||
expect(b.ctx.get('conversation')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,16 +56,16 @@ function snapshotWith(
|
||||
): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls, codeDispatches,
|
||||
pending: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: 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', {})}</>
|
||||
}
|
||||
|
||||
/** Same real-stack bench as the toolview-slot spec: SlotsService + renderer + this package's apply; fakes only at service seams. */
|
||||
@@ -78,26 +78,41 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
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',
|
||||
})
|
||||
const cell = { sessionId: SID, session }
|
||||
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
ctx.provide('sessions', {
|
||||
// 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) => ({ sessionId: id, session: { loadOlder: vi.fn() } }),
|
||||
binding: (id: SessionId) => (id === SID
|
||||
? { sessionId: SID, session, ctx: { effect: () => {}, on: () => () => {} } }
|
||||
: undefined),
|
||||
scope: () => ({ get: () => scoped }),
|
||||
cell: (id: string) => (id === SID ? cell : undefined),
|
||||
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(),
|
||||
updateIntent: vi.fn(),
|
||||
})
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
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(),
|
||||
@@ -110,9 +125,8 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
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)
|
||||
|
||||
@@ -152,7 +166,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('expanding the code row reveals the program body verbatim', async () => {
|
||||
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)
|
||||
@@ -160,7 +174,12 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]')
|
||||
expect(toggle).not.toBeNull()
|
||||
fireEvent.click(toggle!)
|
||||
expect(view.getByText(/const listing = await tools\.bash/)).toBeTruthy()
|
||||
// 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 () => {
|
||||
|
||||
@@ -27,8 +27,8 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,11 +123,10 @@ describe('bash sample row', () => {
|
||||
return createSnapshotStore<SessionListState>({
|
||||
ids: [ROOT, CHILD],
|
||||
byId: {
|
||||
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 },
|
||||
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 },
|
||||
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
|
||||
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, blank: false, updatedAt: 0 },
|
||||
},
|
||||
current: undefined,
|
||||
intent: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
}
|
||||
@@ -160,7 +159,7 @@ describe('bash sample row', () => {
|
||||
const orphan = 'late-child' as SessionId
|
||||
store.update((d) => {
|
||||
d.ids.push(orphan)
|
||||
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, updatedAt: 0 }
|
||||
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, blank: false, updatedAt: 0 }
|
||||
})
|
||||
const view = render(<BashRow {...rowProps(orphan, { store })} />)
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
|
||||
@@ -40,15 +40,15 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
|
||||
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
|
||||
/** Test-owned AppFrame role: declares the layout-owned children and renders the conversation area under the framework session provider. */
|
||||
type AppRootProps = PropsRenderSlots<'conversation' | 'details' | 'conversation.empty'>
|
||||
function AppRoot({ renderSlot, SessionProvider }: AppRootProps) {
|
||||
return <SessionProvider>{() => renderSlot('conversation', {})}</SessionProvider>
|
||||
/** Test-owned AppFrame role: declares and renders the resident conversation area. */
|
||||
type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
|
||||
function AppRoot({ renderSlot }: AppRootProps) {
|
||||
return <>{renderSlot('conversation', {})}</>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,28 +65,60 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [SID],
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, updatedAt: 1 } },
|
||||
byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } },
|
||||
current: SID,
|
||||
intent: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
// Identity-stable cell: the renderer caches hooks per source and inject
|
||||
// results per cell, both by object identity.
|
||||
const cell = { sessionId: SID, session }
|
||||
// Identity-stable provide bundle: the renderer caches hooks per source and
|
||||
// inject results per bundle, both by object identity. Registered providers
|
||||
// (the package's input contribution) materialize into it lazily, once.
|
||||
const providers: ((binding: object) => { hooks?: object; props?: object })[] = []
|
||||
let info: { sessionId: SessionId; hooks: object; props: object } | undefined
|
||||
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
|
||||
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
const actxFake = { get: () => scoped, effect: () => {}, on: () => () => {} }
|
||||
const bindingOf = (id: SessionId) => ({
|
||||
sessionId: id,
|
||||
ctx: actxFake,
|
||||
session: {
|
||||
sessionId: id,
|
||||
loadOlder: vi.fn(),
|
||||
prompt: vi.fn(async () => ({ ok: true, value: { accepted: true } })),
|
||||
// Observable face for the input machine's queue read face.
|
||||
getSnapshot: () => session.getSnapshot(),
|
||||
subscribe: (fn: () => void) => session.subscribe(fn),
|
||||
},
|
||||
})
|
||||
ctx.provide('sessions', {
|
||||
list,
|
||||
binding: (id: SessionId) => ({ sessionId: id, session: { loadOlder: vi.fn() } }),
|
||||
scope: () => ({ get: () => scoped }),
|
||||
cell: (id: string) => (id === SID ? cell : undefined),
|
||||
binding: bindingOf,
|
||||
scope: () => actxFake,
|
||||
provideInfo: (id: string) => {
|
||||
if (id !== SID) return undefined
|
||||
if (info === undefined) {
|
||||
const hooks: Record<string, unknown> = { session }
|
||||
const props: Record<string, unknown> = {}
|
||||
for (const provider of providers) {
|
||||
const c = provider(bindingOf(SID))
|
||||
Object.assign(hooks, c.hooks ?? {})
|
||||
Object.assign(props, c.props ?? {})
|
||||
}
|
||||
info = { sessionId: SID, hooks, props }
|
||||
}
|
||||
return info
|
||||
},
|
||||
maybeProvideInfo(id: string | undefined) {
|
||||
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
|
||||
},
|
||||
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
|
||||
scopeOf: () => SID,
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
@@ -99,9 +131,8 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'conversation.empty': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
}, AppRoot)
|
||||
|
||||
@@ -194,18 +225,20 @@ describe('registrant load-order seam', () => {
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
ctx.provide('sessions', {
|
||||
list: createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready',
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
}),
|
||||
binding: () => undefined,
|
||||
scope: () => undefined,
|
||||
cell: () => undefined,
|
||||
provideInfo: () => undefined,
|
||||
maybeProvideInfo: () => ({ hooks: {}, props: {} }),
|
||||
provide: () => () => {},
|
||||
create: vi.fn(),
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
@@ -216,10 +249,9 @@ describe('registrant load-order seam', () => {
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'conversation.empty': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
},
|
||||
}, AppRoot)
|
||||
|
||||
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
|
||||
|
||||
@@ -29,8 +29,8 @@ const SID = 's1' as SessionId
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
|
||||
pending: [], queue: [], 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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -19,8 +19,8 @@ const SID = 's1' as SessionId
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
|
||||
pending: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt: null, lastAgentError: null,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, lastAgentError: null,
|
||||
} as ConversationSnapshot
|
||||
}
|
||||
|
||||
@@ -65,9 +65,9 @@ describe('render branch tails', () => {
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, intent: undefined, phase: 'ready' })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
const emptyWorkspaces = createSnapshotStore<WorkspaceListState>({
|
||||
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
const view = render(
|
||||
@@ -76,6 +76,8 @@ describe('render branch tails', () => {
|
||||
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSessions={bindSnapshotSelector(emptyList)}
|
||||
useWorkspaces={bindSnapshotSelector(emptyWorkspaces)}
|
||||
useInput={(() => { throw new Error('unused') }) as never}
|
||||
inputActions={{ setDraft: () => {}, submit: () => {} } as never}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
closeDetails={vi.fn()}
|
||||
@@ -98,9 +100,9 @@ describe('render branch tails', () => {
|
||||
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, 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(
|
||||
@@ -109,6 +111,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()}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
846
packages/client/ui-conversation/tests/input-machine.spec.ts
Normal file
846
packages/client/ui-conversation/tests/input-machine.spec.ts
Normal 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
|
||||
}
|
||||
|
||||
/** 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 ' })
|
||||
})
|
||||
})
|
||||
193
packages/client/ui-conversation/tests/input-matrix.spec.tsx
Normal file
193
packages/client/ui-conversation/tests/input-matrix.spec.tsx
Normal 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 ')
|
||||
})
|
||||
})
|
||||
264
packages/client/ui-conversation/tests/input-scenarios.spec.tsx
Normal file
264
packages/client/ui-conversation/tests/input-scenarios.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
99
packages/client/ui-conversation/tests/queue-dock.spec.tsx
Normal file
99
packages/client/ui-conversation/tests/queue-dock.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: [], codeDispatches: new Map(),
|
||||
pending: [], running: false, composerPhase, removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, intent: null, pendingPrompt, lastAgentError: null,
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, lastAgentError: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function mountConversation(pendingPrompt: ConversationSnapshot['pendingPrompt'] = null) {
|
||||
function mount(snapshot: ConversationSnapshot, workspaceRows: WorkspaceView[] = [{ ...workspace('one'), sessionIds: [SID] }]) {
|
||||
const root = sid('root')
|
||||
const sessions = createSnapshotStore<SessionListState>({
|
||||
ids: [root, SID],
|
||||
byId: {
|
||||
[root]: { id: root, displayTitle: 'Root', running: false, updatedAt: 1 },
|
||||
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, updatedAt: 2 },
|
||||
[root]: { id: root, displayTitle: 'Root', running: false, blank: false, updatedAt: 1 },
|
||||
[SID]: { id: SID, displayTitle: 'Child', parentId: root, cwd: '/projects/one', running: false, blank: false, updatedAt: 2 },
|
||||
},
|
||||
current: SID,
|
||||
intent: undefined,
|
||||
phase: 'ready',
|
||||
})
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState([{ ...workspace('one'), sessionIds: [SID] }]))
|
||||
const session = createSnapshotStore<ConversationSnapshot>(conversationSnapshot(
|
||||
pendingPrompt === null ? 'active' : 'blank', pendingPrompt,
|
||||
))
|
||||
const workspaces = createSnapshotStore<WorkspaceListState>(workspaceState(workspaceRows))
|
||||
const session = createSnapshotStore<ConversationSnapshot>(snapshot)
|
||||
const useSession = bindSnapshotSelector(session)
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.setDraft('ordinary draft')
|
||||
const send = vi.fn()
|
||||
const { wiring, sink } = fakeWiring()
|
||||
const useInput = bindSnapshotSelector(wiring.state)
|
||||
const inputActions = wiring.actions
|
||||
const stop = vi.fn()
|
||||
const open = vi.fn()
|
||||
const updateSessionPrompt = vi.fn()
|
||||
const retrySessionPrompt = vi.fn()
|
||||
const renderSlot = ((_key: string, _owner: object, opts?: { only?: string }) => (
|
||||
<div data-testid={`view-${opts?.only ?? 'all'}`} />
|
||||
)) as ConversationRootProps['renderSlot']
|
||||
const retargetWorkspace = vi.fn()
|
||||
const slotCalls: string[] = []
|
||||
let pickerOwner: unknown
|
||||
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
|
||||
slotCalls.push(key)
|
||||
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
|
||||
if (key === 'conversation.session') {
|
||||
return (
|
||||
<ConversationSession
|
||||
sessionId={SID}
|
||||
SessionProvider={({ children }) => children(SID)}
|
||||
useSession={useSession}
|
||||
useSessions={props.useSessions}
|
||||
useWorkspaces={props.useWorkspaces}
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot as never}
|
||||
views={{ list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 }}
|
||||
bindDraftMirror={write => wiring.bindMirror(write)}
|
||||
open={open}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (key === 'conversation.composer.bar') {
|
||||
// The real entry, mounted the way the outlet composes it: standard kit
|
||||
// (shared with the root's props below) + this entry's inject + owner.
|
||||
const bar = owner as ComposerBarOwnerProps
|
||||
return (
|
||||
<InputBar
|
||||
sessionId={SID}
|
||||
SessionProvider={({ children }) => children(SID)}
|
||||
useSession={useSession}
|
||||
useSessions={props.useSessions}
|
||||
useWorkspaces={props.useWorkspaces}
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
keyboard={wiring}
|
||||
stop={stop}
|
||||
renderSlot={(() => null) as InputBarProps['renderSlot']}
|
||||
{...bar}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <div data-testid={`view-${opts?.only ?? key}`} />
|
||||
}) as ConversationRootProps['renderSlot']
|
||||
const renderSlotChain = ((_key, _owner, opts) => opts?.fallback ?? null) as ConversationRootProps['renderSlotChain']
|
||||
const SessionProvider: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
||||
const props: ConversationRootProps = {
|
||||
sessionId: SID,
|
||||
useSession: bindSnapshotSelector(session),
|
||||
SessionProvider: ({ children }) => children(SID),
|
||||
useSession,
|
||||
useSessions: bindSnapshotSelector(sessions),
|
||||
useWorkspaces: bindSnapshotSelector(workspaces),
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
useInput,
|
||||
inputActions,
|
||||
renderSlot,
|
||||
renderSlotChain,
|
||||
SessionProvider,
|
||||
views: { list: () => [{ id: 'chat', label: 'Chat' }], subscribe: () => () => {}, version: () => 1 },
|
||||
send,
|
||||
stop,
|
||||
open,
|
||||
updateSessionPrompt,
|
||||
retrySessionPrompt,
|
||||
selectWorkspace: retargetWorkspace,
|
||||
}
|
||||
const view = render(<ConversationRoot {...props} />)
|
||||
return { view, chat, send, open, updateSessionPrompt, retrySessionPrompt }
|
||||
return {
|
||||
view, chat, sink, open, retargetWorkspace, session, slotCalls,
|
||||
pickerOwner: () => pickerOwner,
|
||||
rerender: () => { view.rerender(<ConversationRoot {...props} />) },
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConversationRoot draft ownership', () => {
|
||||
it('keeps ordinary per-Session composer text in the chat store and selects through runtime actions', () => {
|
||||
const b = mountConversation()
|
||||
describe('ConversationRoot resident composer', () => {
|
||||
it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => {
|
||||
const b = mount(conversationSnapshot())
|
||||
const box = b.view.getByRole('textbox')
|
||||
expect((box as HTMLTextAreaElement).value).toBe('ordinary draft')
|
||||
fireEvent.change(box, { target: { value: 'ordinary revised' } })
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(b.send).toHaveBeenCalledWith('ordinary revised', 'queue')
|
||||
expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue')
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
|
||||
expect(b.open).toHaveBeenCalledWith(sid('root'))
|
||||
})
|
||||
|
||||
it('reads a retained prompt from useSession and edits/retries it through the scoped Session', () => {
|
||||
const b = mountConversation({
|
||||
workspaceId: wid('one'), text: 'retry me', phase: 'failed',
|
||||
retry: 'send', error: 'offline',
|
||||
})
|
||||
it('hero phase: same textarea, hero chrome, no header, picker switches the workspace', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
|
||||
// Hero chrome present, view ring absent.
|
||||
expect(b.view.getByText("Let's start building")).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
// The same machine-backed textarea is live in the hero, and the
|
||||
// persistence mirror stays bound (ConversationSession mounts chrome-less
|
||||
// for blank sessions): hero typing reaches the chat store.
|
||||
const box = b.view.getByRole('textbox')
|
||||
expect((box as HTMLTextAreaElement).value).toBe('retry me')
|
||||
expect(b.view.getByRole('alert').textContent).toBe('Message send failed: offline')
|
||||
fireEvent.change(box, { target: { value: 'revised prompt' } })
|
||||
expect(b.updateSessionPrompt).toHaveBeenCalledWith('revised prompt')
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('ordinary draft')
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(b.retrySessionPrompt).toHaveBeenCalledOnce()
|
||||
expect(b.send).not.toHaveBeenCalled()
|
||||
fireEvent.change(box, { target: { value: 'draft in hero' } })
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('draft in hero')
|
||||
// Picker: open through the chip; a pick switches to the other
|
||||
// workspace's blank session (draft carry is apply-layer wiring).
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Choose workspace' }))
|
||||
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
|
||||
expect(owner.open).toBe(true)
|
||||
owner.onPick(wid('second'))
|
||||
expect(b.retargetWorkspace).toHaveBeenCalledWith(wid('second'))
|
||||
})
|
||||
|
||||
it('textarea DOM identity survives the hero → active flip', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
|
||||
const before = b.view.getByRole('textbox')
|
||||
fireEvent.change(before, { target: { value: 'kept across flip' } })
|
||||
// First message landed: content exists, phase leaves blank.
|
||||
b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
|
||||
b.rerender()
|
||||
const after = b.view.getByRole('textbox')
|
||||
expect(after).toBe(before)
|
||||
expect((after as HTMLTextAreaElement).value).toBe('kept across flip')
|
||||
expect(b.view.queryByText("Let's start building")).toBeNull()
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('blank session keeps the interactive picker chip (workspace switchable until the first message)', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true }))
|
||||
const chip = b.view.getByRole('button', { name: 'Choose workspace' })
|
||||
expect((chip as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(b.slotCalls).toContain('conversation.hero.workspace')
|
||||
})
|
||||
|
||||
it('prompt failure renders the promptError strip (ordinary failure, no transaction UI)', () => {
|
||||
const b = mount(conversationSnapshot({
|
||||
promptError: { op: 'send', error: { code: 'offline', message: 'Message send failed' } as never },
|
||||
}))
|
||||
expect(b.view.getByRole('alert').textContent).toContain('Message send failed (offline)')
|
||||
expect(b.view.queryByRole('button', { name: 'Retry' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slash"
|
||||
},
|
||||
{
|
||||
"path": "../ui-layout"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user