Merge origin/master: session projection base, command channel, composer seats

Resolution follows the reattachment plan: the #587 wire layer (planMode/
setPlanMode RPC, prompt.planMode, client Session projection fences) is
dropped in favor of the session-projection base now on master; master
structure wins in all shared files. Kept from the PR side: the approval-only
pending filter in ChatView (questions render solely through the composer
takeover) and the auto-merged QuestionComposer improvements. The deleted
host/runtime package and retired test carriers are removed with master.
This commit is contained in:
imccyu
2026-07-28 20:24:06 +08:00
3436 changed files with 159250 additions and 58904 deletions

View File

@@ -1,32 +1,28 @@
/**
* Client plugin body: register the conversation/details slot occupants and
* the no-session empty state, contribute the chat entry into the
* 'conversation.view' ring that the conversation registration declares, then
* mount the conversation service (class plugin) and the bash toolview sample.
* Assembly only — components receive everything through props: the framework
* standard kit and store faces arrive automatically from the declarations
* below; the inject factories contribute the plain-data-and-callbacks
* business face (design §5). Tool rows are ordinary keyed-slot registrations
* into 'conversation.chat.toolview' — no dedicated registry exists.
*/
/** Registers the conversation components, shared store, and service callbacks. */
import type { Context } from 'cordis'
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
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 { resolveToolPath } from './contract/tool-call-model.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import { InputHub } from './input/hub.ts'
import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.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'
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
export const inject = ['slots', 'layout', 'sessions']
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
@@ -37,24 +33,18 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat
return conversation
}
/**
* Client plugin body.
* @param ctx - client root context.
/** Mounts the conversation plugin.
* @param ctx - Client root context.
*/
export function apply(ctx: Context): void {
const sessions = ctx.sessions
const workspaces = ctx.workspaces
const layout = ctx.layout
const slots = ctx.slots
// Shared store handle, constructed here so its identity lives and dies with
// this fiber (a module-level handle would be a de-facto singleton). The
// conversation, chat-view, and details registrations all declare it; same
// scope key = same instance, so chat-view selection writes and details
// reads meet in one store.
// Apply-time construction keeps store identity bound to this fiber.
const chatStore = createChatStore()
// Tab projection over the view ring's ledger (list entries carry id/order/
// label as registration options; the ledger keeps them order-sorted).
const viewTabs = (): ViewTab[] => {
const tabs: ViewTab[] = []
for (const entry of slots.entries('conversation.view')) {
@@ -65,54 +55,99 @@ 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',
// Composer controls are additive bottom-row entries; the chain carries
// selector-routed replacements of the whole InputBar.
children: {
'conversation.view': { kind: 'list', scope: 'session' },
'conversation.session': { kind: 'single', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
'conversation.composer.controls': { kind: 'list', 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' },
},
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)
return {
views: {
list: viewTabs,
subscribe: fn => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
},
send: async (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()
try {
await scoped.send(trimmed, mode)
} catch (error: unknown) {
actions.restoreDraft(trimmed)
throw error
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
selectWorkspace: async (workspaceId) => {
const nextId = await workspaces.connectWorkspace(workspaceId)
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)
},
}),
}, 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>): 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 => {
const shell = inputHub.shell(sessionId)
return {
keyboard: shell,
stop: () => {
scoped.cancel().catch(() => {
scopedConversation(sessions, sessionId).cancel().catch(() => {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
})
},
open: (target: SessionId) => { sessions.open(target) },
hooks: { notices: shell.notices, lexicon: shell.lexicon },
}
},
}, 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,15 +159,28 @@ export function apply(ctx: Context): void {
id: 'chat',
order: 0,
label: 'Chat',
children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
children: {
'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => ({
openDetails: (target) => {
actions.select(target)
layout.openDetails()
},
loadOlder: () => { void sessions.manager.get(sessionId).loadOlder() },
}),
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
const scoped = scopedConversation(sessions, sessionId)
return {
openDetails: (target) => {
actions.select(target)
layout.openDetails()
},
openFile: (path) => {
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => {
// Host/OS open failures stay silent in the chat row; the native
// app surfaces its own error dialog when the path is unusable.
})
},
loadOlder: () => { void scoped.loadOlder() },
}
},
}, ChatView)
// Class-plugin mount (packages/AGENTS.md service form): the service
@@ -141,11 +189,22 @@ 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.
// The bash sample rides that exact seam, in third-party posture
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
ctx.plugin(bashToolviewSample)
// The todo_write row rides the same seam (a product registration, not a sample).
ctx.plugin(todoToolview)
// The plan strip rides the input dock above the queue rows (same posture).
ctx.plugin(todoDockEntry)
// 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,
@@ -154,18 +213,4 @@ export function apply(ctx: Context): void {
}),
}, DetailsPanel)
slots.register({
name: 'conversation.empty',
inject: (): EmptyStateInjected => ({
// ctx.get, not ctx.conversation: the service mounts on this plugin's
// own child fiber, so it is not in the inject topology the property
// proxy enforces; get reads the global store and stays loud on a torn
// boot through the optional-chain throw below.
startSession: (opts) => {
const conversation = ctx.get('conversation')
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
return conversation.startSession(opts)
},
}),
}, EmptyState)
}

View File

@@ -9,18 +9,6 @@
color: var(--dsw-alias-label-primary);
}
.pulse {
display: inline-block;
width: 8px;
height: 14px;
background: var(--dsw-alias-state-business-primary);
animation: pulse 1s infinite ease-in-out;
}
@keyframes pulse {
50% { opacity: 0.2; }
}
/* Interrupted-turn terminal marker: quiet inline tag, no animation. */
.stopped {
align-self: flex-start;

View File

@@ -2,8 +2,8 @@
// reasoning as the figma Think summary row (expand = indented gray text),
// other-block JSON fallback. Tool-call heads are NOT rendered here: the chat
// view groups them into tool rows through its keyed toolview slot (figma
// step-summary flow). Shared by finalized nodes and the streaming partial
// (pulse marker).
// step-summary flow). Shared by finalized nodes and the streaming partial;
// the turn-level loading dots live in the chat view's tail, not here.
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -14,7 +14,7 @@ import css from './AssistantMarkdown.module.css'
export interface AssistantMarkdownProps {
blocks: readonly AssistantBlock[]
streaming: boolean
/** Frozen partial of an aborted turn: rendered with a 已停止 marker, no pulse. */
/** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
interrupted?: boolean | undefined
}
@@ -28,7 +28,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
return (
<ToolRow
variant="think"
icon={<IconThinkOutline14 />}
icon={<IconThinkOutline14 size={14} />}
title="Think"
summary={firstLine(text)}
body={text}
@@ -40,18 +40,24 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted }: AssistantMarkdownProps) {
const last = blocks.length - 1
// Tool-call heads render as tool rows in the chat view's grouping pass, so
// a node that is only those heads (or empty) would paint an empty root
// between tool groups — skip the shell unless something visible remains.
const hasVisible = streaming
|| interrupted === true
|| blocks.some(block => block.kind !== 'tool-call')
if (!hasVisible) return null
return (
<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.
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
case 'tool-call': return null
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
}
})}
{streaming && <span className={css.pulse} />}
{interrupted && <span className={css.stopped}>已停止</span>}
</div>
)

View File

@@ -1,5 +1,6 @@
/* Chat flow: block gap 16 between narration/bubbles/tool groups (figma);
tool rows inside a group gap 10. Input padding cap rides the skeleton. */
/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool
runs) via the column gap and between consecutive tool rows via the group
gap. Input padding cap rides the skeleton. */
.root {
position: relative;
@@ -30,19 +31,54 @@
.toolGroup {
display: flex;
flex-direction: column;
gap: 10px;
gap: 16px;
}
.callRow {
border-radius: 6px;
}
/* Selection linkage: the selected call row wears the blue outline.
button-info-fill flips 500→400 with the theme, hitting the darker-blue
dark-mode spec exactly (business-primary stays 500 on both). */
.callRow[data-selected] {
outline: 1.5px solid var(--dsw-alias-button-info-fill);
outline-offset: 1px;
/* Selection still sets data-selected for details linkage; no outline —
tool rows match Think chrome (no selected ring). */
/* run_code sub-dispatch rows: indented under the parent row, left-edged so
the code turn reads as one unit; each nested row is itself a .callRow. */
.subCalls {
display: flex;
flex-direction: column;
gap: 4px;
margin: 4px 0 2px 22px;
padding-left: 8px;
border-left: 1px solid var(--dsw-alias-border-l2);
}
/* Turn loader: one row of four 2.5px pixels (StateDot blue) chasing left to
right with a stepped trail — flat keyframe holds, no tweening. Phase
offsets come from per-rect animation-delay (index * -250ms) set inline
by the component. */
.turnDots {
align-self: flex-start;
flex: none;
display: flex;
align-items: center;
/* One message line box: the dots center inside the text line height. */
height: 26px;
/* Same pin as StateDot: ongoing blue has no alias token (business-primary
is the 500 step, not this 450). */
color: var(--dsw-static-deepseek-450);
}
.turnDotCell {
fill: currentColor;
opacity: 0.15;
animation: dsh-turn-dots-chase 1s infinite;
}
@keyframes dsh-turn-dots-chase {
0%, 24.9% { opacity: 1; }
25%, 49.9% { opacity: 0.6; }
50%, 74.9% { opacity: 0.35; }
75%, 100% { opacity: 0.15; }
}
.hint {

View File

@@ -20,14 +20,14 @@ import {
memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react'
import type {
ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { SelectionTarget } from '../contract/views.ts'
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem } from './MessageItem.tsx'
import { PendingCard } from './PendingCard.tsx'
@@ -36,7 +36,7 @@ import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24
type OpenDetails = (target: SelectionTarget) => void
type OpenFile = (path: string) => void
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
type RenderToolRow = ChatViewSlotProps['renderSlot']
@@ -45,23 +45,22 @@ type RenderToolRow = ChatViewSlotProps['renderSlot']
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
/** One tool call row (result or running): dispatches through the keyed
* toolview slot with the owner payload; unregistered tools fall back to
* GenericToolCard at this render site. */
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected }: {
/** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a
* top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
* renders the running state exactly as a native in-flight row. */
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd }: {
renderSlot: RenderToolRow
callId: string
toolName: string
block: ToolResultNode | RunningToolCall
/** Surface seq for finalized results; the call's turn for running calls. */
seq: number
onOpenDetails: OpenDetails
node: CodeSubCall
openFile: OpenFile
selected: boolean
cwd: string | undefined
}) {
const settled = 'kind' in node
const toolName = settled ? node.call?.name ?? '' : node.name
const owner = useMemo(() => ({
callId, toolName, block,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) },
}), [callId, toolName, block, seq, onOpenDetails])
callId: node.callId, toolName, block: node, openFile, cwd,
}), [node, toolName, openFile, cwd])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
@@ -72,39 +71,146 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
)
})
/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId }: {
/** One tool call row (result or running): dispatches through the keyed
* toolview slot with the owner payload; unregistered tools fall back to
* GenericToolCard at this render site. A `run_code` call additionally
* renders its logged sub-dispatches as always-visible indented rows —
* each one the same keyed-slot dispatch as a native top-level call. */
const CallRow = memo(function CallRow({
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd,
}: {
renderSlot: RenderToolRow
callId: string
toolName: string
block: ToolResultNode | RunningToolCall
openFile: OpenFile
selected: boolean
/** `run_code` sub-dispatches in dispatch order (reference-stable per
* parent; running entries settle in place); undefined for ordinary calls. */
subCalls?: readonly CodeSubCall[] | undefined
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
selectedCallId?: string | undefined
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
}) {
const owner = useMemo(() => ({
callId, toolName, block, openFile, cwd,
}), [callId, toolName, block, openFile, cwd])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} />,
})}
{subCalls !== undefined && subCalls.length > 0 && (
<div className={css.subCalls} data-subcalls>
{subCalls.map(node => (
<SubCallRow
key={node.callId}
renderSlot={renderSlot}
node={node}
openFile={openFile}
selected={node.callId === selectedCallId}
cwd={cwd}
/>
))}
</div>
)}
</div>
)
})
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: {
renderSlot: RenderToolRow
results: readonly ToolResultNode[]
onOpenDetails: OpenDetails
/** Only set when the selected call lives in THIS group (memo economy). */
openFile: OpenFile
/** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */
selectedCallId: string | undefined
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
}) {
return (
<div className={css.toolGroup}>
{results.map((node) => (
{results.map(node => (
<CallRow
key={node.callId}
renderSlot={renderSlot}
callId={node.callId}
toolName={node.call?.name ?? ''}
block={node}
seq={node.seq}
onOpenDetails={onOpenDetails}
openFile={openFile}
selected={node.callId === selectedCallId}
subCalls={codeDispatches.get(node.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
/>
))}
</div>
)
})
/** One command lifecycle row: keyed dispatch on the command name with the
* generic card as the render-site fallback (zero registration required). A
* run-less cross-window node has no name and always lands on the fallback. */
const CommandRow = memo(function CommandRow({ renderSlot, node }: {
renderSlot: RenderToolRow
node: CommandNode
}) {
const owner = useMemo(() => ({ node }), [node])
return (
<div className={css.callRow}>
{renderSlot('conversation.chat.commandview', owner, {
entryKey: node.name ?? '',
fallback: <GenericCommandCard {...owner} />,
})}
</div>
)
})
/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot
* 2px cell, same blue) chasing left to right with a stepped trail — flat
* keyframe holds, no tweening, no rotation. Phase offsets come from
* per-rect animation-delay. */
const LOADER_CELLS = [0, 5, 10, 15] as const
function TurnDots() {
return (
/* The wrapper is a 26px line box (message line height) so the loader
occupies one text line and centers the dots inside it. */
<div className={css.turnDots} aria-hidden="true">
<svg
width="17.5"
height="2.5"
viewBox="0 0 17.5 2.5"
shapeRendering="crispEdges"
>
{LOADER_CELLS.map((x, index) => (
<rect
key={x}
className={css.turnDotCell}
x={x}
y="0"
width="2.5"
height="2.5"
/* Negative delay phases the chase so every cell animates from mount. */
style={{ animationDelay: `${(index - LOADER_CELLS.length) * 250}ms` }}
/>
))}
</svg>
</div>
)
}
/** The streaming partial, isolated so chunk batches re-render only this tail.
* onGrow lets the scroll owner follow content the parent never re-renders for. */
function StreamingTail({ useSession, onGrow }: {
useSession: UseConversation
onGrow: () => void
}) {
const partial = useSession((s) => s.partial)
const partial = useSession(s => s.partial)
useLayoutEffect(() => {
onGrow()
})
@@ -112,16 +218,23 @@ function StreamingTail({ useSession, onGrow }: {
return <AssistantMarkdown blocks={partial.blocks} streaming />
}
/** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
const nodes = useSession((s) => s.nodes)
const runningCalls = useSession((s) => s.runningCalls)
const pending = useSession((s) => s.pending)
const openState = useSession((s) => s.openState)
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
const hasMore = useSession((s) => s.hasMore)
const loadingOlder = useSession((s) => s.loadingOlder)
const selectedCallId = useStore((s) => s.selection?.callId)
/**
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
const running = useSession(s => s.running)
const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches)
const pending = useSession(s => s.pending)
const openState = useSession(s => s.openState)
const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
const hasMore = useSession(s => s.hasMore)
const loadingOlder = useSession(s => s.loadingOlder)
const selectedCallId = useStore(s => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
@@ -203,14 +316,17 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
const renderItem = (item: ChatFlowItem): ReactNode => {
if (item.kind === 'tool-group') {
const inGroup = selectedCallId !== undefined
&& item.results.some((r) => r.callId === selectedCallId)
&& item.results.some(r => r.callId === selectedCallId
|| codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true)
return (
<ToolGroup
key={item.key}
renderSlot={renderSlot}
results={item.results}
onOpenDetails={openDetails}
openFile={openFile}
selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches}
cwd={cwd}
/>
)
}
@@ -218,6 +334,9 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
if (node.kind === 'assistant') {
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
}
if (node.kind === 'command') {
return <CommandRow key={item.key} renderSlot={renderSlot} node={node} />
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
return <MessageItem key={item.key} node={node} />
@@ -227,36 +346,41 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
<div className={css.root}>
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
<div className={css.column}>
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
{loadingOlder ? '加载中…' : '加载更早'}
</button>
</div>
)}
{items.map(renderItem)}
<StreamingTail useSession={useSession} onGrow={onGrow} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map((call) => (
<CallRow
key={call.callId}
renderSlot={renderSlot}
callId={call.callId}
toolName={call.name}
block={call}
seq={call.turn}
onOpenDetails={openDetails}
selected={call.callId === selectedCallId}
/>
))}
</div>
)}
{pending.map((item) => item.kind === 'approval'
? <PendingCard key={item.key} item={item} />
: null)}
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
{loadingOlder ? '加载中…' : '加载更早'}
</button>
</div>
)}
{items.map(renderItem)}
<StreamingTail useSession={useSession} onGrow={onGrow} />
{runningCalls.length > 0 && (
<div className={css.toolGroup}>
{runningCalls.map(call => (
<CallRow
key={call.callId}
renderSlot={renderSlot}
callId={call.callId}
toolName={call.name}
block={call}
openFile={openFile}
selected={call.callId === selectedCallId}
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
/>
))}
</div>
)}
{pending.map(item => item.kind === 'approval'
? <PendingCard key={item.key} item={item} />
: null)}
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnDots />}
</div>
</div>
<StatsLine useSession={useSession} />

View File

@@ -0,0 +1,38 @@
// GenericCommandCard: the default command row — a stripped-down
// GenericToolCard rendering the dispatched command line and the settlement
// text. Supplied by the chat view as the keyed commandview slot's render-site
// fallback (an unregistered command name lands here); registrants may compose
// it as a base, feeding the same owner payload through.
import { ToolRow } from './ToolRow.tsx'
import type { ToolRowState } from '../contract/tool-call-model.ts'
import type { CommandRowOwnerProps } from '../contract/slots.ts'
import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
/** Node state → row state semantic (running while unsettled; outcome kind after). */
function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): ToolRowState {
if (outcome === null) return 'running'
return outcome.kind === 'error' ? 'error' : 'ok'
}
export function GenericCommandCard({ node }: CommandRowOwnerProps) {
const text = node.outcome?.text
const summary = node.outcome === null
? '执行中…'
: text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
// Display line rebuilt from the structured payload (args carries its own
// separator whitespace verbatim); a cross-window node whose run page fell
// out of the window has neither.
const title = node.name === null ? '命令' : `/${node.name}${node.args ?? ''}`
return (
<ToolRow
variant="others"
icon={<IconApiOutline14 size={16} />}
title={title}
summary={summary}
// Expandable only when the outcome text overflows a one-line summary.
body={text !== undefined && text.includes('\n') ? text : null}
state={stateOf(node.outcome)}
/>
)
}

View File

@@ -6,35 +6,40 @@
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowOwnerProps } from '../contract/slots.ts'
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
import { ToolRow } from './ToolRow.tsx'
import { IconSparkle16 } from './IconSparkle16.tsx'
/** Variant leading icons (figma table). */
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
think: <IconThinkOutline14 />,
search: <IconSearchOutline16 />,
read: <IconBrowseOutline16 />,
bash: <IconApiOutline14 size={16} />,
write: <IconEditOutline16 />,
edit: <IconEditOutline16 />,
others: <IconSparkle16 />,
think: <IconThinkOutline14 size={14} />,
search: <IconSearchOutline16 size={14} />,
read: <IconBrowseOutline16 size={14} />,
bash: <IconApiOutline14 size={14} />,
write: <IconEditOutline16 size={14} />,
edit: <IconEditOutline16 size={14} />,
code: <IconCodeOutline16 size={14} />,
others: <IconSparkle16 size={14} />,
}
export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) {
const model = toolRowModel(toolName, block)
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
const model = toolRowModel(toolName, block, cwd)
const singleFile = model.filePath !== undefined
return (
<ToolRow
variant={model.variant}
toolName={toolName}
icon={VARIANT_ICONS[model.variant]}
title={model.title}
summary={model.summary}
body={model.body}
// Single-file tools never expose an args body — the path link is the only action.
body={singleFile ? null : model.body}
state={model.state}
onOpenDetails={openDetails}
filePath={model.filePath}
onOpenFile={singleFile ? openFile : undefined}
/>
)
}

View File

@@ -1,10 +1,11 @@
/* User bubble: right-aligned, figma r22 fill = the bubble specific token
(#EDF3FE light / dark pair rides the token sheet). */
/* User bubble: right-aligned column (bubble + IconActions). Figma
User_Bubble/message_container 659:38813 — r22 fill, actions gap 6 below. */
/* Block spacing is the flow column's gap alone — no extra padding here. */
.userRow {
display: flex;
justify-content: flex-end;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.bubble {
@@ -19,6 +20,46 @@
color: var(--dsw-alias-label-primary);
}
.actions {
display: flex;
align-items: center;
gap: 10px;
height: 28px;
}
/* Hover-capable pointers: hide until the row is hovered/focused. Touch /
hover:none keeps actions visible (opacity:0 still hit-tests). */
@media (hover: hover) {
.actions {
opacity: 0;
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
}
.userRow:hover .actions,
.userRow:focus-within .actions {
opacity: 1;
}
}
.action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 6px;
border: none;
border-radius: 28px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.action:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.badge {
display: inline-block;
margin-bottom: 4px;
@@ -32,3 +73,18 @@
.contextRow {
padding: 2px 0;
}
/* Reference chip projection inside a user bubble (`<skill>name</skill>` model
spans render as chips; free geometry — no textarea pairing here). */
.refChip {
display: inline-block;
margin: 0 2px;
padding: 0 8px;
border-radius: 6px;
background: rgba(97, 135, 216, 0.22);
color: var(--dsw-alias-label-primary);
font-size: 0.85em;
line-height: 1.6;
white-space: nowrap;
vertical-align: baseline;
}

View File

@@ -1,13 +1,18 @@
// MessageItem: the four simple node kinds — user bubble (right-aligned),
// steering (badged bubble), context injection and unknown-surface JSON rows.
// Props are frozen node slices off the snapshot cache; memo holds across
// streaming because unchanged nodes keep their references.
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
// copy / branch / edit IconActions), steering (badged bubble), context
// injection and unknown-surface JSON rows. Props are frozen node slices off
// the snapshot cache; memo holds across streaming because unchanged nodes
// keep their references.
import { memo } from 'react'
import { memo, useCallback } from 'react'
import type { ReactNode } from 'react'
import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import {
IconBranchOutline16, IconCopyOutline16, IconEditOutline16,
JsonBlock, MessageText, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import css from './MessageItem.module.css'
export interface MessageItemProps {
@@ -25,16 +30,121 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
return { text: texts.join(''), rest }
}
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
async function writeClipboard(text: string): Promise<void> {
// lib.dom types clipboard non-optional, but insecure contexts omit it —
// that runtime gap is exactly what this guard detects.
/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
} catch {
// Denied permissions / iframe policy.
}
return
}
// execCommand('copy') is the only clipboard fallback where the async API
// is missing (insecure contexts); deprecated but deliberately retained.
/* eslint-disable @typescript-eslint/no-deprecated */
const exec = typeof document.execCommand === 'function'
? document.execCommand.bind(document)
: undefined
if (exec === undefined) return
const el = document.createElement('textarea')
el.value = text
el.setAttribute('readonly', '')
el.style.position = 'fixed'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
try {
exec('copy')
} catch {
// Clipboard unavailable; the button stays idle.
}
/* eslint-enable @typescript-eslint/no-deprecated */
el.remove()
}
/**
* 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}</>
}
/** User-bubble IconActions (figma 659:38820): copy is live; branch/edit are chrome stubs. */
function UserActions({ text }: { text: string }) {
const onCopy = useCallback(() => {
void writeClipboard(text)
}, [text])
return (
<div className={css.actions}>
<Tooltip label="复制" side="bottom">
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
<IconCopyOutline16 />
</button>
</Tooltip>
<Tooltip label="在新对话中分支" side="bottom">
<button type="button" className={css.action} aria-label="在新对话中分支">
<IconBranchOutline16 />
</button>
</Tooltip>
<Tooltip label="编辑" side="bottom">
<button type="button" className={css.action} aria-label="编辑">
<IconEditOutline16 />
</button>
</Tooltip>
</div>
)
}
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
switch (node.kind) {
case 'user':
case 'user': {
const { text, rest } = contentText(node.content)
return (
<div className={css.userRow}>
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
</div>
<UserActions text={text} />
</div>
)
}
case 'steering': {
const { text, rest } = contentText(node.content)
return (
<div className={css.userRow}>
<div className={css.bubble}>
{node.kind === 'steering' && <span className={css.badge}>插话</span>}
<MessageText text={text} />
<span className={css.badge}>插话</span>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
</div>
</div>
@@ -43,7 +153,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
case 'context':
return (
<div className={css.contextRow}>
<JsonBlock label="上下文注入" payload={{ content: node.content, meta: node.meta }} />
<JsonBlock label="上下文注入" payload={{ content: node.content, source: node.source }} />
</div>
)
default:

View File

@@ -1,9 +1,4 @@
// StatsLine: the session stats row (figma 122:11212 "cache hit 92% · 1,284
// tokens · 45.2s · 5 turns · 32 steps"), rendered by ChatView under the flow
// (part of the chat view body — the chrome attachment mechanism retired with
// the view ring). Duration has no data source in P-I (ledger). Subscribes to
// `nodes` only: chunk batches never swap that reference, so the row renders
// zero times during streaming (the RFC performance model's acceptance row).
// Settled-node identity prevents stream-delta updates from rerendering this row.
import { memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
@@ -58,7 +53,7 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
const nodes = useSession((s) => s.nodes)
const nodes = useSession(s => s.nodes)
const stats = useMemo(() => deriveStats(nodes), [nodes])
if (stats.steps === 0) return null
const parts: string[] = []

View File

@@ -7,22 +7,47 @@
}
.row {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
.row[data-clickable] {
cursor: pointer;
border-radius: 6px;
/* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band —
theme background at 60% — glides over the row content from off-left to
off-right, washing glyphs and icon toward the background as it passes.
ease-out with a 10% end hold gives each pass a beat before the next. */
.root[data-state='running'] .row::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-tool-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
.row[data-clickable]:hover {
background: var(--dsw-alias-interactive-bg-hover);
@keyframes dsh-tool-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
/* Expand-on-row (Think / code): pointer only — no row fill hover. */
.row[data-expandable] {
cursor: pointer;
}
.leading {
position: relative; /* .chevronHover overlay anchor */
flex: none;
width: 16px;
height: 16px;
@@ -42,6 +67,21 @@
color: var(--dsw-alias-label-secondary);
}
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
shared product accent and tool-owned action title. */
.root[data-tool^='cordis_'] .leading,
.root[data-tool^='cordis_'] .title {
color: var(--dsw-alias-state-business-primary);
}
.root[data-tool^='cordis_'] .title {
font-weight: 500;
}
.root[data-tool^='cordis_'] .sep {
background: var(--dsw-alias-state-business-primary);
}
button.leading {
cursor: pointer;
}
@@ -50,11 +90,36 @@ button.leading {
color: var(--dsw-alias-label-secondary);
}
/* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
into a down chevron before the row is opened. The chevron overlays the
icon cell absolutely so both can stay mounted for the opacity transition. */
.iconIdle {
display: inline-flex;
opacity: 1;
transition: opacity 100ms ease;
}
.chevronHover {
position: absolute;
inset: 0;
margin: auto;
opacity: 0;
transition: opacity 100ms ease;
}
.row:hover .iconIdle {
opacity: 0;
}
.row:hover .chevronHover {
opacity: 1;
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-primary-dimmed);
color: var(--dsw-alias-label-secondary);
}
.sep {
@@ -77,6 +142,29 @@ button.leading {
color: var(--dsw-alias-label-tertiary);
}
/* File-tool path: same geometry as .summary; hover underline + pointer. */
.fileLink {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
padding: 0;
border: none;
background: none;
font: inherit;
text-align: left;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.fileLink:hover {
text-decoration: underline;
}
/* Expanded body: pad-left 22 indented gray text, no border, no fill. */
.body {
padding: 4px 0 4px 22px;
@@ -86,3 +174,10 @@ button.leading {
word-break: break-word;
color: var(--dsw-alias-label-tertiary);
}
/* The code variant's expanded body is the run_code program, rendered through
the shared CodeBlock (shiki-highlighted TypeScript); only indentation is
this row's concern. */
.codeBody {
margin: 4px 0 4px 22px;
}

View File

@@ -1,18 +1,21 @@
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
// 16px leading slot (state dot / tool icon, chevron when expanded) + title +
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
// separator dot + FILL-truncated summary. Expanded body is indented gray text;
// no inline output (full results live in the details panel). Expand state is
// component-local view state; row click hands the selection off to the owner.
// component-local view state. File-tool summaries are path links that open
// through the host; the row itself is not a details-panel control.
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'
export interface ToolRowProps {
variant: ToolRowVariant
/** Wire tool name for tool-owned styling layered over the generic variant. */
toolName?: string | undefined
/** Leading 16px tool icon, shown while collapsed and not running/failed. */
icon: ReactNode
title: string
@@ -22,15 +25,20 @@ export interface ToolRowProps {
state: ToolRowState
/** Makes the row itself the expand control instead of only its leading icon. */
expandOnRowClick?: boolean | undefined
/** Selection handoff (row click), already bound to this call by the owner. */
onOpenDetails?: (() => void) | undefined
/**
* Filesystem path from tool args; when set with onOpenFile, the summary
* renders as a hover-underline link that opens the host default app.
*/
filePath?: string | undefined
/** Open the path with the host OS default application (already cwd-resolved). */
onOpenFile?: ((path: string) => void) | undefined
}
/** Leading-slot state substitution: the tool icon yields to the state semantic
* (running = blue ring, error = red, interrupted = amber halo; ok = icon). */
/** Leading-slot state substitution: the tool icon yields to the terminal state
* semantic (error = red, interrupted = amber halo). Running keeps the icon —
* the row sweep (CSS on data-state) carries the in-flight signal. */
function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
switch (state) {
case 'running': return <StateDot state="ongoing" />
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return icon
@@ -39,20 +47,26 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
export function ToolRow({
variant,
toolName,
icon,
title,
summary,
body,
state,
expandOnRowClick = false,
onOpenDetails,
filePath,
onOpenFile,
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const expandable = body !== null
// A row that names a single file keeps one interaction (open that path);
// args expand is off whether or not the open callback is wired yet.
const singleFile = filePath !== undefined
const fileLink = singleFile && onOpenFile !== undefined
const expandable = body !== null && !singleFile
const open = expanded && expandable
const rowExpands = expandable && expandOnRowClick
const toggleExpand = () => {
setExpanded((v) => !v)
setExpanded(v => !v)
}
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
@@ -63,15 +77,32 @@ export function ToolRow({
event.preventDefault()
toggleExpand()
}
const openFile = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
if (filePath !== undefined) onOpenFile?.(filePath)
}
// Expandable rows preview the toggle on hover: the tool icon yields to a
// down chevron (CSS swap on .row:hover); state dots still take precedence.
const collapsedIcon = expandable
? (
<>
<span className={css.iconIdle}>{icon}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
</>
)
: icon
const leading = open
? <IconChevronDownOutline14 className={css.chevron} />
: leadingFor(state, collapsedIcon)
return (
<div className={css.root} data-variant={variant} data-state={state}>
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
<div
className={css.row}
data-clickable={rowExpands || onOpenDetails !== undefined || undefined}
data-expandable={rowExpands || undefined}
role={rowExpands ? 'button' : undefined}
tabIndex={rowExpands ? 0 : undefined}
aria-expanded={rowExpands ? open : undefined}
onClick={rowExpands ? toggleExpand : onOpenDetails}
onClick={rowExpands ? toggleExpand : undefined}
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
>
{expandable && !rowExpands ? (
@@ -81,22 +112,34 @@ export function ToolRow({
aria-expanded={open}
onClick={toggleFromLeading}
>
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
{leading}
</button>
) : (
<span className={css.leading}>
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
{leading}
</span>
)}
<span className={css.title}>{title}</span>
{!open && (
<>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{summary}</span>
{fileLink ? (
<button
type="button"
className={css.fileLink}
onClick={openFile}
>
{summary}
</button>
) : (
<span className={css.summary}>{summary}</span>
)}
</>
)}
</div>
{open && <div className={css.body}>{body}</div>}
{open && (variant === 'code'
? <CodeBlock code={body} lang="typescript" className={css.codeBody} />
: <div className={css.body}>{body}</div>)}
</div>
)
}

View File

@@ -12,6 +12,16 @@ export type ChatFlowItem =
| { kind: 'node'; key: string; node: ConversationNode }
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
/** An assistant node that renders nothing: only tool-call heads (rows render
* via the grouping pass) and blank text/reasoning. Skipped by the flow so it
* neither costs column gaps nor splits a tool-row run. Interrupted nodes
* always render (the 已停止 marker). */
function rendersNothing(node: ConversationNode): boolean {
return node.kind === 'assistant' && node.interrupted !== true
&& node.blocks.every(b => b.kind === 'tool-call'
|| ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === ''))
}
/**
* Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes (surface order).
@@ -21,6 +31,7 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
const items: ChatFlowItem[] = []
let group: ToolResultNode[] | null = null
for (const node of nodes) {
if (rendersNothing(node)) continue
if (node.kind === 'tool-result') {
if (group === null) {
group = [node]

View File

@@ -1,22 +1,22 @@
/**
* Slot-ring contract for the conversation package: the 'conversation.view'
* slot this package declares (the view ring — one list entry per conversation
* view tab), the chat view's per-tool row hole ('conversation.chat.toolview',
* keyed on the wire tool name), and the composed props shapes its registrants
* mount into the layout-owned slots (conversation / details /
* conversation.empty) plus its own slots. Terminal slot design (§3): full
* component props are the automatic shares — PropsRuntime<K> (framework
* standard kit) & PropsRenderSlots<S> (declared children) & PropsStore<H>
* (declared store's read/write faces) & the injected business face declared
* here.
*/
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
/** Conversation slot declarations and their composed component props. */
import type { ReactNode, RefObject } from 'react'
import type {
InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, 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, InputNotice, 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
@@ -33,6 +33,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* `fallback` for unregistered tools.
*/
'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
/**
* The chat view's per-command row hole: keyed dispatch on the command
* name (`command/run.name`; a run-less cross-window node has none and
* always lands on the fallback). Declared by the chat view entry; the
* render site dispatches via `entryKey: name` with GenericCommandCard as
* the `fallback` — a slash command renders durably with zero
* registration, and a domain upgrades by registering one row component.
*/
'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
/**
* The composer takeover chain: entries are selector-routed replacements
* of the default InputBar. Declared by this package's 'conversation'
@@ -42,11 +51,82 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
*/
'conversation.composer': { kind: 'chain'; scope: 'session'; owner: ComposerChainProps }
/**
* Additive controls in the default composer's bottom-left row. Entries
* receive the session standard kit and no owner payload.
* 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.composer.controls': { kind: 'list'; scope: 'session'; owner: ComposerControlOwnerProps }
'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
}
/**
@@ -72,8 +152,13 @@ export interface ToolRowOwnerProps {
toolName: string
/** Frozen call slice: the running call or the settled result node. */
block: ToolCallBlock
/** Open the details panel for this call (session-level facility, supplied by the view). */
openDetails(): void
/** Session workspace root; path summaries display relative to it. */
cwd?: string | undefined
/**
* Open a tool-arg filesystem path with the host OS default application.
* The chat view resolves relative paths against the session cwd.
*/
openFile: (path: string) => void
}
/**
@@ -85,6 +170,22 @@ export interface ToolRowOwnerProps {
*/
export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
/**
* Owner share of the per-command row slot: the frozen {@link CommandNode}
* slice off the snapshot (cache-stable reference — memo premise). The node
* carries the whole lifecycle (structured name/args, pairing id,
* outcome-or-executing), so a
* registrant needs no second data channel; domain state arrives through its
* own projection cell.
*/
export interface CommandRowOwnerProps {
/** Folded command lifecycle node (run + optional done). */
node: CommandNode
}
/** Full props of a registered command-row component (same shape rule as {@link ToolRowProps}). */
export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'>
/**
* Base props of a conversation view entry: the framework standard kit for the
* session-scope 'conversation.view' slot (useSession narrowed to the
@@ -98,28 +199,81 @@ export type ConvViewProps = PropsRuntime<'conversation.view'>
/** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */
export type ChatStore = ReturnType<typeof createChatStore>
/**
* Injected share of the conversation slot: plain data and callbacks only
* (design §5 — hooks are framework-made). The store lines that used to ride
* here live in the declared {@link ChatStore}; ancestry derives from the
* standard useSessions hook in-component; views render through the declared
* 'conversation.view' child slot, with this face projecting the tab strip.
*/
/** Business callbacks injected into the conversation slot. */
export interface ConversationInjected {
/** View tab read face (uSES triple over the 'conversation.view' slot ledger). */
views: {
list(): readonly ViewTab[]
subscribe(fn: () => void): () => void
version(): number
}
/** Send choreography through Host admission: trims, clears the draft optimistically, restores it on failure. */
send(text: string, mode: 'queue' | 'steer'): Promise<void>
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
stop(): void
/** Navigate to another session (breadcrumb ancestors). */
open(id: SessionId): void
/**
* 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) => Promise<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
}
/** 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
}
/**
* 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
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
hooks: {
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
notices: ObservableSnapshot<InputNotice | null>
/** Hot plain-text reference lexicon for the decoration scan (decision 21). */
lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
}
}
/**
* 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 (hooks compartment bound). */
export type ComposerBarProps =
PropsRuntime<'conversation.composer.bar'>
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
& InjectFace<ComposerBarInjected>
/**
* Composer chain currency: what ConversationRoot dispatches at its
* renderSlotChain site. The owner declares the currency only — never a
@@ -128,22 +282,29 @@ export interface ConversationInjected {
* with zero owner changes.
*/
export interface ComposerChainProps {
/** The session's live pending waits, in arrival order (snapshot reference). */
interactions: readonly PendingInteraction[]
}
/** Owner seat for additive composer controls; the render site supplies no payload. */
export interface ComposerControlOwnerProps {}
/** Full props of an additive composer-control entry. */
export type ComposerControlProps = PropsRuntime<'conversation.composer.controls'>
/** Full conversation-slot component props: runtime & child-render (view ring, controls, 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' | 'conversation.composer.controls'
| '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'
>
& PropsStore<ChatStore> & ConversationInjected
& 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
@@ -151,14 +312,18 @@ export type ConversationSlotProps =
*/
export interface ChatViewInjected {
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
openDetails(target: SelectionTarget): void
/** Pull one older history page. */
loadOlder(): void
openDetails: (target: SelectionTarget) => void
/**
* Open a tool-arg filesystem path with the host OS default application
* (relative paths resolve against the session cwd).
*/
openFile: (path: string) => void
loadOlder: () => void
}
/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */
/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */
export type ChatViewSlotProps =
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview'>
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview'>
& PropsStore<ChatStore> & ChatViewInjected
/**
@@ -167,17 +332,18 @@ export type ChatViewSlotProps =
*/
export interface DetailsInjected {
/** Close the details panel (layout geometry stays with ctx.layout). */
closeDetails(): void
closeDetails: () => void
}
/** Full details-slot component props: selection arrives through the shared store, call material through useSession. */
export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected
/** Injected share of the no-session empty-state slot. */
export interface EmptyStateInjected {
/** The create → navigate → first-send chain, in one service call. */
startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void>
/** Owner share common to the hero / New-Session Workspace pickers. */
export interface EmptyWorkspaceOwnerProps {
open: boolean
anchorRef?: RefObject<HTMLElement>
/** Currently active workspace (renders a trailing check in the picker list). */
selectedId?: WorkspaceId | undefined
onPick: (workspaceId: WorkspaceId) => void
onClose: () => void
}
/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */
export type EmptyStateSlotProps = PropsRuntime<'conversation.empty'> & EmptyStateInjected

View File

@@ -13,8 +13,8 @@ export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
/** The frozen slice the chat view hands to toolview components as `block`
* (both members are cache-stable references off ConversationSnapshot). */
/** The seven row variants (think is fed by reasoning blocks, not tool calls). */
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'others'
/** The eight row variants (think is fed by reasoning blocks, not tool calls). */
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'code' | 'others'
/** Row state semantic; colors self-supplied via StateDot (design gives none). */
export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped'
@@ -22,7 +22,7 @@ export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped'
/** Figma row titles per variant (design literals, not translatable copy). */
export const VARIANT_TITLES: Record<ToolRowVariant, string> = {
think: 'Think', search: 'Search', read: 'Read', bash: 'Bash',
write: 'Write', edit: 'Edit', others: 'Tool call',
write: 'Write', edit: 'Edit', code: 'Code', others: 'Tool call',
}
/** Known tool name -> variant. */
@@ -35,6 +35,17 @@ const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
glob: 'search',
write: 'write',
edit: 'edit',
run_code: 'code',
cordis_inspect: 'read',
cordis_mount: 'code',
cordis_unmount: 'others',
}
/** Tool-owned titles that refine a generic row variant without replacing it. */
const TOOL_TITLES: Record<string, string> = {
cordis_inspect: 'Inspect',
cordis_mount: 'Mount temporary Plugin',
cordis_unmount: 'Unmount temporary Plugin',
}
/**
@@ -51,6 +62,12 @@ export interface ToolRowModel {
variant: ToolRowVariant
title: string
summary: string
/**
* Filesystem path from args (`path` / `file_path`) when the row is a file
* tool; absent for URL reads and non-file tools. The chat view resolves
* relative values against the session cwd before opening.
*/
filePath: string | undefined
/** Expanded-body text (pretty args); null = row not expandable. */
body: string | null
state: ToolRowState
@@ -86,9 +103,18 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
think: [],
write: ['path', 'file_path'],
edit: ['path', 'file_path'],
code: ['description'],
others: [],
}
/** Strip the workspace root from workspace-rooted absolute paths (display only). */
function relativizeToCwd(text: string, cwd: string | undefined): string {
if (cwd === undefined || cwd === '') return text
const root = cwd.replace(/[/\\]+$/, '')
if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1)
return text
}
function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
const parsed = parseArgs(argsRaw)
if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw)
@@ -101,34 +127,75 @@ function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
return firstLine(argsRaw)
}
function deriveBody(argsRaw: string): string | null {
/** Path keys only — never `url` (web_fetch lands on the read variant). */
const FILE_PATH_KEYS = ['path', 'file_path'] as const
/** File-tool variants whose summary may be an openable workspace path. */
const FILE_PATH_VARIANTS: ReadonlySet<ToolRowVariant> = new Set(['read', 'write', 'edit'])
function deriveFilePath(variant: ToolRowVariant, argsRaw: string): string | undefined {
if (!FILE_PATH_VARIANTS.has(variant)) return undefined
const parsed = parseArgs(argsRaw)
if (typeof parsed !== 'object' || parsed === null) return undefined
const picked = pickString(parsed as Record<string, unknown>, FILE_PATH_KEYS)
return picked === undefined ? undefined : firstLine(picked)
}
/**
* Resolve a tool-arg path against the session cwd for host.openPath.
* Absolute POSIX/Windows paths pass through; relative paths join under cwd.
* @param cwd - session working directory (may be absent for ungrouped sessions).
* @param path - path as carried in tool args.
* @returns a host-facing path string.
*/
export function resolveToolPath(cwd: string | undefined, path: string): string {
if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path
if (cwd === undefined || cwd === '') return path
const base = cwd.replace(/[/\\]+$/, '')
const rel = path.replace(/^[/\\]+/, '')
return `${base}/${rel}`
}
function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
if (argsRaw === '') return null
const parsed = parseArgs(argsRaw)
return parsed === undefined ? argsRaw : JSON.stringify(parsed, null, 2)
if (parsed === undefined) return argsRaw
// The code row's expanded body IS the program (monospace via the row's
// variant styling), not the args JSON envelope around it.
if (variant === 'code' && typeof parsed === 'object' && parsed !== null) {
const code = (parsed as Record<string, unknown>).code
if (typeof code === 'string' && code !== '') return code
}
return JSON.stringify(parsed, null, 2)
}
/**
* Derive the full row model from a frozen call slice.
* @param toolName - wire tool name (dispatch-supplied; survives windowless results).
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @param cwd - session workspace root; workspace-rooted path summaries display relative to it.
* @returns the row model.
*/
export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowModel {
export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel {
const variant = classifyTool(toolName)
const done = 'kind' in block
const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? ''
const state: ToolRowState = !done ? 'running'
: block.error?.code === 'interrupted' ? 'stopped'
: block.isError ? 'error' : 'ok'
const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw)
const base = argsRaw === '' ? block.callId : relativizeToCwd(deriveSummary(variant, argsRaw), cwd)
const toolTitle = TOOL_TITLES[toolName]
// Others keeps the static "Tool call" title (figma literal); the real tool
// name rides the mutable summary slot so no information is lost.
const summary = variant === 'others' && toolName !== '' ? `${toolName} · ${base}` : base
// name rides the mutable summary slot unless the tool owns a specific title.
const summary = variant === 'others' && toolName !== '' && toolTitle === undefined
? `${toolName} · ${base}`
: base
return {
variant,
title: VARIANT_TITLES[variant],
title: toolTitle ?? VARIANT_TITLES[variant],
summary,
body: deriveBody(argsRaw),
filePath: deriveFilePath(variant, argsRaw),
body: deriveBody(variant, argsRaw),
state,
}
}

View File

@@ -1,14 +1,4 @@
/**
* Shared conversation contract primitives: the view tab projection (slot
* entries in 'conversation.view' surface as tabs), the chat store state
* shared through the declared store, and the selection primitives every
* domain consumes. Shared face between the skeleton domain (tab strip +
* view outlet) and the chat domain; domain implementation files import this,
* never each other. The view ring itself IS the 'conversation.view' slot
* (contract in slots.ts) — the package-local view registry is retired, and
* so is the hand-threaded translate channel (framework-level per-slot i18n
* injection is the planned replacement).
*/
/** Shared conversation view, selection, and store-state contracts. */
/** Tool call identity as carried on the wire (branded upstream in connection). */
export type CallId = string
@@ -23,11 +13,8 @@ export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: C
export interface ViewTab { id: string; label: string }
/**
* Chat store state (slot terminal design §4): the per-session store shared by
* the conversation, chat-view, and details registrations. `createChatStore`
* implements this shape. `view` may carry a stale persisted id after a view
* plugin unloads — the slot ledger is the runtime validator (unknown ids fall
* back to the first registered view).
* Per-session state shared by conversation, chat-view, and details slots.
* Unknown persisted view ids fall back to the first registered view.
*/
export interface ChatStoreState {
/** Details-linkage channel (conversation writes, details reads). */

View File

@@ -1,12 +1,7 @@
/**
* Conversation domain plugin, browser half: skeleton (header/tabs/composer),
* the 'conversation.view' slot ring (chat entry here; other plugins
* contribute view tabs through ctx.slots), the chat view's keyed
* 'conversation.chat.toolview' row hole, scope-addressed ConversationService,
* minimal details panel. Contract: api-contracts v3 section 7. Thin shell:
* type surfaces live in contract/, assembly in apply.ts; the implementation
* domains (skeleton/chat) never import each other — contract/ is their only
* shared face.
* Browser conversation plugin. `contract/` is the shared type boundary
* between the independently implemented skeleton and chat domains; `apply.ts`
* owns their slot assembly.
*/
import type { ConversationService } from './service.ts'
@@ -18,10 +13,10 @@ export type {
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, ComposerChainProps, ComposerControlOwnerProps,
ComposerControlProps, ConversationInjected,
ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
ComposerChainProps, ConversationInjected,
ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

View File

@@ -0,0 +1,265 @@
/**
* 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 {
/** 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
}
/** One queued-message row projected from the session/queued frames (T9 supplies the store). */
export interface QueuedMessage {
/** Stable row key: the enqueueing prompt's rpcId. */
readonly key: string
readonly preview: string
}
/** Guard union of the scoped consume-token event, checked by the machine. */
export type ConsumeTokenGuard = ConsumeTokenRequest['guard']
/** Half-open [start, end) range/selection in draft character coordinates. */
export interface EditSelection {
readonly start: number
readonly end: number
}
/**
* One edit applied to the previous draft: [start, end) in the PREVIOUS
* draft's coordinates was replaced by insertedLength characters. Supplied by
* the wiring layer when the DOM event exposes the edit shape; absent, the
* machine recovers it with a prefix/suffix common-scan diff.
*/
export interface EditRange extends EditSelection {
readonly insertedLength: number
}
/**
* One reference chip occurrence, backing exactly one U+FFFC placeholder in
* the draft (design §9.1 底层表示). Identity is occurrenceId — same-named
* references stay independently addressable. label/clipboardText are the
* owner's insert-time projections, cached so the chip survives owner loss
* (invalid flips instead of dropping the occurrence).
*/
export interface Occurrence {
/** Machine-minted stable identity (monotonic per machine). */
readonly occurrenceId: number
/** Owning source name (serializer routing key). */
readonly source: string
/** Owner-scoped reference id. */
readonly ref: string
/** Placeholder offset in the draft; the occurrence occupies exactly [offset, offset+1). */
readonly offset: number
/** Chip display label (insert-time cache). */
readonly label: string
/** Clipboard / persistence projection, e.g. `/name` (insert-time cache, never the model form). */
readonly clipboardText: string
/** Owner-resolution failure flag: chip renders invalid; serialization must fail. */
readonly invalid?: boolean
}
/** One sync-matched paste component; start/end are relative to the pasted text. */
export interface PasteComponent extends EditSelection {
readonly reference: ReferenceInsert
}
/**
* Live paste-match attempt published while async matching may still upgrade
* pasted tokens (design §9.1 剪贴板 round-trip). Any non-paste transaction,
* submit start, invalidate-paste, or release ends it; a paste-upgrade keeps
* it current (later tokens re-CAS against the advanced draftRev).
*/
export interface PasteAttemptState {
/** Machine-minted attempt identity (paste-upgrade must match it). */
readonly attemptId: number
/** Pasted range in the draft as of the paste transaction. */
readonly insertedRange: EditSelection
/** Caller-supplied projection generation echoed back (the controller drops cross-generation results). */
readonly generation: number
}
/**
* InputMachine construction knobs. The machine never reads an ambient clock:
* `now` is the only time source, injected by the shell (tests inject a
* fake). The default clock is constant, i.e. consecutive single-char typing
* always coalesces until a non-typing transaction intervenes.
*/
export interface InputMachineOptions {
/** Single-char typing undo-merge window in ms (default 1000). */
readonly mergeWindowMs?: number
/** Monotonic clock for typing-merge decisions (default: constant 0). */
readonly now?: () => number
}
/** Published input state (the currency; per-session). */
export interface InputState {
readonly draft: string
/** Monotonic draft revision (span CAS compares against this). */
readonly draftRev: number
readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting'
/** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */
readonly claim?: { readonly token: string; readonly hint?: string }
/** Chip occurrence table, sorted by offset (one U+FFFC per entry). */
readonly occurrences: readonly Occurrence[]
/** Live paste-match attempt (absent when no paste is matchable). */
readonly paste?: PasteAttemptState
/** Read-only queue projection (session/queued frames + connect snapshot). */
readonly queue: readonly QueuedMessage[]
}
/**
* One in-flight submission attempt: the ONLY id concept in the submit plane.
* Created on enter; carried by adjudicated/submit-settled events; stale
* attempts are dropped (anti-backwash). release/session teardown aborts the
* current attempt, keeping the promise bounded.
*/
export interface SubmitAttempt {
readonly seq: number
readonly signal: AbortSignal
/** Draft at enter time; rollback restores it only while the live draft still equals it. */
readonly draftSnapshot: string
}
/**
* InputMachine input events (the machine's single write path). Every draft
* mutation is one transaction: draft edit, occurrence reconciliation, and
* undo-log push are atomic inside dispatch(). Events carrying `at` stamp the
* injected clock reading; only single-char typing coalescing reads it.
*/
export type InputEvent =
/** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */
| { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange }
/** Insert '\n' replacing the selection (F1: the execCommand newline path moved into the machine). */
| { readonly type: 'newline'; readonly selection: EditSelection }
| { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan }
/** Place one U+FFFC at the span and mint the occurrence (scoped insert-reference event payload). */
| { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan }
/** Delete a settled command token; success is observable as a draftRev advance. */
| { readonly type: 'consume-token'; readonly guard: ConsumeTokenGuard }
/** Owner-resolution result: exactly the listed occurrences are invalid (style bit; not a transaction). */
| { readonly type: 'set-invalid'; readonly invalidIds: readonly number[] }
| { readonly type: 'undo' }
| { readonly type: 'redo' }
/**
* Paste text replacing the selection, one transaction. Hot-snapshot sync
* matches ride in as components (chips minted inside the SAME transaction:
* one undo returns to pre-paste); a PasteMatchAttempt opens for the async
* remainder. Component ranges must be disjoint and inside the pasted text.
*/
| { readonly type: 'paste-begin'; readonly text: string; readonly selection: EditSelection; readonly components?: readonly PasteComponent[]; readonly generation?: number }
/** Async match landed: upgrade one pasted token to a chip as an INDEPENDENT transaction (undo #1 → text, undo #2 → pre-paste). */
| { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert }
/** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */
| { readonly type: 'invalidate-paste' }
| { readonly type: 'enter'; readonly mode: 'queue' | 'steer' }
| { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
| { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string }
/**
* An ordinary (default-sink) send was accepted: clear the draft as a COMMIT —
* undo must not resurrect sent content (mirrors submit-settled's success arm).
*/
| { readonly type: 'send-committed' }
| { readonly type: 'release' }
/**
* InputMachine output effects (executed by the SessionInput shell; the
* machine stays pure). Draft/occurrence mutations carry no effect — the
* shell publishes the state store after every dispatch.
*/
export type InputEffect =
| { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string }
| { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string }
| { readonly type: 'default-sink'; readonly draft: string; readonly mode: 'queue' | 'steer' }
| { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }

View File

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

View File

@@ -0,0 +1,449 @@
/**
* 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 lexicon source for the decoration scan
* (decision 21): delegates to the controller's aggregated store. Stable
* identity per shell; without a pipeline the snapshot is the empty Map and
* subscribers never fire.
*/
readonly lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>> = {
getSnapshot: () => this.deps.slash?.()?.lexicon.getSnapshot() ?? EMPTY_LEXICON,
subscribe: fn => this.deps.slash?.()?.lexicon.subscribe(fn) ?? (() => {}),
}
/**
* Apply one command claim (scoped begin-command event listener body).
* @param claim - the command claim from the pick path.
* @param span - pick-time span snapshot.
* @returns whether the machine accepted (phase + span CAS passed and the draft mutated).
*/
beginCommand(claim: CommandClaim, span: TokenSpan): boolean {
const before = this.core.state.draftRev
this.run(this.core.dispatch({ type: 'begin-command', claim, span }))
return this.core.state.phase === 'claimed' && this.core.state.draftRev !== before
}
/**
* Apply one reference insertion (scoped insert-reference event listener body).
* @param ref - the reference insertion from the pick path.
* @param span - pick-time span snapshot.
* @returns whether the machine accepted.
*/
insertReference(ref: ReferenceInsert, span: TokenSpan): boolean {
const before = this.core.state.draftRev
this.run(this.core.dispatch({ type: 'insert-ref', reference: ref, span }))
return this.core.state.draftRev !== before
}
/**
* Consume one command token after business success (scoped consume-token
* event listener body). Span guard: revision CAS then splice; bare-token
* guard: trimmed-draft equality then clear.
* @param guard - exact span or bare-token guard.
* @returns whether the token was consumed.
*/
consumeToken(guard: ConsumeTokenRequest['guard']): boolean {
const snapshot = this.core.state
if (guard.kind === 'span') {
if (guard.span.draftRev !== snapshot.draftRev) return false
const draft = snapshot.draft
this.setDraft(draft.slice(0, guard.span.start) + draft.slice(guard.span.end))
return true
}
if (snapshot.draft.trim() !== guard.token) return false
this.setDraft('')
return true
}
/**
* Insert plain reference text over the pick-time span (scoped insert-text
* event listener body, decision 21). Same CAS-then-splice shape as the
* consume-token span branch: the machine sees an ordinary draft-changed
* transaction (one undo step), no occurrence is minted — the chip look is
* a scan-derived decoration, never state.
* @param text - the plain reference text to splice in (e.g. `/name `).
* @param span - pick-time span snapshot (draftRev CAS).
* @returns whether the text was applied.
*/
insertText(text: string, span: TokenSpan): boolean {
const snapshot = this.core.state
if (span.draftRev !== snapshot.draftRev) return false
const draft = snapshot.draft
this.setDraft(draft.slice(0, span.start) + text + draft.slice(span.end))
return true
}
/**
* Surface a notice from outside the machine (detached command results).
* @param level - severity tier.
* @param text - notice body.
*/
notify(level: 'info' | 'error', text: string): void {
this.noticeSeq += 1
this.notices.set({ level, text, seq: this.noticeSeq })
}
// ---- wiring-layer extras (not on the frozen SessionInput face) ----
/** Teardown: abort any in-flight attempt and stop accepting async settlements. */
dispose(): void {
this.disposed = true
this.run(this.core.dispatch({ type: 'release' }))
}
/** Read the live machine state (guard derivation reads here). */
get snapshot(): InputState {
return this.state.getSnapshot()
}
/**
* Bind the draft persistence mirror (chat store write). Adopt-on-bind: the
* store draft may hold a persisted value from a previous mount; the caller
* seeds it via setDraft BEFORE binding, and afterwards every machine-adopted
* draft mirrors out.
* @param write - store draft write.
* @returns the unbind disposer.
*/
bindMirror(write: (text: string) => void): () => void {
this.mirrorFn = write
return () => {
if (this.mirrorFn === write) this.mirrorFn = undefined
}
}
// ---- effect executor ----
private run(effects: readonly InputEffect[]): void {
for (const fx of effects) this.execute(fx)
this.publish()
}
private execute(fx: InputEffect): void {
switch (fx.type) {
case 'notice': {
this.noticeSeq += 1
this.notices.set({ level: fx.level, text: fx.text, seq: this.noticeSeq })
return
}
case 'adjudicate': {
this.adjudicate(fx.attempt, fx.draft)
return
}
case 'begin-submit': {
this.beginSubmit(fx.attempt, fx.claim, fx.args)
return
}
case 'default-sink': {
this.sinkSerialized(fx.draft, fx.mode)
return
}
default:
return // machine-internal effects (mirror rides publish)
}
}
/**
* Prompt serialization before the sink (design §3.12): expand each
* placeholder to its owner's model form via the session controller's
* codec routing. Owner missing / serialize failure / disposal blocks the
* send — notice + draft and chips retained, never a silent downgrade to
* the clipboard text. Chip-free drafts skip the async detour.
*/
private sinkSerialized(draft: string, mode: 'queue' | 'steer'): void {
const occurrences = this.core.state.occurrences
if (occurrences.length === 0) {
this.deps.defaultSink(draft.trim(), mode)
return
}
const slash = this.deps.slash?.()
const controller = new AbortController()
void Promise.all(occurrences.map(async (o) => {
if (slash === undefined) throw new Error(`no serializer for reference source "${o.source}"`)
return { offset: o.offset, text: await slash.serializeReference(o.source, o.ref, controller.signal) }
})).then(
(parts) => {
if (this.disposed) return
// Splice model forms over their placeholders (offsets are draft-time;
// parts arrive offset-sorted since the table is).
let out = ''
let cursor = 0
for (const part of parts) {
out += draft.slice(cursor, part.offset) + part.text
cursor = part.offset + 1
}
out += draft.slice(cursor)
this.deps.defaultSink(out.trim(), mode)
},
(error: unknown) => {
controller.abort()
if (this.disposed) return
const message = error instanceof Error ? error.message : String(error)
this.notify('error', message)
},
)
}
/** Enter adjudication: poll the session controller; failure = notice + draft retained (never a silent downgrade). */
private adjudicate(attempt: SubmitAttempt, draft: string): void {
const slash = this.deps.slash?.()
if (slash === undefined) {
// No pipeline mounted: the '/' line is an ordinary message.
this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
return
}
slash.adjudicate(draft.trim(), attempt.signal).then(
(outcome: PickOutcome) => {
if (this.dead(attempt)) return
this.run(this.core.dispatch({ type: 'adjudicated', attempt, outcome }))
},
(error: unknown) => {
if (this.dead(attempt)) return
const message = error instanceof Error ? error.message : String(error)
this.run(this.core.dispatch({ type: 'adjudication-failed', attempt, message }))
},
)
}
/** The submit transaction: claim.submit against the session scope; ok maps from the outcome kind. */
private beginSubmit(attempt: SubmitAttempt, claim: CommandClaim, args: string): void {
Promise.resolve()
.then(() => claim.submit(args, this.deps.actx))
.then(
(outcome) => {
if (this.dead(attempt)) return
this.run(this.core.dispatch({
type: 'submit-settled', attempt, ok: outcome.kind === 'success', outcome,
}))
},
(error: unknown) => {
if (this.dead(attempt)) return
const message = error instanceof Error ? error.message : String(error)
this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message }))
},
)
}
/** Late-settlement guard: superseded attempts and disposed facades drop silently. */
private dead(attempt: SubmitAttempt): boolean {
return this.disposed || attempt.signal.aborted
}
private compose(): InputState {
const core = this.core.state
return { ...core, queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE }
}
private publish(): void {
const next = this.compose()
this.state.set(next)
if (next.draft !== this.lastDraft) {
this.lastDraft = next.draft
this.mirrorFn?.(next.draft)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,11 @@
/**
* ConversationService implementation: scope-addressed send/cancel and the
* empty-state startSession chain. Contract: api-contracts v3 section 7.
* Selection/draft state moved to the declared chat store (slot terminal
* design §4); the view registry moved to the 'conversation.view' slot (slot
* ledger owns registration, ordering, and disposal) — what remains is the
* send/stop orchestration face.
* 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
* read the session tag with scopeOf (same mechanism as the host tool
* registry). Mutable state lives in plain objects reached by one property
* read — field assignment through the tracker's shadow proxy is off-limits,
* as are `#` hard-private fields.
* read the session tag with `scopeOf`. Mutable state must remain reachable
* through one property read; assignment through the tracker proxy and `#`
* private fields bypass that rebinding.
*/
import { Service } from 'cordis'
import type { Context } from 'cordis'
@@ -19,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)
}
/**
@@ -50,37 +52,17 @@ export class ConversationService extends Service {
if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`)
}
/**
* Empty-state first-send chain (root-context method; does not read scope):
* create the session, navigate to it, then send through the new scope.
* The create → open ordering is safe: the manager merges the new summary
* synchronously before create() resolves, so the list store is projected by
* the time open() validates against it (manager notification batching is
* microtask-based; SessionsService projects on the same flush that create
* awaited through the RPC round trip).
* @param opts - project directory, prompt text, and send mode.
*/
async startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void> {
const sessions = this.requireSessions()
const id = await sessions.create(opts.cwd === undefined ? {} : { cwd: opts.cwd })
// The manager notifier flushes per microtask; one await guarantees the
// list-store projection landed before sessions.open validates against it.
await Promise.resolve()
sessions.open(id)
const scoped = sessions.scope(id)
if (scoped === undefined) throw new Error(`conversation.startSession: created session "${id}" resolved no scope`)
// ctx.get, not scoped.conversation: property access walks the fiber
// topology (a scope fiber never injects services), while get reads the
// global store and still binds this service to the scoped ctx.
const scopedConversation = scoped.get('conversation')
if (scopedConversation === undefined) throw new Error('conversation.startSession: conversation service unavailable through the new scope')
await scopedConversation.send(opts.text, opts.mode)
/** Pull one older history page for the scoped Session. */
async loadOlder(): Promise<void> {
await this.scopedSession('loadOlder').loadOlder()
}
/** Resolve the caller scope's Session or throw on root contexts. */
private scopedSession(op: string): Session {
const id = this.scopeId(op)
return this.requireSessions().manager.get(id)
const binding = this.requireSessions().binding(id)
if (binding === undefined) throw new Error(`conversation.${op}: session "${id}" resolved no binding`)
return binding.session
}
/** Read the caller's session scope tag via the sessions service; root contexts fail loud. */

View File

@@ -54,8 +54,8 @@
border: none;
border-radius: 12px;
background: transparent;
font-size: 13px;
line-height: 16px;
font-size: 14px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
@@ -72,13 +72,6 @@
cursor: default;
}
.meta {
margin-left: 4px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
.tabs {
display: flex;
@@ -87,7 +80,7 @@
padding-left: 8px;
}
/* figma .Tab 34:11442: 13/16 wt510 text, gap 8 to the 3px bar (no bottom rounding). */
/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar (no bottom rounding). */
.tab {
position: relative;
padding: 0 0 11px;
@@ -95,7 +88,7 @@
background: transparent;
font-size: 13px;
line-height: 16px;
font-weight: 510;
font-weight: 500;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
@@ -127,3 +120,57 @@
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 {
position: relative; /* .heroGlow positioning context */
align-self: center;
/* figma 75:8208: 12 between hero chrome / workspace row / card. */
gap: 12px;
/* Foot inside the centered box floats the stack a bit above true center. */
padding-bottom: 32px;
width: min(776px, calc(100% - 48px));
z-index: 1;
}
/* Blue backdrop ellipse (figma 313:14109), centered on the input card: the
card's resting center sits ~92px above the stack bottom (32 foot pad +
half of the ~120px two-row card); width tracks the card (glow asset 1051
vs design card 776) so blur scales in userSpace with it. z-index -1 keeps
it behind the in-flow hero content inside this stacking context. */
.heroGlow {
position: absolute;
left: 50%;
bottom: 92px;
z-index: -1;
width: calc(100% * 1051 / 776);
aspect-ratio: 1051 / 468;
transform: translate(-50%, 50%);
pointer-events: none;
}
.heroWorkspaceRow {
display: flex;
align-items: center;
min-width: 0;
padding-left: 8px;
}
.root[data-phase='hero'] {
justify-content: center;
}
/* Settling (session replaying, hero/docked unknown): keep the composer
mounted but invisible so no wrong layout flashes before the phase lands. */
.root[data-phase='settling'] .composerStack {
visibility: hidden;
}

View File

@@ -1,156 +1,136 @@
// 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 view and composer-control child slots
// (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 { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { useEffect, 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 type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
import { DisabledInputBar } from './DisabledInputBar.tsx'
import css from './ConversationRoot.module.css'
/** Full props = 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, useStore, actions, renderSlot, renderSlotChain,
views, send, stop, open,
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 openState = useSession(s => s.openState)
const composerPhase = useSession(s => s.composerPhase)
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)
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const draft = useStore(s => s.draft)
const running = useSession(s => s.running)
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 [submitting, setSubmitting] = useState(false)
const submittingRef = useRef(false)
const aliveRef = useRef(true)
const [pickerOpen, setPickerOpen] = useState(false)
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
const pickerAnchor = useRef<HTMLButtonElement>(null)
useEffect(() => () => {
aliveRef.current = false
}, [])
const sessionWorkspace = sessionId === undefined
? undefined
: workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId))
const pendingWorkspace = workspaces.items.find(
workspace => workspace.workspaceId === pendingWorkspaceId,
)
const error: InputBarError | null = promptError === null
? null
: { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` }
const controls = renderSlot('conversation.composer.controls', {})
const submit = (mode: 'queue' | 'steer'): void => {
if (submittingRef.current) return
submittingRef.current = true
setSubmitting(true)
const settle = (): void => {
submittingRef.current = false
if (aliveRef.current) setSubmitting(false)
// Clear the pending pick once the session lands in it, or when the picked
// workspace disappears from a ready list (deleted from the sidebar).
useEffect(() => {
if (pendingWorkspaceId === undefined) return
if (sessionWorkspace?.workspaceId === pendingWorkspaceId
|| (workspaces.phase === 'ready' && pendingWorkspace === undefined)) {
setPendingWorkspaceId(undefined)
}
void send(draft, mode).then(settle, settle)
}
}, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace])
// While a session is still replaying (loading + blank) the hero/docked
// choice is unknowable — render the composer hidden instead of flashing
// the centered hero and snapping to the docked bar (or vice versa).
const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading'
const hero = sessionId === undefined || (composerPhase === 'blank' && openState === 'open')
const zone: InputZone | undefined =
session === undefined || inputState === undefined ? undefined : { session, input: inputState }
// Flow optimization — worth a close PR review for code/boundary issues.
// The chip is a selector; label resolution walks the flow top-down:
// 1. a just-picked workspace (pending) → its title;
// 2. cold start, no session yet → placeholder ("Choose workspace");
// 3. the blank session's workspace is in the list → its title;
// 4. list still loading → cwd folder name bridges so the title does not
// flash on refresh (empty cwd → placeholder);
// 5. list ready but no owning workspace (deleted from the sidebar) →
// placeholder, never the deleted folder's name via cwd.
const chipTitle = pendingWorkspace?.title
?? (sessionId === undefined
? undefined
: sessionWorkspace?.title
?? (workspaces.phase === 'ready' || cwd === undefined || cwd === ''
? undefined
: workspaceLabel(cwd)))
const heroWorkspaceRow = (
<div className={css.heroWorkspaceRow}>
<WorkspaceChip
buttonRef={pickerAnchor}
label={chipTitle}
menuOpen={pickerOpen}
onClick={() => { setPickerOpen(open => !open) }}
/>
{renderSlot('conversation.hero.workspace', {
open: pickerOpen,
anchorRef: pickerAnchor,
selectedId: pendingWorkspaceId ?? sessionWorkspace?.workspaceId,
onPick: (workspaceId) => {
setPickerOpen(false)
setPendingWorkspaceId(workspaceId)
void selectWorkspace(workspaceId).catch(() => {
setPendingWorkspaceId(current => current === workspaceId ? undefined : current)
})
},
onClose: () => { setPickerOpen(false) },
})}
</div>
)
// The placeholder chip ("Choose workspace") and the inert input travel
// together: a blank session whose workspace vanished (deleted from the
// sidebar) reverts to the same disabled bar as the initial no-session state.
const inputBar = sessionId === undefined || (hero && chipTitle === undefined)
? <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}
submitting={submitting}
disabled={removed}
error={error}
variant="composer"
controls={controls}
onDraftChange={actions.setDraft}
onSend={submit}
onStop={stop}
/>
<div className={clsx(css.composerStack, hero && css.composerHero)}>
{hero && <HeroGlow className={css.heroGlow} />}
{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="会话层级">
{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={settling ? 'settling' : hero ? 'hero' : 'active'}>
{/* Mounted for every real session, hero included: ConversationSession
renders no chrome while blank but owns the draft-persistence mirror
bind — unmounting it in the hero would lose pre-first-send text on
a refresh or scope rebuild. */}
{sessionId !== undefined && renderSlot('conversation.session', {})}
{renderSlotChain(
'conversation.composer',
{ interactions: pending },
{ fallback: composerBar, overlay: true },
)}
</div>
)
}
/** Turn count = user message nodes in the window (display meta; exact host count deferred). */
function countTurns(s: { nodes: readonly { kind: string }[] }): number {
let n = 0
for (const node of s.nodes) if (node.kind === 'user') n += 1
return n
}

View File

@@ -0,0 +1,95 @@
/** 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 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 (deps pinned to inputActions): later store writes come from
// the machine mirror, not this seed effect.
}, [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>}
</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>
</>
)
}

View File

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

View File

@@ -0,0 +1,40 @@
/** Inert no-session input body; the resident Hero shell renders around it. */
import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './InputBar.module.css'
/** Disabled visual twin of the session-bound InputBar. */
export function DisabledInputBar() {
return (
<div className={clsx(css.root, css.hero)}>
<div className={css.card}>
<div className={css.grow}>
<textarea
className={css.input}
value=""
disabled
placeholder="Choose a workspace to start"
rows={2}
readOnly
/>
<div aria-hidden className={css.mirror}>{'\n'}</div>
</div>
<div className={css.row}>
<div className={css.tools}>
<button type="button" className={css.add} aria-label="Add attachment" disabled>
<IconPlusOutline16 size={14} />
</button>
</div>
<div className={css.trailing}>
<button type="button" className={css.primary} aria-label="Send message" disabled>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
</svg>
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,127 @@
// 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'
import {
FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
import css from './HeroShell.module.css'
/**
* Basename label for the workspace chip (the shared derivation);
* separator-only paths echo the raw cwd.
* @param cwd - workspace directory path (non-empty).
* @returns chip label.
*/
export function workspaceLabel(cwd: string): string {
const base = workspaceTitleOf(cwd)
return base !== '' ? base : cwd
}
/**
* 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. Without a
* label the chip renders its placeholder state: closed folder + the
* "Choose workspace" call to action.
* @param props.label - chip label (see {@link workspaceLabel}); omitted → placeholder.
* @param props.menuOpen - menu expansion echo.
* @param props.onClick - menu toggle.
* @returns the chip button element.
*/
export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
buttonRef?: RefObject<HTMLButtonElement>
label?: string | undefined
menuOpen?: boolean
onClick?: () => void
}) {
return (
<button
ref={buttonRef}
type="button"
className={css.workspace}
aria-label="Choose workspace"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={onClick}
>
{label === undefined
? <IconFolderClose16 className={css.folder} size={16} />
: <IconFolderOpen16 className={css.folder} size={16} />}
<span className={css.workspaceLabel}>{label ?? 'Choose workspace'}</span>
<IconChevronDownOutline14 className={css.chevron} size={12} />
</button>
)
}
/**
* The soft blue backdrop ellipse (figma 313:14109). Rendered by the hero
* owner (ConversationRoot), not HeroShell, so it can center on the input
* card; the owner's className supplies all positioning.
* @param props.className - positioning class from the owner.
* @returns the blurred-ellipse svg element.
*/
export function HeroGlow({ className }: { className?: string | undefined }) {
// Stable filter id so multiple hero mounts do not collide in the DOM.
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
return (
<svg className={className} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
<defs>
<filter
id={glowFilterId}
x="0"
y="0"
width="1051"
height="468"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
</filter>
</defs>
<g filter={`url(#${glowFilterId})`}>
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.08" />
</g>
</svg>
)
}
/** 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 chrome (headline only; no glow, no composer, no workspace
* row — the glow is the owner's {@link HeroGlow}).
* @param props - see {@link HeroShellProps}.
* @returns the centered hero element tree.
*/
export function HeroShell({ children }: HeroShellProps) {
return (
<div className={css.root}>
<div className={css.stack}>
<div className={css.headline}>
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
<FishLogo size={34} className={css.fish} />
Let&apos;s start building
</div>
<div className={css.body}>
{/* 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}
</div>
)
}

View File

@@ -1,68 +0,0 @@
/* NEW SESSION hero: headline over the shared InputBar card, centered in the
conversation column. The card is the same component as the composer —
only positioning lives here. */
.root {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-width: 0;
padding: 24px;
}
/* figma hero group 34:10409: headline block sits 36px above the input card. */
.card {
display: flex;
flex-direction: column;
gap: 36px;
width: 100%;
max-width: 776px;
}
/* figma 34:10411: fish + title row, gap 10, centered; title 26/32 wt600 (34:10414). */
.headline {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 26px;
line-height: 32px;
font-weight: 600;
color: var(--dsw-alias-label-primary);
}
/* figma 34:10412/10413: brand-blue vector. */
.fish {
flex: none;
color: var(--dsw-alias-state-business-primary);
}
.picker {
display: flex;
align-items: center;
min-width: 0;
}
.select,
.customInput {
max-width: 320px;
padding: 4px 10px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 12px;
background: var(--dsw-alias-bg-base);
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
}
.customInput {
width: 320px;
outline: none;
}
.customInput:focus {
/* Business blue, not brand-primary: that token resolves to ink in this sheet. */
border-color: var(--dsw-alias-state-business-primary);
color: var(--dsw-alias-label-primary);
}

View File

@@ -1,120 +0,0 @@
// EmptyState (figma NEW SESSION screen): centered hero card built around the
// SAME InputBar component the resident composer uses (the empty→content
// transition is one component changing position, never a swap). Project
// picker: cwd set derived in-component from the standard useSessions hook
// (subscription is the framework's, derivation is a pure function — design
// §6) plus a free-form new-directory input; submit runs the startSession
// chain (create → open → send) in one service call.
import { useMemo, useState } from 'react'
import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { EmptyStateSlotProps } from '../contract/slots.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import css from './EmptyState.module.css'
/** Select sentinel for the free-form directory entry (impossible as a real path: not absolute). */
const NEW_DIR = '::new-directory'
/** Full props composed by reference from the contract (runtime share & injected share; no store). */
export type EmptyStateProps = EmptyStateSlotProps
/** Deduped cwd set in list order (pure derivation over the sessions list). */
function deriveCwds(state: SessionListState): readonly string[] {
const seen = new Set<string>()
for (const id of state.ids) {
const cwd = state.byId[id]?.cwd
if (cwd !== undefined && cwd !== '') seen.add(cwd)
}
return [...seen]
}
export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
const list = useSessions(s => s)
const cwds = useMemo(() => deriveCwds(list), [list])
// Local viewing state: the empty state owns no session, so its draft is
// ephemeral by design (drafts are keyed by session id; there is none yet).
const [draft, setDraft] = useState('')
const [cwd, setCwd] = useState<string>('')
const [custom, setCustom] = useState(false)
const [sending, setSending] = useState(false)
const [error, setError] = useState<InputBarError | null>(null)
const submit = (mode: 'queue' | 'steer'): void => {
const text = draft.trim()
/* v8 ignore next -- defensive: InputBar disables send while empty. */
if (text === '' || sending) return
setSending(true)
setError(null)
const chosen = cwd.trim()
startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
.catch((reason: unknown) => {
// The empty state survives failure with the draft intact (no session
// exists to carry promptError; this is the only local error surface).
setError({ op: 'send', message: reason instanceof Error ? reason.message : String(reason) })
setSending(false)
})
// Success needs no cleanup: the session selection swaps this slot out for the session body.
}
const picker = (
<div className={css.picker}>
{custom
? (
<input
className={css.customInput}
value={cwd}
autoFocus
placeholder="目录路径,如 /home/me/proj"
onChange={(e) => { setCwd(e.target.value) }}
/>
)
: (
<select
className={css.select}
value={cwd}
aria-label="项目目录"
onChange={(e) => {
if (e.target.value === NEW_DIR) {
setCustom(true)
setCwd('')
} else {
setCwd(e.target.value)
}
}}
>
<option value="">默认目录</option>
{cwds.map(c => <option key={c} value={c}>{c}</option>)}
<option value={NEW_DIR}>新目录…</option>
</select>
)}
</div>
)
return (
<div className={css.root}>
<div className={css.card}>
<div className={css.headline}>
{/* figma 34:10412: fish 34x25 leading the headline, gap 10. */}
<FishLogo size={34} className={css.fish} />
Let&apos;s start building
</div>
<InputBar
draft={draft}
running={false}
submitting={sending}
disabled={false}
error={error}
variant="hero"
placeholder="Message to run task, plan and build"
accessory={picker}
onDraftChange={setDraft}
onSend={submit}
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
onStop={() => {}}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,152 @@
/* NEW SESSION hero (figma Input_Bottom 75:8208): fish + title, workspace chip
above the shared InputBar card. The input itself is InputBar — only stack
geometry and the chip live here. */
.root {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-width: 0;
padding: 0 24px;
}
/* Cap matches InputBar card width (800). Glow may paint past the sides. */
.stack {
display: flex;
flex-direction: column;
align-items: stretch;
/* figma 75:8208: 12 between title block / workspace / card. */
gap: 12px;
width: 100%;
max-width: 800px;
overflow: visible;
}
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. */
.headline {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 26px;
line-height: 32px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
/* figma fish fill rides business blue. */
.fish {
flex: none;
color: var(--dsw-alias-state-business-primary);
}
/* Workspace row sits 12px above the input card (figma y80 → y112). The blue
glow lives with the owner (ConversationRoot .heroGlow) so it can center on
the input card. */
.body {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
overflow: visible;
}
.body > * {
position: relative;
z-index: 1;
}
/* Must beat `.body > :not(.glow)` specificity so the open Menu (and its
right-hand submenu) paints above the InputBar card. */
.body > .workspaceRow {
z-index: 10;
display: flex;
align-items: center;
min-width: 0;
/* figma 75:8208 workspace row: px 8 above the card. */
padding-left: 8px;
}
/* Folder + label + chevron — transparent at rest; fill only on hover / open. */
.workspace {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: min(100%, 360px);
min-height: 28px;
padding: 0 8px;
border: none;
border-radius: 12px;
background: transparent;
color: var(--dsw-alias-label-primary);
font-size: 13px;
line-height: 20px;
font-weight: 500;
cursor: pointer;
}
.workspace:not(:disabled):hover,
.workspace[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Locked form (bound guidance state): a static echo — no hover feedback, no
pointer affordance; label keeps full contrast. */
.workspace:disabled {
cursor: default;
}
.folder {
flex: none;
color: var(--dsw-alias-label-primary);
}
.workspaceLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
flex: none;
color: var(--dsw-alias-label-caption);
}
/* Dialog field: 44 tall on the modal's 332 content column, r22, hairline
border, pad 14/7, 14/22 wt400 primary text, caption placeholder. Focus
keeps the resting border (design shows no focus ring). */
.modalInput {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 7px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 22px;
outline: none;
background: transparent;
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.modalInput::placeholder {
color: var(--dsw-alias-label-caption);
}
.modalInput:disabled {
color: var(--dsw-alias-label-dimmed);
}
.modalAction {
min-width: 72px;
}
.modalError {
margin-top: 8px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-error-primary);
}

View File

@@ -1,7 +1,17 @@
/* Floating capsule input (figma Input_Bottom 34:11445): card floats above the
/* 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
column (776 is a cap, not a fixed size — layout rule: the box shrinks with
column (800 is a cap, not a fixed size — layout rule: the box shrinks with
the center column keeping its padding). Hero variant = the same card
centered in the empty state; the transition between the two is a position
move of one component. */
@@ -10,34 +20,64 @@
display: flex;
flex-direction: column;
align-items: center;
/* figma Input_Bottom 34:11445: pad L32/R32/B12; the bottom gradient mask is
owned by the chat scroller. Top 8 hosts the error strip's breathing room. */
padding: 8px 32px 12px;
/* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
the chat scroller. Top 6 is the gap under the dock todo strip (12px todo
margin + 6px here); error/status strips still carry their own margin. */
padding: 6px 32px 12px;
}
.hero {
padding: 0;
}
.error {
.error,
.status {
width: 100%;
max-width: 776px;
max-width: 800px;
margin-bottom: 6px;
padding: 4px 8px;
border-radius: 8px;
background: var(--dsw-alias-interactive-bg-hover-danger);
color: var(--dsw-alias-state-error-primary);
font-size: 12px;
line-height: 18px;
}
.status {
background: var(--dsw-alias-interactive-bg-hover);
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 34:11458: 12px between the text area and the button row. */
/* figma Input 75:8208: 12px between the text area and the button row; 10px
top pad on the card before .InputText. */
gap: 12px;
width: 100%;
max-width: 776px;
max-width: 800px;
padding-top: 10px;
/* Input stroke: black/0.10 light, white/0.06 dark (figma darkmode note says
the input border is one notch weaker than buttons) — exactly the
l2-darkmode-thin pair. Fill: the input surface token (elevated in dark). */
@@ -47,11 +87,13 @@
box-shadow: var(--dsw-shadow-lv2);
font-size: 16px;
line-height: 24px;
}
/* New-session state rounds up (figma: r24 and a taller box). */
.hero .card {
border-radius: 24px;
/* Elevated surface in dark, same as the menus: the textarea inside scrolls
once the composer hits its height cap, so the thumb takes the l2 pair.
Declared on the card because the elevation belongs to the surface, and the
custom properties inherit down to the textarea that actually scrolls (see
ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.accessory {
@@ -61,6 +103,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. */
@@ -68,6 +118,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;
@@ -84,8 +176,19 @@
}
.input,
.mirror {
padding: 12px 16px 0;
.mirror,
.backdrop {
/* Textareas default to content-box (unlike buttons/inputs): without this the
width:100% textarea gains its padding OUTSIDE the card and text runs past
the right padding — and wraps 28px later than the mirror/backdrop layers. */
box-sizing: border-box;
/* 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;
@@ -108,30 +211,98 @@
.mirror {
visibility: hidden;
pointer-events: none;
/* 2-line floor: 2 × 24px line + 12px top padding; 14-line cap (336px). */
min-height: 60px;
/* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */
min-height: 52px;
max-height: 336px;
overflow: hidden;
}
.hero .mirror {
/* New-session box is taller at rest (figma 118px input area). */
min-height: 84px;
}
/* figma Frame 1123 (34:11463): session controls sit bottom-left and the
primary action bottom-right. */
/* Toolbar: attach + Plan + Read-only on the left; model + send on the right
(figma Input_Bottom chrome). */
.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px 10px 16px;
gap: 12px;
padding: 0 10px 10px 10px;
min-width: 0;
}
.controls {
.tools,
.modes,
.trailing {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
/* figma 75:8208: 16 between + and the mode chips; 4 between Plan / Read-only. */
.tools {
gap: 16px;
}
.modes {
gap: 4px;
}
.trailing {
flex: none;
gap: 12px;
}
/* Attach circle (figma + control): 28px, selector fill, primary glyph. */
.add {
display: grid;
place-items: center;
flex: none;
width: 28px;
height: 28px;
border: none;
border-radius: 999px;
background: var(--dsw-specific-selector);
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
.add:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-solid);
}
.add:disabled {
opacity: 0.5;
cursor: default;
}
/* Plan / Read-only / model — native <select>, chip-like closed chrome
(figma ToggleButton: 13/20 medium secondary, 12px chevron). */
.select {
max-width: 220px;
height: 28px;
padding: 0 20px 0 8px;
border: none;
border-radius: 8px;
outline: none;
background-color: transparent;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 4px center;
background-size: 12px 12px;
color: var(--dsw-alias-label-secondary);
font-size: 13px;
line-height: 20px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
appearance: none;
}
.select:hover:not(:disabled) {
background-color: var(--dsw-alias-interactive-bg-hover);
}
.select:disabled {
opacity: 0.5;
cursor: default;
}
/* Primary send (figma IconButton 34:10465): 34px circle, #3964FE light /
@@ -140,16 +311,20 @@
.primary {
display: grid;
place-items: center;
flex: none;
width: 34px;
height: 34px;
border: none;
border-radius: 999px;
background: var(--dsw-alias-button-info-fill);
color: var(--dsw-alias-label-primary-foreground);
/* Static white, not the foreground token: the arrow stays white on the blue
fill in both themes (design 34:10465). */
color: #fff;
cursor: pointer;
transition: background-color 100ms ease;
}
.primary:hover {
.primary:hover:not(:disabled) {
background: var(--dsw-alias-button-info-hover);
}
@@ -158,10 +333,79 @@
cursor: default;
}
/* Stop state: same slot, dimmed brand fill — the running-state send-key
replacement is a design gap filled by us (figma gives no stop form). */
.stopping,
.stopping:hover {
background: var(--dsw-alias-button-primary-dimmed);
color: var(--dsw-alias-brand-text);
.retry {
margin-left: 8px;
padding: 1px 8px;
border: 1px solid currentColor;
border-radius: 4px;
background: transparent;
color: inherit;
font-size: 12px;
cursor: pointer;
}
/* Plain-text reference highlight (decision 21): a pure range mark over the
draft's own glyphs — advance untouched, so the two layers cannot drift.
Chip family colors; clone keeps rounded ends on soft-wrap fragments. */
.textRef {
color: transparent;
background-color: transparent;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
position: relative;
}
.textRef:after {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border-radius: 6px;
background: rgba(97, 135, 216, 0.22);
transform: translate(-2px, -1px);
padding: 2px 4px;
}
/* Reference chip: rendered in the backdrop at the placeholder offset. Hard
alignment constraint: the chip's advance must equal the textarea's U+FFFC
advance EXACTLY or every glyph after it drifts (caret/selection follow the
textarea character stream). The ::before renders the same U+FFFC through
the same font stack (DshChipCell 4em cell), so both layers agree by
construction — no measured widths. The label overlays the cell, clipped
with an ellipsis; the full name rides the title tooltip. */
.chip {
position: relative;
border-radius: 6px;
background: rgba(97, 135, 216, 0.22);
}
.chip::before {
content: '\FFFC';
color: transparent;
}
.chipLabel {
/* Compensated-scale centering: overflow clipping happens BEFORE transform,
so the box is laid out at 1/0.72 of the cell and scaled back down — the
clip edge then lands on the visual cell edge, not mid-glyph. */
position: absolute;
left: 50%;
top: 50%;
width: calc(100% / 0.72 - 10px);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
color: var(--dsw-alias-label-primary);
white-space: nowrap;
transform: translate(-50%, -50%) scale(0.72);
}
.chipInvalid {
background: rgba(216, 97, 97, 0.2);
text-decoration: line-through;
opacity: 0.7;
}

View File

@@ -1,43 +1,49 @@
// InputBar: the one composer input (figma Input_Bottom). The same component
// serves the empty state (variant='hero': centered launch card) and the
// resident composer (variant='composer') — the empty→content transition is a
// position move of this component, never a swap (layout ruling). Running
// LOCKS the input while Host admission or generation is active. Admission
// settles before model work; running keeps Stop available until the turn ends.
/** 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, whose hooks compartment binds
* useNotices/useLexicon; 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 } from 'react'
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'
import { useEffect, useRef, useState } 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: 'send' | 'stop'
message: string
}
export interface InputBarProps {
draft: string
running: boolean
/** Prompt is waiting for selector settlement or synchronous Host admission. */
submitting: boolean
disabled: boolean
error: InputBarError | null
/** Hero = empty-state centered card; composer = resident bottom bar. */
variant: 'hero' | 'composer'
placeholder?: string
/** Optional leading accessory row content (the empty state mounts its cwd picker here). */
accessory?: ReactNode
/** Optional bottom-row controls, left of the primary button. */
controls?: ReactNode
onDraftChange: (text: string) => void
onSend: (mode: 'queue' | 'steer') => void
onStop: () => void
}
export type InputBarProps = ComposerBarProps
const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
{ id: 'readonly', label: 'Read-only' },
{ id: 'readwrite', label: 'Read-write' },
]
export function InputBar({
draft, running, submitting, disabled, error, variant, placeholder, accessory, controls, onDraftChange, onSend, onStop,
useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon,
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
}: InputBarProps) {
const input = useInput(s => s)
const notice = useNotices(s => s)
const lexicon = useLexicon(s => s)
const promptError = useSession(s => s.promptError)
const running = useSession(s => s.running)
const disabled = useSession(s => s.removed)
// 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;
@@ -52,28 +58,153 @@ export function InputBar({
}, 10)
}
// Locked while running: the browser drops keystrokes AND focus on a disabled
// textarea — no sending mid-turn, stop or wait.
const locked = disabled || running || submitting
// 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')
// Unlock (mount / session switch / turn end) returns focus to the box.
// 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) 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
// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.
// eslint-disable-next-line @typescript-eslint/no-deprecated
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
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)
// selectionStart is number|null in lib.dom; the eslint program narrows it.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
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.
// selectionStart/End are number|null in lib.dom; the eslint program narrows them.
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
const selectionOf = (el: HTMLTextAreaElement) => ({
start: el.selectionStart ?? 0,
end: el.selectionEnd ?? el.selectionStart ?? 0,
})
/* eslint-enable @typescript-eslint/no-unnecessary-condition */
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.
@@ -82,70 +213,183 @@ export function InputBar({
inputRef.current?.focus()
}
const primaryLabel = running ? '停止' : submitting ? '发送中' : '发送'
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 && !submitting) onSend('queue')
if (!empty && !disabled && !machineBusy) inputActions.submit('queue')
}
// Access placeholder select (the one remaining local-chrome control).
const accessSelect: ReactNode = (
<select
className={css.select}
aria-label="Access mode"
value={readonlyId}
disabled={locked}
onChange={(e: ChangeEvent<HTMLSelectElement>) => { setReadonlyId(e.target.value) }}
>
{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, 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)}>
{error !== null && (
<div className={css.error}>
{error.op === 'stop' ? '停止失败' : '发送失败'}:{error.message}
<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
? '会话不可用'
: running
? '回复生成中,可停止后再输入'
: submitting
? '正在发送…'
: '输入消息,Enter 发送,Shift+Enter 换行')}
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}
/>
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
</div>
<div className={css.row}>
<div className={css.controls}>{controls}</div>
<button
type="button"
className={clsx(css.primary, running && css.stopping)}
aria-label={primaryLabel}
title={running ? '停止本轮' : submitting ? '正在等待发送确认' : '发送(Enter)'}
disabled={!running && (empty || disabled || submitting)}
onMouseDown={keepFocus}
onClick={onPrimary}
>
{running ? (
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="14" height="14" 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 className={css.tools}>
<button
type="button"
className={css.add}
aria-label={addLabel}
title={addLabel}
disabled={locked}
onMouseDown={keepFocus}
onClick={onAdd}
>
<IconPlusOutline16 size={14} />
</button>
<div className={css.modes}>
{renderSlot('conversation.input.plan', { locked })}
{accessSelect}
</div>
{leftItems}
</div>
<div className={css.trailing}>
{rightItems}
{renderSlot('conversation.input.model', { locked })}
{machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />}
<button
type="button"
className={css.primary}
aria-label={primaryLabel}
title={primaryLabel}
disabled={!running && (empty || disabled || machineBusy)}
onMouseDown={keepFocus}
onClick={onPrimary}
>
{running ? (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
</svg>
)}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,123 @@
/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419):
tip surface, 14px radius, status icons + secondary item labels. Column is
calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */
.root {
flex: none;
overflow: hidden;
margin: 0 auto;
width: calc(100% - 88px);
max-width: 776px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 14px;
background: var(--dsw-specific-tip);
/* Elevated surface: `--dsw-specific-tip` is the same dark rung as the menu
surface, and `.list` scrolls inside this card, so the thumb takes the l2
elevation tokens. Declared here because the elevation belongs to the
surface, and the custom properties inherit down to `.list` (see ui-theme
styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 16px;
}
.header {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 0;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.progress {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
font-size: 13px;
line-height: 20px;
font-weight: 400;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
display: grid;
flex: none;
place-items: center;
color: var(--dsw-alias-label-tertiary);
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
max-height: 180px;
overflow-y: auto;
}
.item {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
}
.glyph {
display: grid;
flex: none;
place-items: center;
width: 16px;
height: 16px;
}
.glyphCompleted {
color: var(--dsw-alias-state-success-primary);
}
.glyphPending {
color: var(--dsw-alias-label-caption);
}
.glyphProgress {
color: var(--dsw-alias-state-business-primary);
animation: todo-progress-spin 1s linear infinite;
}
@keyframes todo-progress-spin {
to {
transform: rotate(360deg);
}
}
/* Figma strip is single-line; long items ellipsize with no inline expand. */
.content {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,144 @@
// TodoPanel: plan strip above the composer (the web counterpart of the TUI
// plan panel). Renders the standing todo/write whole-list snapshot (cleared on
// the next turn/start) — no data of its own, hidden while the list is empty.
// Mounted through the 'conversation.input.dock' slot (QueueDock posture): the
// dock adapter does the selecting, so the panel takes the plain list and stays
// framework-free. Visual: figma 772:51905 / 772:52972 / 772:53419.
import { useId, useState } from 'react'
import type { Context } from 'cordis'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// The domain's client-namespace pure-type outlet: one import edge delivers
// the `todos` projection-key merge (single source, no consumer-side restated
// declare) and the payload type. Type-only by construction — the outlet is
// free of host value imports, so no host Context merge enters this program.
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './TodoPanel.module.css'
export interface TodoPanelProps {
/** The session's current plan (empty renders nothing) — selected by the dock adapter. */
todos: readonly TodoItem[]
}
/** Local exhaustiveness helper — client packages do not depend on `dsh-llm`. */
/* v8 ignore next 3 -- closed-union backstop; only reached if status is forged */
function assertNever(value: never): never {
throw new Error(`unreachable todo status: ${String(value)}`)
}
/** Status glyphs share the figma 14×14 artboard; the 16×16 `.glyph` cell centers them. */
function CompletedGlyph() {
return (
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphCompleted}>
<circle cx="7" cy="7" r="6.4" stroke="currentColor" strokeWidth="1.2" />
<path
d="M10.9631 5.71411L7.70154 8.97571C7.48011 9.19714 7.27736 9.40099 7.09229 9.54993C6.89742 9.70669 6.66314 9.85279 6.3634 9.90027C6.2049 9.92534 6.04339 9.92534 5.88489 9.90027C5.58515 9.85279 5.35087 9.70669 5.15601 9.54993C4.97093 9.40099 4.76818 9.19714 4.54675 8.97571L3.03516 7.46411L3.96313 6.53613L5.47473 8.04773C5.7169 8.28989 5.86196 8.43389 5.97888 8.52795C6.08597 8.61409 6.10875 8.60701 6.08997 8.604C6.11259 8.60758 6.13571 8.60758 6.15833 8.604C6.13954 8.60701 6.16232 8.61409 6.26941 8.52795C6.38633 8.43389 6.53139 8.28989 6.77356 8.04773L10.0352 4.78613L10.9631 5.71411Z"
fill="currentColor"
/>
</svg>
)
}
/** In-progress: business-blue ring fading out; CSS spins the svg. */
function ProgressGlyph() {
const gradientId = useId()
return (
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphProgress}>
<defs>
<linearGradient id={gradientId} x1="2.5" y1="12" x2="10.5" y2="3.5" gradientUnits="userSpaceOnUse">
<stop stopColor="currentColor" />
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
</linearGradient>
</defs>
<circle cx="7" cy="7" r="6.4" stroke={`url(#${gradientId})`} strokeWidth="1.2" />
</svg>
)
}
/** Pending: dashed unstarted ring (figma dash 2.4 2.4). */
function PendingGlyph() {
return (
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphPending}>
<circle cx="7" cy="7" r="6.4" stroke="currentColor" strokeWidth="1.2" strokeDasharray="2.4 2.4" />
</svg>
)
}
function StatusGlyph({ status }: { status: TodoItem['status'] }) {
switch (status) {
case 'completed': return <CompletedGlyph />
case 'in_progress': return <ProgressGlyph />
case 'pending': return <PendingGlyph />
/* v8 ignore next -- closed TodoItem status union */
default: return assertNever(status)
}
}
/** Header summary: "<done>/<total> tasks · <n> in progress". */
function progressLabel(todos: readonly TodoItem[]): string {
const done = todos.filter(t => t.status === 'completed').length
const active = todos.filter(t => t.status === 'in_progress').length
return `${done}/${todos.length} tasks · ${active} in progress`
}
export function TodoPanel({ todos }: TodoPanelProps) {
const [collapsed, setCollapsed] = useState(false)
if (todos.length === 0) return null
return (
<section className={css.root} data-testid="todo-panel" aria-label="To-dos">
<div className={css.body}>
<button
type="button"
className={css.header}
aria-expanded={!collapsed}
onClick={() => { setCollapsed(v => !v) }}
>
<span className={css.title}>To-dos</span>
<span className={css.progress}>{progressLabel(todos)}</span>
<span className={css.chevron} aria-hidden>
{collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
</span>
</button>
{!collapsed && (
<ul className={css.list}>
{todos.map(item => (
<li key={item.content} className={css.item} data-status={item.status}>
<span className={css.glyph} aria-hidden><StatusGlyph status={item.status} /></span>
<span className={css.content}>{item.content}</span>
</li>
))}
</ul>
)}
</div>
</section>
)
}
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
export type TodoDockProps = PropsRuntime<'conversation.input.dock'>
/** Dock adapter: reads the host-computed 'todos' projection (whole list; absent or null renders nothing). */
export function TodoDock({ useProjection }: TodoDockProps) {
const todos = useProjection('todos')
return <TodoPanel todos={todos ?? []} />
}
/**
* The plan strip as a plain registrant plugin (QueueDock 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 todoDockEntry = {
name: 'conversation-todo-dock',
inject: ['slots', 'conversation'],
/**
* Register the plan strip into the input dock (list entry, above the queue rows).
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
},
}

View File

@@ -1,21 +1,11 @@
/**
* Chat store factory (slot terminal design §4): selection + draft + active
* view for one session, shared by the conversation and details registrations
* (apply constructs one handle and passes it to both). Session-scope
* derivation: both mount slots are scope=session, so the framework creates
* one instance per session; the persist key is scope-suffixed by the
* framework, aligning with the previous per-session draft persistence.
*
* Module exports the factory only — a module-level handle would pin identity
* in the module cache (a de-facto singleton surviving plugin reloads).
* Per-session chat store shared by conversation and details registrations.
* The plugin creates its handle at apply time so identity follows the fiber.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
/**
* Annotation twin of the actions literal below (the export needs a declared
* return type); drift fails assignability at the defineStore call.
*/
/** Declared action shape used to give the exported factory a stable return type. */
type ChatActions = {
select: (draft: ChatStoreState, target: SelectionTarget | null) => void
setDraft: (draft: ChatStoreState, text: string) => void
@@ -25,18 +15,11 @@ type ChatActions = {
}
/**
* Declare the per-session chat store. `selection` is the details-linkage
* channel (conversation writes, details reads); `draft` is the composer text
* (persisted so it survives session switches and reloads); `view` is the
* active conversation view id (a 'conversation.view' entry id — store seat is
* the cross-remount survival channel, null falls back to the first view).
* @returns the store handle (spec + identity + factory in one value).
* Declares the per-session chat state and write surface.
* @returns the store handle.
*/
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
return defineStore({
// Anchored to the contract shape: consumers read the store through
// PropsStore<ChatStore>'s SnapshotSelectorHook<ChatStoreState>, so init
// and the contract cannot drift.
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
persist: 'dsh.conversation.chat',
actions: {

View File

@@ -1,29 +1,51 @@
/* Sample bash rows: deliberately distinct from ToolRow so the differential
registry hit is visible at a glance. */
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
.row {
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
gap: 8px;
height: 24px;
min-width: 0;
cursor: pointer;
border-radius: 6px;
font-family: var(--ds-font-family-code);
font-size: 13px;
}
.row:hover {
background: var(--dsw-alias-interactive-bg-hover);
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-bash-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
.prompt {
@keyframes dsh-bash-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex: none;
color: var(--dsw-alias-state-success-primary);
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.scopeBadge {
flex: none;
margin-right: 8px;
padding: 0 6px;
border-radius: 6px;
font-size: 11px;
@@ -32,17 +54,38 @@
background: var(--dsw-alias-state-business-primary);
}
.command {
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
.err {
flex: none;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -1,35 +1,52 @@
// Bash toolview sample, written in third-party posture: everything below uses
// only the public slot surface (ctx.slots.register into the keyed
// 'conversation.chat.toolview' hole + ToolRowProps) — the acceptance proof
// that a plain plugin can take over a tool row with zero dedicated machinery.
// Session-dimension differentiation happens INSIDE the component (the
// canonical sub-agent scenario): rows in child sessions render the scoped
// variant, derived from the standard useSessions kit — no registry predicates.
// Bash toolview registrant: third-party posture over the keyed toolview hole
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
// Child sessions keep a scoped badge so session-dimension differentiation stays
// observable inside the component (no parallel registry).
import type { Context } from 'cordis'
import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './bash-sample.module.css'
/** Bash row: command-first monospace summary replacing the generic card.
* Sub-session rows (parentId present) swap the prompt for a scoped badge —
* the differential stays observable per session from one registration. */
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
function leadingFor(state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
// Running keeps the icon — the row sweep carries the in-flight signal.
default: return <IconApiOutline14 size={14} />
}
}
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
function stateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
if (isChild) {
return (
<div className={css.row} data-sample="bash-scoped" onClick={openDetails}>
<span className={css.scopeBadge}>scoped</span>
<span className={css.command}>{model.summary}</span>
</div>
)
}
const status = stateStatus(model.state)
return (
<div className={css.row} data-sample="bash-global" onClick={openDetails}>
<span className={css.prompt} aria-hidden>$</span>
<span className={css.command}>{model.summary}</span>
{model.state === 'error' && <span className={css.err}>failed</span>}
<div
className={css.root}
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
data-variant="bash"
data-state={model.state}
>
<span className={css.leading}>{leadingFor(model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
{isChild && <span className={css.scopeBadge}>scoped</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{model.summary}</span>
</div>
)
}

View File

@@ -0,0 +1,56 @@
/* todo_write plan-update row: ToolRow chrome (figma 780:53675) —
[16 checklist] gap6 [title 14/24] gap8 [2x2 dot] gap8 [summary FILL truncate]. */
.row {
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
font-weight: 500; /* figma wt510, rendered 500 */
color: var(--dsw-alias-label-primary-dimmed);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
.err {
flex: none;
margin-left: 8px;
color: var(--dsw-alias-state-error-primary);
font-size: 11px;
line-height: 16px;
}

View File

@@ -0,0 +1,91 @@
// todo_write toolview: plan-flavored summary row replacing the generic
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
// hole like the bash sample (a product registration, not a sample). The row
// summarizes the written list (counts + active item) from the call args; the
// durable list itself renders in the TodoPanel above the composer, so the
// row stays one line. Chrome matches ToolRow (figma 780:53675).
import type { Context } from 'cordis'
import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './todo-row.module.css'
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
interface TodoWriteItem { content?: unknown; status?: unknown }
function isItem(value: unknown): value is TodoWriteItem {
return typeof value === 'object' && value !== null
}
function summarize(argsRaw: string): string | null {
let parsed: unknown
try {
parsed = JSON.parse(argsRaw)
} catch {
// Mid-stream truncation or malformed model JSON: fall back to the generic summary.
return null
}
// Valid JSON with an invalid shape (null root, non-array todos, null items —
// a rejected tool/call retains such args verbatim): same generic fallback.
if (typeof parsed !== 'object' || parsed === null) return null
const todos = (parsed as { todos?: unknown }).todos
if (!Array.isArray(todos) || !todos.every(isItem)) return null
const done = todos.filter(t => t.status === 'completed').length
const active = todos.find(t => t.status === 'in_progress')
const head = `${done}/${todos.length} 已完成`
return typeof active?.content === 'string' && active.content !== ''
? `${head} · ${active.content}`
: head
}
/** Leading-slot state substitution matches ToolRow / bash: icon yields to the
* state semantic while running or failed; ok keeps the checklist glyph. */
function leadingFor(state: ToolRowState) {
switch (state) {
case 'running': return <StateDot state="ongoing" />
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconChecklistOutline16 />
}
}
/** One-line plan update row. Non-ok execution states keep the generic row's
* dot semantics — a cancelled call wrote no todo/write, so it must not read
* as a completed update. */
export function TodoRow({ toolName, block }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
const summary = summarize(argsRaw) ?? model.summary
return (
<div
className={css.row}
data-sample="todo-row"
data-state={model.state}
>
<span className={css.leading} aria-hidden>{leadingFor(model.state)}</span>
<span className={css.title}>更新任务清单</span>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{summary}</span>
{model.state === 'error' && <span className={css.err}>failed</span>}
{model.state === 'stopped' && <span className={css.err}>已中断</span>}
</div>
)
}
/**
* The todo row as a plain registrant plugin, riding the same load-order seam
* as the bash sample: `inject: ['conversation']` guarantees the chat entry
* (and with it the 'conversation.chat.toolview' declaration) is on the ledger.
*/
export const todoToolview = {
name: 'todo-toolview',
inject: ['slots', 'conversation'],
/**
* Register the todo row into the chat view's keyed toolview hole.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow)
},
}

View File

@@ -1,10 +1,4 @@
/**
* Conversation plugin, node half. Pure UI plugin: the empty apply exists so
* the plugin appears in the host cordis.yml / Loader (load and lifecycle
* follow the host; the browser half ships via exports["./client"], discovered
* through the package.json dshClient declaration). Contract: api-contracts
* v3 sections 0.3 and 7.
*/
/** Host loader entry for the browser-only conversation plugin. */
/** Host plugin body — no host-side behavior for the conversation plugin. */
/** Provides no host-side behavior. */
export function apply(): void {}