Merge remote-tracking branch 'origin/master' into worktree/web-session-titles

# Conflicts:
#	.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
#	packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
#	packages/client/ui-conversation/tests/selection-survival.spec.ts
#	packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/client/ui-layout/tests/service.spec.ts
#	packages/client/ui-sidebar/tests/apply.spec.tsx
#	packages/client/ui-sidebar/tests/store.spec.ts
#	packages/client/ui-trajectory/tests/views.spec.tsx
#	packages/client/web/src/app.tsx
#	packages/client/web/tests/boot.spec.tsx
#	packages/host/runtime/README.md
#	packages/host/runtime/tests/host-runtime.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-23 18:39:48 +08:00
285 changed files with 11631 additions and 6072 deletions

View File

@@ -1,8 +1,16 @@
# @deepseek-ai/dsh-client-ui-conversation
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation), ctx.toolviews named registry with bash samples, minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7.
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, stats line, per-tool row slot with a bash sample registrant), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` composed slot props, `views.ts` view ring, `toolview.ts` tool ring, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains.
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
Generic tool rows classify the built-in bash, read, search, write, and edit names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction.
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
Per-session UI state (selection, composer draft, active view) lives in the declared chat store (`stores.ts` `createChatStore`): apply constructs one handle and passes it to the conversation, chat-view, and details registrations, so the session slots share one instance per session (selection written by the chat view, read by details) and the framework owns instance lifecycle and draft persistence. Components are pure — the framework standard kit (`useSession`/`sessionId`/`useSessions`) and the store faces (`useStore`/`actions`) arrive automatically from the registration declaration; the inject factories contribute plain data and callbacks only (send/stop choreography, tab read face, details/paging callbacks, startSession chain).
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
## Model Experience
@@ -19,4 +27,3 @@ None; this package neither assembles nor sends a provider request.
- **Assistant footer extensions (IconActions row, per-message paging) are reserved slots** — drawn in the design, not implemented.
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
- **Approval/question cards are display-only placeholders** — web-side answering (composer takeover panel) is the P-II approvals project.
- **Module-level toolview caches are single-bundle state** — the inject cache and registry maps must reach cross-bundle consumers through the package export surface and loader module table, never by a second bundle copy.

View File

@@ -34,12 +34,10 @@
},
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-client-i18n": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
"clsx": "^2.0.0",
"react": "^18.2.0"
},

View File

@@ -1,56 +1,40 @@
/**
* Client plugin body: provide the conversation service and toolview registry,
* register the conversation/details slot occupants and the no-session empty
* state, and mount the chat view with its samples. Assembly only — components
* receive everything through inject factories; nothing here renders directly.
* 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.
*/
import { createElement, Fragment, type ReactNode } from 'react'
import type { Context } from 'cordis'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import { scopedSlots, shallowEqual } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
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 {
SessionId, SessionListState, SessionsService, SlotsService,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
import type { ConvViewProps, SelectionTarget, ViewEntry, ViewId } from './contract/views.ts'
import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts'
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
} from './contract/slots.ts'
import { createChatStore } from './stores.ts'
import { ConversationService } from './service.ts'
import { ToolViewRegistry } from './toolviews/registry.ts'
import { childSessionScope, registerChat } from './chat/register.ts'
import { registerBashSamples } from './toolviews/bash-sample.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.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', 'i18n']
export const inject = ['slots', 'layout', 'sessions']
/** Resolve a service via ctx.get, failing loud. Property access is reserved
* for contexts whose fiber declares the inject (scope fibers do not). */
// T is the caller-named cast target; inlining `as T` per call site would scatter the budgeted cast.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
function need<T>(ctx: Context, name: string): T {
const value = ctx.get(name) as T | undefined
if (value === undefined) throw new Error(`ui-conversation: ${name} service unavailable`)
return value
}
/** Per-list-state cwd set (deduped, list order) for the empty-state picker. */
const cwdsCache = new WeakMap<SessionListState, readonly string[]>()
function cwdsOf(state: SessionListState): readonly string[] {
let cached = cwdsCache.get(state)
if (cached === undefined) {
const seen = new Set<string>()
for (const id of state.ids) {
const cwd = state.byId[id]?.cwd
if (cwd !== undefined && cwd !== '') seen.add(cwd)
}
cached = [...seen]
cwdsCache.set(state, cached)
}
return cached
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
const scoped = sessions.scope(id)
if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`)
const conversation = scoped.get('conversation')
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable through the session scope')
return conversation
}
/**
@@ -58,128 +42,119 @@ function cwdsOf(state: SessionListState): readonly string[] {
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
const sessions = need<SessionsService>(ctx, 'sessions')
const layout = need<LayoutService>(ctx, 'layout')
const i18n = need<I18nService>(ctx, 'i18n')
const slots = need<SlotsService>(ctx, 'slots')
const sessions = ctx.sessions
const layout = ctx.layout
const slots = ctx.slots
const conversation = new ConversationService(ctx)
const toolviews = new ToolViewRegistry()
ctx.provide('toolviews', toolviews)
// 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.
const chatStore = createChatStore()
const t = i18n.bind('conversation')
// Chat view + StatsLine footer; bash samples assembled here (apply is the
// only cross-domain point — chat consumes the resolver face, samples come
// from the toolviews domain). registerView inside registerChat is already
// effect-scoped; the raw sample registrations need the effect wrapper to
// ride the fiber cascade.
ctx.effect(
() => registerChat({ conversation, toolviews, t }),
'ui-conversation: chat view')
ctx.effect(
() => registerBashSamples(toolviews, childSessionScope(sessions.list)),
'ui-conversation: bash toolview samples')
// ConvViewProps.slots is ScopedSlots<never>: a real outlet with an empty
// whitelist (uncallable by type, correct runtime shape for future grants).
const emptySlots = scopedSlots<never>(slots.core)
/** conversation slot: skeleton surface assembled once per (entry x session). */
const conversationInject = (b: SessionBinding): ConversationInjected => {
const bctx = b.ctx as Context
const scoped = need<ConversationService>(bctx, 'conversation')
const id = b.sessionId as SessionId
const useSession = b.session.useSelector as UseSession
const selectionStore = scoped.selection
const draftsStore = scoped.drafts
const session = sessions.manager.get(id)
// Watch-driven history pull: assembling the surface IS the watch signal
// (once per entry x session; open() is idempotent and self-recovers).
void session.open()
const viewProps: Omit<ConvViewProps, 'slots'> = {
sessionId: id,
useSession,
useSelection: selectionStore.useSelector,
actions: {
openDetails: (target: SelectionTarget) => { scoped.openDetails(target) },
loadOlder: () => { void session.loadOlder() },
},
// 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')) {
/* v8 ignore next -- unreachable: list registration validates id at load. */
if (entry.options.id === undefined) continue
tabs.push({ id: entry.options.id, label: entry.options.label ?? entry.options.id })
}
return tabs
}
const injected: ConversationInjected = {
useAncestry: () => sessions.list.useSelector(
() => sessions.ancestry(id),
(a, b) => shallowEqual(a, b)),
views: {
list: () => conversation.views(),
subscribe: fn => conversation.subscribeViews(fn),
version: () => conversation.viewsVersion(),
},
// layout's viewFor value type is its own looser ViewId; the registry is
// the runtime validator (unknown ids fall back to the first view).
useActiveView: () => layout.current.useSelector(s => s.viewFor[id]) as ViewId | undefined,
composer: {
useDraft: () => draftsStore.useSelector(s => s),
setDraft: (text) => { draftsStore.set(text) },
send: (mode) => {
const text = draftsStore.getSnapshot().trim()
if (text === '') return
// Conversation occupant. Declaring the view ring here is claiming it:
// ConversationRoot is the only component authorized to render the ring.
slots.register({
name: 'conversation',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
store: chatStore,
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ConversationInjected => {
// History pull is NOT triggered here: the runtime sessions service opens
// the event window when the watch lands on the session (cell/binding
// resolution) — an inject factory assembles callbacks, it has no side
// effect on session state.
const scoped = scopedConversation(sessions, sessionId)
return {
views: {
list: viewTabs,
subscribe: fn => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
},
send: (text, mode) => {
const trimmed = text.trim()
if (trimmed === '') return
// Optimistic clear with failure restore (choreography lives with the
// sender; the business failure also lands in snapshot.promptError).
draftsStore.set('')
void scoped.send(text, mode).catch(() => {
if (draftsStore.getSnapshot() === '') draftsStore.set(text)
})
// The store write path stays inside the declared actions set:
// restoreDraft itself no-ops once the user typed something new.
actions.clearDraft()
void scoped.send(trimmed, mode).catch(() => { actions.restoreDraft(trimmed) })
},
stop: () => {
scoped.cancel().catch(() => {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
})
},
},
actions: {
openView: (view: ViewId) => { layout.openView(id, view) },
open: (target: SessionId) => { layout.open(target) },
},
renderView: (entry: ViewEntry): ReactNode => {
const children: ReactNode[] = []
if (entry.chrome?.header !== undefined) {
children.push(createElement(entry.chrome.header, { key: 'header', sessionId: id, useSession }))
}
children.push(createElement(entry.component, { key: 'view', ...viewProps, slots: emptySlots }))
if (entry.chrome?.footer !== undefined) {
children.push(createElement(entry.chrome.footer, { key: 'footer', sessionId: id, useSession }))
}
return createElement(Fragment, null, ...children)
},
}
return injected
}
open: (target: SessionId) => { sessions.open(target) },
}
},
}, ConversationRoot)
/** details slot: minimal selection-driven panel. */
const detailsInject = (b: SessionBinding): DetailsInjected => {
const bctx = b.ctx as Context
const scoped = need<ConversationService>(bctx, 'conversation')
const injected: DetailsInjected = {
useSelection: scoped.selection.useSelector,
actions: { closeDetails: () => { layout.closeDetails() } },
}
return injected
}
// The chat view: first entry of the ring this package just declared.
// Declaring the keyed toolview hole here is claiming it: ChatView is the
// only component authorized to render per-tool rows. Shares the chat
// store, so its selection writes land in the same per-session instance the
// details panel reads.
slots.register({
name: 'conversation.view',
id: 'chat',
order: 0,
label: 'Chat',
children: { 'conversation.chat.toolview': { 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() },
}),
}, ChatView)
/** conversation.empty root slot: the NEW SESSION hero. */
const emptyInject = (): EmptyStateInjected => {
const useCwds: SnapshotSelectorHook<readonly string[]> = (sel, eq) =>
sessions.list.useSelector(s => sel(cwdsOf(s)), eq)
const injected: EmptyStateInjected = {
useCwds,
actions: { startSession: opts => conversation.startSession(opts) },
}
return injected
}
// Class-plugin mount (packages/AGENTS.md service form): the service
// registers itself as `conversation` and lives on its own child fiber.
// Mounted AFTER the chat entry register above — construction guarantee for
// 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)
slots.register('conversation', ConversationRoot, { inject: conversationInject })
slots.register('details', DetailsPanel, { inject: detailsInject })
slots.register('conversation.empty', EmptyState, { inject: emptyInject })
// The bash sample rides that exact seam, in third-party posture.
ctx.plugin(bashToolviewSample)
slots.register({
name: 'details',
store: chatStore,
inject: (): DetailsInjected => ({
closeDetails: () => { layout.closeDetails() },
}),
}, 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

@@ -1,8 +1,9 @@
// AssistantMarkdown: renders assistant blocks in order — markdown text body,
// 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 via the toolview outlet (figma step-summary
// flow). Shared by finalized nodes and the streaming partial (pulse marker).
// 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).
import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -32,6 +33,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
summary={firstLine(text)}
body={text}
state={running ? 'running' : 'ok'}
expandOnRowClick
/>
)
}

View File

@@ -1,53 +1,55 @@
// ChatView: the default conversation view — message flow with user bubbles,
// assistant narration, tool summary rows grouped into step runs, pending
// cards, paging and bottom-follow. Created via factory so plugin deps
// (toolviews registry, i18n) arrive by closure, never by import.
// cards, paging, bottom-follow, and the session stats line under the flow
// (chrome dissolved into the view: the footer is part of what a chat view
// IS, not registration metadata). Pure component registered directly; its
// registration declares the keyed 'conversation.chat.toolview' hole, so tool
// rows render through the props renderSlot share (entryKey = tool name,
// GenericToolCard as the render-site fallback).
//
// Render economics (architecture RFC performance model): the list parent
// subscribes to snapshot segments that do NOT change per streaming chunk
// (nodes/runningCalls/pending keep their references across chunk batches), so
// during a token storm only StreamingTail re-renders; history rows hold via
// memo on cache-stable node slices. Selection changes re-render the parent
// map but only rows whose own selected bit flipped.
// map but only rows whose own selected bit flipped. renderSlot is
// entry-identity-stable (framework binding cache), so passing it through
// memoized rows never churns them.
import {
memo, useLayoutEffect, useMemo, useRef, useState, type FC, type ReactNode,
memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
} from 'react'
import type {
ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode,
ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ConvViewProps, SelectionTarget, Translate } from '../contract/views.ts'
import type { ToolViewProps } from '../contract/toolview.ts'
import type { ToolViewResolver } from '../contract/toolview.ts'
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 { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem } from './MessageItem.tsx'
import { PendingCard } from './PendingCard.tsx'
import { ToolViewOutlet } from './ToolViewOutlet.tsx'
import { StatsLine } from './StatsLine.tsx'
import css from './ChatView.module.css'
/** Plugin-supplied closure deps (assembled in registerChat, apply world). */
export interface ChatViewDeps {
toolviews: ToolViewResolver
t: Translate
}
const FOLLOW_THRESHOLD = 24
type OpenDetails = (target: SelectionTarget) => void
/** web-react's UseSession is deliberately wide (dependency direction); the
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
type RenderToolRow = ChatViewSlotProps['renderSlot']
/** ui-slots' UseSession is deliberately wide (dependency direction); the
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
/** One tool call row (result or running): builds the bound ToolViewProps. */
const CallRow = memo(function CallRow({ registry, sessionId, useSession, t, callId, toolName, block, seq, onOpenDetails, selected }: {
registry: ToolViewResolver
sessionId: SessionId
useSession: ConvViewProps['useSession']
t: Translate
/** 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 }: {
renderSlot: RenderToolRow
callId: string
toolName: string
block: ToolResultNode | RunningToolCall
@@ -56,24 +58,23 @@ const CallRow = memo(function CallRow({ registry, sessionId, useSession, t, call
onOpenDetails: OpenDetails
selected: boolean
}) {
const viewProps = useMemo<ToolViewProps>(() => ({
callId, toolName, block, useSession,
actions: { openDetails: () => onOpenDetails({ turnSeq: seq, callId, toolName }) },
t,
}), [callId, toolName, block, useSession, seq, onOpenDetails, t])
const owner = useMemo(() => ({
callId, toolName, block,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) },
}), [callId, toolName, block, seq, onOpenDetails])
return (
<div className={css.callRow} data-selected={selected || undefined}>
<ToolViewOutlet registry={registry} sessionId={sessionId} toolName={toolName} viewProps={viewProps} />
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
fallback: <GenericToolCard {...owner} />,
})}
</div>
)
})
/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */
const ToolGroup = memo(function ToolGroup({ registry, sessionId, useSession, t, results, onOpenDetails, selectedCallId }: {
registry: ToolViewResolver
sessionId: SessionId
useSession: ConvViewProps['useSession']
t: Translate
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId }: {
renderSlot: RenderToolRow
results: readonly ToolResultNode[]
onOpenDetails: OpenDetails
/** Only set when the selected call lives in THIS group (memo economy). */
@@ -84,10 +85,7 @@ const ToolGroup = memo(function ToolGroup({ registry, sessionId, useSession, t,
{results.map((node) => (
<CallRow
key={node.callId}
registry={registry}
sessionId={sessionId}
useSession={useSession}
t={t}
renderSlot={renderSlot}
callId={node.callId}
toolName={node.call?.name ?? ''}
block={node}
@@ -114,180 +112,166 @@ function StreamingTail({ useSession, onGrow }: {
return <AssistantMarkdown blocks={partial.blocks} streaming />
}
/**
* Build the chat view component over plugin deps.
* @param deps - toolview registry and bound translator.
* @returns the ConvViewProps component registered as the chat view.
*/
export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
const { toolviews, t } = deps
/** 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)
return function ChatView({ sessionId, useSession: useSessionWide, useSelection, actions }: ConvViewProps) {
const useSession = useSessionWide as UseConversation
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 = useSelection((sel) => sel?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
const [atBottom, setAtBottom] = useState(true)
/** Paging anchor: height/position at click, compensated after the prepend lands. */
const anchorRef = useRef<{ h: number; t: number } | null>(null)
const firstSeqRef = useRef<number | null>(null)
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(null)
const listRef = useRef<HTMLDivElement | null>(null)
const atBottomRef = useRef(true)
const [atBottom, setAtBottom] = useState(true)
/** Paging anchor: height/position at click, compensated after the prepend lands. */
const anchorRef = useRef<{ h: number; t: number } | null>(null)
const firstSeqRef = useRef<number | null>(null)
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(null)
const firstSeq = nodes[0]?.seq ?? null
const lastItem = items[items.length - 1]
const firstSeq = nodes[0]?.seq ?? null
const lastItem = items[items.length - 1]
const toBottom = (el: HTMLDivElement): void => {
el.scrollTop = el.scrollHeight
atBottomRef.current = true
setAtBottom(true)
}
useLayoutEffect(() => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
if (el === null) return
// Open completed: jump to the bottom once.
if (openState === 'open' && !openedRef.current) {
openedRef.current = true
toBottom(el)
firstSeqRef.current = firstSeq
lastKeyRef.current = lastItem?.key ?? null
return
}
// Prepend (head seq decreased): compensate by the height delta.
if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) {
el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h)
anchorRef.current = null
firstSeqRef.current = firstSeq
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastItem?.key ?? null
return
}
firstSeqRef.current = firstSeq
// Own words must be visible: a new trailing user node force-scrolls
// (send lives in the composer, so arrival is detected here, not armed there).
const lastKey = lastItem?.key ?? null
const appendedUser = lastKey !== lastKeyRef.current
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
lastKeyRef.current = lastKey
if (appendedUser || atBottomRef.current) toBottom(el)
})
const onScroll = (): void => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */
if (el === null) return
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
}
// Follow streaming growth the parent never re-renders for (stable ref).
// The ref starts null and is assigned every render, so the placeholder
// initializer a function initial value would need never exists.
const followRef = useRef<(() => void) | null>(null)
followRef.current = () => {
const el = listRef.current
if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight
}
const onGrow = useRef(() => followRef.current?.()).current
const loadOlder = (): void => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
actions.loadOlder()
}
const renderItem = (item: ChatFlowItem): ReactNode => {
if (item.kind === 'tool-group') {
const inGroup = selectedCallId !== undefined
&& item.results.some((r) => r.callId === selectedCallId)
return (
<ToolGroup
key={item.key}
registry={toolviews}
sessionId={sessionId}
useSession={useSession}
t={t}
results={item.results}
onOpenDetails={actions.openDetails}
selectedCallId={inGroup ? selectedCallId : undefined}
/>
)
}
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
}
/* 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} />
}
return (
<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={loadOlder}>
{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}
registry={toolviews}
sessionId={sessionId}
useSession={useSession}
t={t}
callId={call.callId}
toolName={call.name}
block={call}
seq={call.turn}
onOpenDetails={actions.openDetails}
selected={call.callId === selectedCallId}
/>
))}
</div>
)}
{pending.map((item) => <PendingCard key={item.rpcId} item={item} />)}
</div>
</div>
{!atBottom && (
<button
type="button"
className={css.toBottom}
aria-label="回到底部"
onClick={() => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
if (el !== null) toBottom(el)
}}
>
<IconChevronDownOutline14 />
</button>
)}
</div>
)
const toBottom = (el: HTMLDivElement): void => {
el.scrollTop = el.scrollHeight
atBottomRef.current = true
setAtBottom(true)
}
useLayoutEffect(() => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
if (el === null) return
// Open completed: jump to the bottom once.
if (openState === 'open' && !openedRef.current) {
openedRef.current = true
toBottom(el)
firstSeqRef.current = firstSeq
lastKeyRef.current = lastItem?.key ?? null
return
}
// Prepend (head seq decreased): compensate by the height delta.
if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) {
el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h)
anchorRef.current = null
firstSeqRef.current = firstSeq
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastItem?.key ?? null
return
}
firstSeqRef.current = firstSeq
// Own words must be visible: a new trailing user node force-scrolls
// (send lives in the composer, so arrival is detected here, not armed there).
const lastKey = lastItem?.key ?? null
const appendedUser = lastKey !== lastKeyRef.current
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
lastKeyRef.current = lastKey
if (appendedUser || atBottomRef.current) toBottom(el)
})
const onScroll = (): void => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */
if (el === null) return
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
}
// Follow streaming growth the parent never re-renders for (stable ref).
// The ref starts null and is assigned every render, so the placeholder
// initializer a function initial value would need never exists.
const followRef = useRef<(() => void) | null>(null)
followRef.current = () => {
const el = listRef.current
if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight
}
const onGrow = useRef(() => followRef.current?.()).current
const loadOlderAnchored = (): void => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
loadOlder()
}
const renderItem = (item: ChatFlowItem): ReactNode => {
if (item.kind === 'tool-group') {
const inGroup = selectedCallId !== undefined
&& item.results.some((r) => r.callId === selectedCallId)
return (
<ToolGroup
key={item.key}
renderSlot={renderSlot}
results={item.results}
onOpenDetails={openDetails}
selectedCallId={inGroup ? selectedCallId : undefined}
/>
)
}
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
}
/* 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} />
}
return (
<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) => <PendingCard key={item.rpcId} item={item} />)}
</div>
</div>
<StatsLine useSession={useSession} />
{!atBottom && (
<button
type="button"
className={css.toBottom}
aria-label="回到底部"
onClick={() => {
const el = listRef.current
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
if (el !== null) toBottom(el)
}}
>
<IconChevronDownOutline14 />
</button>
)}
</div>
)
}

View File

@@ -1,13 +1,15 @@
// GenericToolCard: the registry-miss fallback toolview — classifies the tool
// into one of the five figma row variants and renders the summary row. Also
// the shared base the bash sample builds on: any ToolViewProps consumer.
// GenericToolCard: the default tool row — classifies the tool into one of
// the five figma row variants and renders the summary row. Supplied by the
// chat view as the keyed toolview slot's render-site fallback (an
// unregistered tool name lands here); registrants may also compose it as a
// base, feeding the same owner payload through.
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconSearchOutline16, IconThinkOutline14,
IconApiOutline14, IconBrowseOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolViewProps } from '../contract/toolview.ts'
import { toolRowModel, type ToolCallBlock, type ToolRowVariant } from '../contract/tool-call-model.ts'
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'
@@ -17,11 +19,13 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
search: <IconSearchOutline16 />,
read: <IconBrowseOutline16 />,
bash: <IconApiOutline14 size={16} />,
write: <IconEditOutline16 />,
edit: <IconEditOutline16 />,
others: <IconSparkle16 />,
}
export function GenericToolCard({ toolName, block, actions }: ToolViewProps) {
const model = toolRowModel(toolName, block as ToolCallBlock)
export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) {
const model = toolRowModel(toolName, block)
return (
<ToolRow
variant={model.variant}
@@ -30,7 +34,7 @@ export function GenericToolCard({ toolName, block, actions }: ToolViewProps) {
summary={model.summary}
body={model.body}
state={model.state}
onOpenDetails={actions.openDetails}
onOpenDetails={openDetails}
/>
)
}

View File

@@ -1,14 +1,13 @@
// StatsLine: the session stats row (figma 122:11212 "cache hit 92% · 1,284
// tokens · 45.2s · 5 turns · 32 steps"), mounted as the chat view's
// chrome.footer — the first chrome-attachment consumer. 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).
// 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).
import { memo, useMemo } from 'react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { ChromeProps } from '../contract/views.ts'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import css from './StatsLine.module.css'
interface UsageTotals {
@@ -55,8 +54,11 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
}
}
export const StatsLine = memo(function StatsLine({ useSession }: ChromeProps) {
const nodes = (useSession as SnapshotSelectorHook<ConversationSnapshot>)((s) => s.nodes)
/** Props: the conversation-snapshot selector hook (handed down by ChatView). */
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
const nodes = useSession((s) => s.nodes)
const stats = useMemo(() => deriveStats(nodes), [nodes])
if (stats.steps === 0) return null
const parts: string[] = []

View File

@@ -4,7 +4,7 @@
// 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.
import { useState, type ReactNode } from 'react'
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -20,6 +20,8 @@ export interface ToolRowProps {
/** Expanded-body text; null = not expandable (leading slot never toggles). */
body: string | null
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
}
@@ -35,31 +37,56 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
}
}
export function ToolRow({ variant, icon, title, summary, body, state, onOpenDetails }: ToolRowProps) {
export function ToolRow({
variant,
icon,
title,
summary,
body,
state,
expandOnRowClick = false,
onOpenDetails,
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const expandable = body !== null
const open = expanded && expandable
const rowExpands = expandable && expandOnRowClick
const toggleExpand = () => {
setExpanded((v) => !v)
}
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
toggleExpand()
}
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
toggleExpand()
}
return (
<div className={css.root} data-variant={variant} data-state={state}>
<div
className={css.row}
data-clickable={onOpenDetails !== undefined || undefined}
onClick={onOpenDetails}
data-clickable={rowExpands || onOpenDetails !== undefined || undefined}
role={rowExpands ? 'button' : undefined}
tabIndex={rowExpands ? 0 : undefined}
aria-expanded={rowExpands ? open : undefined}
onClick={rowExpands ? toggleExpand : onOpenDetails}
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
>
{expandable ? (
{expandable && !rowExpands ? (
<button
type="button"
className={css.leading}
aria-expanded={open}
onClick={(e) => {
e.stopPropagation()
setExpanded((v) => !v)
}}
onClick={toggleFromLeading}
>
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
</button>
) : (
<span className={css.leading}>{leadingFor(state, icon)}</span>
<span className={css.leading}>
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
</span>
)}
<span className={css.title}>{title}</span>
{!open && (

View File

@@ -1,89 +0,0 @@
// ToolViewOutlet: resolves the toolview for one call through ctx.toolviews
// (uSES over the registry version so unload falls back live) and renders it
// behind a per-row error boundary. GenericToolCard is the render-side
// fallback for both a registry miss and a crashed custom row. A registrant
// inject factory is called once per (registration x binding) and cached,
// mirroring the scoped-slots injection discipline.
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import { useSessionBinding } from '@deepseek-ai/dsh-client-web-react'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolViewInject, ToolViewProps, ToolViewResolver } from '../contract/toolview.ts'
import { GenericToolCard } from './GenericToolCard.tsx'
export interface ToolViewOutletProps {
registry: ToolViewResolver
sessionId: SessionId
toolName: string
viewProps: ToolViewProps
}
/** Inject cache: per inject-factory (stable per registration) x binding object. */
const injectCache = new WeakMap<ToolViewInject<object>, WeakMap<object, object>>()
function cachedInject(inject: ToolViewInject<object>, binding: SessionBinding): object {
let perBinding = injectCache.get(inject)
if (!perBinding) {
perBinding = new WeakMap()
injectCache.set(inject, perBinding)
}
let props = perBinding.get(binding)
if (!props) {
props = inject(binding)
perBinding.set(binding, props)
}
return props
}
class RowErrorBoundary extends Component<
{ resetKey: unknown; fallback: ReactNode; children: ReactNode }, { failed: boolean }
> {
override state = { failed: false }
// Fallback state MUST flip here (render phase): a boundary whose derived
// state does not change re-renders the crashing children and React gives
// up after the second throw, escalating past the boundary.
static getDerivedStateFromError(): { failed: boolean } {
return { failed: true }
}
override componentDidCatch(error: unknown): void {
console.error('toolview row crashed:', error)
}
// A re-registration (resetKey bump) retries the custom row.
override componentDidUpdate(prev: { resetKey: unknown }): void {
if (this.state.failed && prev.resetKey !== this.props.resetKey) {
this.setState({ failed: false })
}
}
override render(): ReactNode {
if (this.state.failed) return this.props.fallback
return this.props.children
}
}
/** Split component: only inject-carrying registrations need the session
* binding hook (keeps injectless rendering free of the Provider requirement). */
function InjectedRow({ Row, inject, viewProps }: {
Row: FC<ToolViewProps & object>; inject: ToolViewInject<object>; viewProps: ToolViewProps
}) {
const binding = useSessionBinding()
const injected = cachedInject(inject, binding)
return <Row {...{ ...injected, ...viewProps }} />
}
export function ToolViewOutlet({ registry, sessionId, toolName, viewProps }: ToolViewOutletProps) {
const version = useSyncExternalStore(
(fn) => registry.subscribe(fn),
() => registry.getVersion(),
)
const resolved = registry.resolve(toolName, sessionId)
if (resolved === undefined) return <GenericToolCard {...viewProps} />
const Row = resolved.component
return (
<RowErrorBoundary resetKey={version} fallback={<GenericToolCard {...viewProps} />}>
{resolved.inject === undefined
? <Row {...viewProps} />
: <InjectedRow Row={Row} inject={resolved.inject} viewProps={viewProps} />}
</RowErrorBoundary>
)
}

View File

@@ -1,52 +0,0 @@
/**
* Chat-side registration entry, called from the plugin apply (the assembly
* point): registers the chat view with the stats-line footer chrome. The
* chat domain touches the tool ring only through the contract resolver face;
* bash sample registration moved to apply (cross-domain assembly).
*/
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationService } from '../service.ts'
import type { Translate } from '../contract/views.ts'
import type { ToolViewResolver } from '../contract/toolview.ts'
import { createChatView } from './ChatView.tsx'
import { StatsLine } from './StatsLine.tsx'
/** Read face of the sessions list store (subscription not needed: the filter
* reads the latest snapshot at each resolve). */
export interface SessionListReader { getSnapshot(): SessionListState }
/**
* Default scoped-sample filter: the sub-session family. Sub-agent rows
* rendering differently is the registry's canonical product scenario, and
* forking gives W5 acceptance a real entry point to observe the differential.
* @param list - injected sessions list read face.
* @returns filter matching sessions with a parent.
*/
export function childSessionScope(list: SessionListReader): (sessionId: SessionId) => boolean {
return sessionId => list.getSnapshot().byId[sessionId]?.parentId !== undefined
}
/** Assembly inputs for {@link registerChat} (resolved by apply, not here). */
export interface RegisterChatDeps {
conversation: ConversationService
/** Toolview read face consumed by the chat rows' outlet. */
toolviews: ToolViewResolver
/** Translator bound to the conversation namespace. */
t: Translate
}
/**
* Register the chat view (footer chrome included).
* @param deps - assembled service instances.
* @returns disposer removing the registration.
*/
export function registerChat(deps: RegisterChatDeps): () => void {
const { conversation, toolviews, t } = deps
return conversation.registerView({
id: 'chat',
label: 'Chat',
order: 0,
component: createChatView({ toolviews, t }),
chrome: { footer: StatsLine },
})
}

View File

@@ -1,63 +1,149 @@
/**
* Slot-ring contract for the conversation package: the composed props shapes
* its registrants mount into the layout-owned slots (conversation / details /
* conversation.empty — the SlotMap declarations live with ui-layout, the
* slot owner). Per the share-ownership rule, the owner share is REFERENCED
* from ui-layout and each registrant's injected share is declared here, next
* to the component that receives it; full component props = owner share &
* standard share & own injected share.
* 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 { ReactNode } from 'react'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConvOwnerProps, DetailsOwnerProps, EmptyOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SelectionTarget, ViewEntry, ViewId } from './views.ts'
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
/** Injected share of the conversation slot (assembled by apply's inject factory). */
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
* The conversation view ring: one list entry per view tab (chat here;
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
* ConversationRoot via `only: <active id>`. Declared by this package's
* 'conversation' entry (declaring is claiming). Session scope: views read
* the conversation snapshot through the standard kit.
*/
'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
/**
* The chat view's per-tool row hole: keyed dispatch on the wire tool name
* (the key space is runtime-open — SlotMap declares slots, never keys).
* Declared by the chat view entry (declaring is claiming); the render
* site dispatches via `entryKey: toolName` with GenericToolCard as the
* `fallback` for unregistered tools.
*/
'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
}
}
/**
* View-slot owner share: deliberately empty — ConversationRoot supplies
* nothing at its renderSlot site (sessionId and the snapshot hook arrive as
* framework-standard props; tool rows go through each view's own declared
* toolview hole). Kept as the named owner seat so a future cross-view
* payload has a home.
*/
export interface ConvViewOwnerProps {}
/**
* Owner share of a per-view toolview slot: the call material the rendering
* view supplies per row. Uniform across views — the trajectory/waterfall
* toolview slots (same kind/scope/owner, names fixed by the slot-naming
* discipline) land with their own row render sites; today only the chat slot
* is declared (RendersCheck rejects a declaration nobody renders).
*/
export interface ToolRowOwnerProps {
/** Tool call identity (details linkage; stable across running → settled). */
callId: CallId
/** Wire tool name (also the keyed dispatch key at the render site). */
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
}
/**
* Full props of a registered tool-row component: the slot's runtime share
* (owner payload + session standard kit + global seat). Registrants type
* their component `FC<ToolRowProps & I>` with `I` inferred from their inject
* factory. Declared against the chat slot; the three per-view toolview slots
* share one declaration shape, so this alias serves them all.
*/
export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
/**
* Base props of a conversation view entry: the framework standard kit for the
* session-scope 'conversation.view' slot (useSession narrowed to the
* conversation snapshot by the runtime merge, sessionId, useSessions).
* Entries declaring the shared store or an inject face compose their shares
* on top (the chat entry's {@link ChatViewSlotProps}); store-less pure
* readers (ui-trajectory) take this base alone.
*/
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.
*/
export interface ConversationInjected {
/** Breadcrumb chain (root ancestor first, self last; ancestry(list) feed). */
useAncestry: () => readonly SessionSummary[]
/** View registry read face (uSES triple from the conversation service). */
/** View tab read face (uSES triple over the 'conversation.view' slot ledger). */
views: {
list(): readonly ViewEntry[]
list(): readonly ViewTab[]
subscribe(fn: () => void): () => void
version(): number
}
/** Active view accessor (layout.viewFor backed; undefined falls to 'chat'). */
useActiveView: () => ViewId | undefined
/** Composer surface: draft store hook pair + send/stop choreography. */
composer: {
useDraft: () => string
setDraft(text: string): void
send(mode: 'queue' | 'steer'): void
stop(): void
}
actions: {
openView(view: ViewId): void
open(id: SessionId): void
}
/** Renders the active view's body (the owner closes over ConvViewProps assembly). */
renderView: (entry: ViewEntry) => ReactNode
/** Send choreography: trims, clears the draft optimistically, restores it on failure. */
send(text: string, mode: 'queue' | 'steer'): void
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
stop(): void
/** Navigate to another session (breadcrumb ancestors). */
open(id: SessionId): void
}
/** Full conversation-slot component props: owner share & standard share & injected share. */
export type ConversationSlotProps = ConvOwnerProps & { useSession: UseSession } & ConversationInjected
/** Full conversation-slot component props: runtime share & view-slot render share & store share & injected share. */
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view'> & PropsStore<ChatStore> & ConversationInjected
/** Injected share of the details slot. */
/**
* Injected share of the chat view entry: the two callbacks whose targets live
* outside the view (layout orchestration; the session object layer).
*/
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
}
/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */
export type ChatViewSlotProps =
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview'>
& PropsStore<ChatStore> & ChatViewInjected
/**
* Injected share of the details slot: the panel is otherwise a pure reader of
* the shared chat store, but its close button is a layout orchestration call.
*/
export interface DetailsInjected {
useSelection: SnapshotSelectorHook<SelectionTarget | null>
actions: { closeDetails(): void }
/** Close the details panel (layout geometry stays with ctx.layout). */
closeDetails(): void
}
/** Full details-slot component props. */
export type DetailsSlotProps = DetailsOwnerProps & { useSession: UseSession } & DetailsInjected
/** 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 (root slot: no standard share). */
/** Injected share of the no-session empty-state slot. */
export interface EmptyStateInjected {
/** cwd options derived from sessions.list (deduped; assembled by the inject factory). */
useCwds: SnapshotSelectorHook<readonly string[]>
actions: { startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void> }
/** The create → navigate → first-send chain, in one service call. */
startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void>
}
/** Full empty-state component props. */
export type EmptyStateSlotProps = EmptyOwnerProps & EmptyStateInjected
/** 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

@@ -3,25 +3,29 @@
* one-line summary and expanded-body text from the frozen call slice. No
* inline output ever — full results live in the details panel.
*/
import type { ToolCallBlock } from './toolview.ts'
// The block union's defining home is runtime (fold-product types); this
// contract only forwards it (type-definition authority stays with the layer
// that produces the values).
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
export type { ToolCallBlock } from './toolview.ts'
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 five figma row variants (think is fed by reasoning blocks, not tool calls). */
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'others'
/** The seven row variants (think is fed by reasoning blocks, not tool calls). */
export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'others'
/** Row state semantic; colors self-supplied via StateDot (design gives none). */
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', others: 'Tool call',
think: 'Think', search: 'Search', read: 'Read', bash: 'Bash',
write: 'Write', edit: 'Edit', others: 'Tool call',
}
/** Known tool name -> variant; fs write/edit intentionally fall to others (no figma form). */
/** Known tool name -> variant. */
const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
bash: 'bash',
read: 'read',
@@ -29,6 +33,8 @@ const TOOL_VARIANTS: Record<string, ToolRowVariant> = {
web_search: 'search',
grep: 'search',
glob: 'search',
write: 'write',
edit: 'edit',
}
/**
@@ -78,6 +84,8 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
read: ['path', 'file_path', 'url'],
search: ['query', 'pattern', 'url'],
think: [],
write: ['path', 'file_path'],
edit: ['path', 'file_path'],
others: [],
}

View File

@@ -1,77 +0,0 @@
/**
* Tool-ring contract: the props surface handed to toolview components, the
* registry's resolve/registration shapes, and the tool-call block union.
* Shared face between the chat domain (ToolViewOutlet consumes resolve) and
* the toolviews domain (registry implementation + sample rows); domain
* implementation files import this, never each other.
*/
import type { FC } from 'react'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
import type { CallId, Translate } from './views.ts'
// The block union's defining home is runtime (fold-product types); the
// contract only forwards it (type-definition authority stays with the layer
// that produces the values).
export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
/** Props handed to registered toolview components. */
export interface ToolViewProps {
callId: CallId
toolName: string
block: ToolCallBlock
useSession: UseSession
actions: { openDetails(): void }
t: Translate
}
/**
* Toolview inject factory: produces the registrant's private injected share
* `I`, called once per (registration x session binding) and cached by the
* render outlet. Session-bound by nature — tool rows always render inside a
* session subtree.
*/
export type ToolViewInject<I extends object> = (b: SessionBinding) => I
/** Options accepted by the toolview registry's register; `I` is inferred from the inject factory. */
export interface ToolViewOptions<I extends object = object> {
/** Session filter; absent = global registration. */
scope?: (sessionId: SessionId) => boolean
/** Private inject factory merged into the row's props by the render outlet. */
inject?: ToolViewInject<I>
}
/**
* A resolved toolview registration. `I` is erased to `object` on the resolve
* read face (storage erases the per-registration parameter; the outlet merges
* injected props untyped — the register site already proved component ⊇ I).
*/
export interface ResolvedToolView<I extends object = object> {
component: FC<ToolViewProps & I>
inject?: ToolViewInject<I>
}
/** The registry's read face consumed by render outlets (implementation lives in the toolviews domain). */
export interface ToolViewResolver {
/**
* Resolve the renderer for a tool in a session. Order: scope match (later
* registration wins) > global > undefined (caller falls back to the
* generic card).
* @param tool - tool name.
* @param sessionId - session the row renders in.
* @returns resolved view, or undefined when nothing matches.
*/
resolve(tool: string, sessionId: SessionId): ResolvedToolView | undefined
/**
* Subscribe to registration changes (synchronous).
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribe(fn: () => void): () => void
/**
* Monotonic version for uSES pairing.
* @returns current version.
*/
getVersion(): number
}

View File

@@ -1,68 +1,39 @@
/**
* View-ring contract: the typed conversation view table and the props
* surfaces handed to registered views. Shared face between the skeleton
* domain (ConversationRoot renders views) and the chat domain (registers the
* chat view); domain implementation files import this, never each other.
* 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).
*/
import type { FC } from 'react'
import type { ScopedSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
/**
* One ConversationViewMap entry: per-view props extension shapes (design
* ledger, view ring). `chromeProps` extends {@link ChromeProps} for the
* view's chrome attachments; `extraProps` extends {@link ConvViewProps} for
* the view component itself. Both optional — the common bases stay the floor.
*/
export interface ViewEntryDef { chromeProps?: object; extraProps?: object }
/**
* Typed conversation view table; ui-trajectory merges {trajectory, waterfall}.
* The chat entry is declared inline here (self-merge from a sibling module
* trips TS6305 under tsc -b).
*/
export interface ConversationViewMap { chat: ViewEntryDef }
/** View id constrained to registered ConversationViewMap keys (all string literals; chat is declared inline). */
export type ViewId = keyof ConversationViewMap
/** Per-view chrome props: the common base plus the entry's declared extension. */
export type ChromePropsOf<Id extends ViewId> =
ChromeProps & (ConversationViewMap[Id] extends { chromeProps: infer C extends object } ? C : object)
/** Per-view component props: the common base plus the entry's declared extension. */
export type ConvViewPropsOf<Id extends ViewId> =
ConvViewProps & (ConversationViewMap[Id] extends { extraProps: infer E extends object } ? E : object)
/** Tool call identity as carried on the wire (branded upstream in connection). */
export type CallId = string
/** Translate function bound to a namespace via i18n. */
export type Translate = (key: string, params?: Record<string, unknown>) => string
/** One registered conversation view (props positions keyed by the entry's declared shapes). */
export interface ViewEntry<Id extends ViewId = ViewId> {
id: Id
label: string
order?: number
component: FC<ConvViewPropsOf<Id>>
/** Per-view chrome attachments (chat mounts the stats line as footer). */
chrome?: { header?: FC<ChromePropsOf<Id>>; footer?: FC<ChromePropsOf<Id>> }
}
/** Props for view chrome attachments. */
export interface ChromeProps { sessionId: SessionId; useSession: UseSession }
/** Selection target for the details linkage channel (toolcall is the step special case). */
export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: CallId; toolName?: string }
/** Props handed to registered conversation views. */
export interface ConvViewProps {
sessionId: SessionId
useSession: UseSession
useSelection: SnapshotSelectorHook<SelectionTarget | null>
actions: { openDetails(t: SelectionTarget): void; loadOlder(): void }
/** Chat has no delegated sub-slots in P-I (toolviews go through the named registry). */
slots: ScopedSlots<never>
/**
* One conversation view tab, projected from a 'conversation.view' slot
* entry's registration options (label falls back to the entry id).
*/
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).
*/
export interface ChatStoreState {
/** Details-linkage channel (conversation writes, details reads). */
selection: SelectionTarget | null
/** Composer draft (persisted; survives session switches and reloads). */
draft: string
/** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */
view: string | null
}

View File

@@ -1,42 +1,31 @@
/**
* Conversation domain plugin, browser half: skeleton (header/tabs/composer),
* typed view registry, scope-addressed ConversationService, named toolview
* registry, minimal details panel. Contract: api-contracts v3 section 7.
* Thin shell: type surfaces live in contract/, assembly in apply.ts; the
* three implementation domains (skeleton/chat/toolviews) never import each
* other — contract/ is their only shared face.
* 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.
*/
import type { ConversationService } from './service.ts'
import type { ToolViewRegistry } from './toolviews/registry.ts'
export { apply, inject } from './apply.ts'
export { ConversationService } from './service.ts'
export { ToolViewRegistry } from './toolviews/registry.ts'
export type {
CallId, ChromeProps, ChromePropsOf, ConversationViewMap, ConvViewProps, ConvViewPropsOf,
SelectionTarget, Translate, ViewEntry, ViewEntryDef, ViewId,
CallId, ChatStoreState, SelectionTarget, ViewTab,
} from './contract/views.ts'
export type { ToolCallBlock } from './contract/tool-call-model.ts'
export type {
ResolvedToolView, ToolCallBlock, ToolViewOptions, ToolViewProps, ToolViewResolver,
} from './contract/toolview.ts'
export type {
ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps,
ChatStore, ChatViewInjected, ChatViewSlotProps, ConversationInjected, ConversationSlotProps,
ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
export { ConversationRoot } from './skeleton/ConversationRoot.tsx'
export type { ConversationRootProps } from './skeleton/ConversationRoot.tsx'
export { InputBar } from './skeleton/InputBar.tsx'
export type { InputBarError, InputBarProps } from './skeleton/InputBar.tsx'
export { EmptyState } from './skeleton/EmptyState.tsx'
export type { EmptyStateProps } from './skeleton/EmptyState.tsx'
export { DetailsPanel } from './skeleton/DetailsPanel.tsx'
export type { DetailsPanelProps } from './skeleton/DetailsPanel.tsx'
// Export discipline: packages/client/AGENTS.md.
declare module 'cordis' {
interface Context {
conversation: ConversationService
toolviews: ToolViewRegistry
}
}

View File

@@ -1,8 +1,10 @@
/**
* ConversationService implementation: scope-addressed send/cancel, per-scope
* selection/draft stores booked on the session scope fiber, view registry
* with a uSES read face, openDetails orchestration, and the empty-state
* startSession chain. Contract: api-contracts v3 section 7.
* 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 addressing rides the cordis Service tracker: property access through
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
@@ -20,29 +22,10 @@ import type { Context } from 'cordis'
// SessionsService tags contexts with — scopeOf then always returns undefined
// in the browser while unit tests (single-instance path resolution) stay green.
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { Session, SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SelectionTarget, ViewEntry, ViewId } from './index.ts'
/** Mutable view-registry cell (plain object: mutation never crosses the tracker proxy). */
interface ViewsState {
entries: Map<string, ViewEntry>
/** Sorted projection cache; null = rebuild on next read. */
cache: readonly ViewEntry[] | null
tick: number
listeners: Set<() => void>
}
import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
export class ConversationService extends Service {
private readonly selections = new Map<SessionId, SnapshotStore<SelectionTarget | null>>()
private readonly draftStores = new Map<SessionId, SnapshotStore<string>>()
private readonly viewsState: ViewsState = {
entries: new Map(), cache: null, tick: 0, listeners: new Set(),
}
/**
* @param ctx - owning root context (the plugin apply context; the service
* registers itself and follows that fiber's lifetime).
@@ -71,91 +54,6 @@ export class ConversationService extends Service {
if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`)
}
/** Per-scope selection channel (details linkage); root access throws. */
get selection(): SnapshotStore<SelectionTarget | null> {
return this.scopeStore(this.selections, 'selection',
() => createSnapshotStore<SelectionTarget | null>(null))
}
/**
* Per-scope draft store, persisted per session id; root access throws.
* Persistence is hand-rolled (raw string per key): the snapshot-store
* engine's persist middleware object-spreads state on save, corrupting
* primitive-state stores.
*/
get drafts(): SnapshotStore<string> {
return this.scopeStore(this.draftStores, 'drafts', (id) => {
const key = `dsh.conversation.draft.${id}`
const store = createSnapshotStore<string>(loadDraft(key))
store.subscribe(() => { saveDraft(key, store.getSnapshot()) })
return store
})
}
/**
* Write the scoped selection and open the details panel. Orchestration
* only — panel geometry stays with ctx.layout.
* @param target - selection target.
*/
openDetails(target: SelectionTarget): void {
this.selection.set(target)
this.requireLayout().openDetails()
}
/**
* Register a conversation view. Duplicate ids throw; the registration is an
* effect on the caller's fiber (plugin unload collects it).
* @param entry - the view entry.
* @returns disposer removing the view.
*/
registerView<Id extends ViewId>(entry: ViewEntry<Id>): () => void {
const views = this.viewsState
const dispose = this.ctx.effect(() => {
if (views.entries.has(entry.id)) {
throw new Error(`conversation view "${entry.id}" is already registered`)
}
views.entries.set(entry.id, entry)
bumpViews(views)
return () => {
views.entries.delete(entry.id)
bumpViews(views)
}
}, 'conversation.registerView()')
// The effect disposer settles asynchronously; the registry face stays a
// synchronous fire-and-forget disposer.
return () => { void dispose() }
}
/**
* Registered views ordered by `order` (ties keep registration sequence).
* Stable array reference between mutations (uSES getSnapshot source).
* @returns the view entries.
*/
views(): readonly ViewEntry[] {
const state = this.viewsState
state.cache ??= [...state.entries.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
return state.cache
}
/**
* Subscribe to view registry changes (synchronous, like the toolview registry).
* @param fn - change callback.
* @returns unsubscribe.
*/
subscribeViews(fn: () => void): () => void {
const { listeners } = this.viewsState
listeners.add(fn)
return () => { listeners.delete(fn) }
}
/**
* Monotonic view registry version for uSES pairing.
* @returns current version.
*/
viewsVersion(): number {
return this.viewsState.tick
}
/**
* Empty-state first-send chain (root-context method; does not read scope):
* create the session, navigate to it, then send through the new scope.
@@ -170,9 +68,9 @@ export class ConversationService extends Service {
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 layout.open validates against it.
// list-store projection landed before sessions.open validates against it.
await Promise.resolve()
this.requireLayout().open(id)
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
@@ -185,34 +83,11 @@ export class ConversationService extends Service {
/** 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)
}
/** Read the caller's session scope tag; root contexts fail loud. */
private scopeId(op: string): SessionId {
const id = scopeOf(this.ctx)
if (id === undefined) {
throw new Error(`conversation.${op} requires a session scope — address one via ctx.sessions.scope(id).conversation`)
}
return id
}
/**
* Per-scope store account: lazily created, booked on the scope fiber so the
* scope teardown (SessionsService prune) collects the entry.
*/
private scopeStore<T>(
map: Map<SessionId, SnapshotStore<T>>, op: string,
make: (id: SessionId) => SnapshotStore<T>): SnapshotStore<T> {
const id = this.scopeId(op)
let store = map.get(id)
if (store === undefined) {
store = make(id)
map.set(id, store)
this.ctx.effect(() => () => { map.delete(id) }, `conversation.${op} scope account`)
}
return store
return this.requireSessions().manager.get(id)
}
private requireSessions(): SessionsService {
@@ -223,29 +98,4 @@ export class ConversationService extends Service {
if (sessions === undefined) throw new Error('conversation: sessions service unavailable')
return sessions
}
private requireLayout(): LayoutService {
const layout = this.ctx.get('layout')
if (layout === undefined) throw new Error('conversation: layout service unavailable')
return layout
}
}
function bumpViews(state: ViewsState): void {
state.cache = null
state.tick += 1
for (const fn of [...state.listeners]) fn()
}
function loadDraft(key: string): string {
/* v8 ignore next -- storage-less environment guard (workers/tests without DOM); jsdom always provides localStorage. */
if (typeof localStorage === 'undefined') return ''
return localStorage.getItem(key) ?? ''
}
function saveDraft(key: string, text: string): void {
/* v8 ignore next -- storage-less environment guard (workers/tests without DOM); jsdom always provides localStorage. */
if (typeof localStorage === 'undefined') return
if (text === '') localStorage.removeItem(key)
else localStorage.setItem(key, text)
}

View File

@@ -1,38 +1,57 @@
// ConversationRoot: the conversation slot's skeleton (figma Header 39:27730 +
// Tab_Group + view area + composer). Zero framework imports — everything
// arrives via props from the inject factory: breadcrumb feed, view registry
// read face, per-view render, and the composer's draft/send choreography.
// The active view id lives in layout.viewFor (shell viewing state), read and
// written through injected accessors.
// Tab_Group + view area + composer). Pure component — everything arrives via
// props: the framework standard kit (useSession/sessionId/useSessions), the
// declared chat store's useStore/actions, the injected business face, and the
// renderSlot share for the declared 'conversation.view' child slot (views are
// slot entries; the active one renders via the list `only` filter).
// 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).
import { 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 { ConversationSlotProps } from '../contract/slots.ts'
import { InputBar } from './InputBar.tsx'
import type { InputBarError } from './InputBar.tsx'
import css from './ConversationRoot.module.css'
/**
* Full props = owner share (sessionId) & standard share (useSession) &
* injected share — composed by reference from the contract, never re-typed
* here (share-ownership rule).
*/
/** Full props = the automatic shares & injected share — composed by reference
* from the contract, never re-typed here (share-ownership rule). */
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, useAncestry, views, useActiveView, composer, actions, renderView,
sessionId, useSession, useSessions, useStore, actions, renderSlot,
views, send, stop, open,
}: ConversationRootProps) {
useSyncExternalStore(views.subscribe, views.version)
const list = views.list()
const activeId = useActiveView() ?? 'chat'
const active = list.find(v => v.id === activeId) ?? list[0]
const tabs = views.list()
// The store's persisted view id may be stale (view plugin unloaded); the
// slot ledger is the runtime validator — unknown ids fall to the first view.
const activeId = useStore(s => s.view) ?? 'chat'
const active = tabs.find(v => v.id === activeId) ?? tabs[0]
const ancestry = useAncestry()
const draft = composer.useDraft()
const running = useSession(s => (s as { running: boolean }).running)
const removed = useSession(s => (s as { removed: boolean }).removed)
const promptError = useSession(s => (s as { promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null }).promptError)
const turns = useSession(s => countTurns(s as { nodes: readonly { kind: string }[] }))
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 error: InputBarError | null = promptError === null
? null
@@ -52,7 +71,7 @@ export function ConversationRoot({
type="button"
className={clsx(css.crumb, last && css.crumbCurrent)}
disabled={last}
onClick={() => { actions.open(s.id) }}
onClick={() => { open(s.id) }}
>
{s.displayTitle}
</button>
@@ -65,16 +84,16 @@ export function ConversationRoot({
{/* Header button row (Fork / Session log / I/O Details): a P-I visual
placeholder registry slot is deferred — buttons land with their features. */}
</div>
{list.length > 1 && (
{tabs.length > 1 && (
<div className={css.tabs} role="tablist">
{list.map(v => (
{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.openView(v.id) }}
onClick={() => { actions.setView(v.id) }}
>
{v.label}
</button>
@@ -84,7 +103,7 @@ export function ConversationRoot({
</header>
<div className={css.viewArea}>
{active !== undefined && renderView(active)}
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>
<InputBar
@@ -93,9 +112,9 @@ export function ConversationRoot({
disabled={removed}
error={error}
variant="composer"
onDraftChange={composer.setDraft}
onSend={composer.send}
onStop={composer.stop}
onDraftChange={actions.setDraft}
onSend={(mode) => { send(draft, mode) }}
onStop={stop}
/>
</div>
)

View File

@@ -1,14 +1,16 @@
// DetailsPanel, P-I minimal form: close button + the selected call's args and
// result rendered raw. The three-段 Switch / Prev-Next stepping / See-in-
// trajectory are deferred (ledger). Subscribes to the per-scope selection and
// derives the call material from the session snapshot — no data of its own.
// trajectory are deferred (ledger). Reads the selection from the shared chat
// store (conversation writes, this panel reads — the cross-registration
// share the store seat exists for) and derives the call material from the
// session snapshot — no data of its own.
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { shallowEqual } from '@deepseek-ai/dsh-client-web-react'
import type { DetailsSlotProps } from '../contract/slots.ts'
import css from './DetailsPanel.module.css'
/** Full props composed by reference from the contract (owner & standard & injected shares). */
/** Full props composed by reference from the contract (automatic shares & injected share). */
export type DetailsPanelProps = DetailsSlotProps
/** Selected call material: resolved result node, or the in-flight running call's args. */
@@ -41,13 +43,13 @@ function pretty(raw: string): string {
}
}
export function DetailsPanel({ useSession, useSelection, actions }: DetailsPanelProps) {
const selection = useSelection(s => s)
export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPanelProps) {
const selection = useStore(s => s.selection)
const callId = selection?.callId
// materialFor builds a fresh wrapper; shallowEqual short-circuits on its
// stable members (result node reference rides the snapshot's structural sharing).
const material = useSession(
s => (callId === undefined ? null : materialFor(s as ConversationSnapshot, callId)),
s => (callId === undefined ? null : materialFor(s, callId)),
(a, b) => shallowEqual(a, b))
return (
@@ -58,7 +60,7 @@ export function DetailsPanel({ useSession, useSelection, actions }: DetailsPanel
</div>
<button
type="button" className={css.close} aria-label="关闭详情"
onClick={() => { actions.closeDetails() }}
onClick={() => { closeDetails() }}
>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />

View File

@@ -1,12 +1,14 @@
// 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 from sessions.list plus a free-form new-directory
// input; submit runs the startSession chain (create → open → send) in one
// service call.
// 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 { useState } from 'react'
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'
@@ -15,11 +17,22 @@ 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 (owner & injected shares; root slot has no standard share). */
/** Full props composed by reference from the contract (runtime share & injected share; no store). */
export type EmptyStateProps = EmptyStateSlotProps
export function EmptyState({ useCwds, actions }: EmptyStateProps) {
const cwds = useCwds(s => s)
/** 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('')
@@ -35,14 +48,14 @@ export function EmptyState({ useCwds, actions }: EmptyStateProps) {
setSending(true)
setError(null)
const chosen = cwd.trim()
actions.startSession({ text, mode, ...(chosen === '' ? {} : { cwd: chosen }) })
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: layout.open swaps this slot out for the session body.
// Success needs no cleanup: the session selection swaps this slot out for the session body.
}
const picker = (

View File

@@ -0,0 +1,52 @@
/**
* 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).
*/
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.
*/
type ChatActions = {
select: (draft: ChatStoreState, target: SelectionTarget | null) => void
setDraft: (draft: ChatStoreState, text: string) => void
clearDraft: (draft: ChatStoreState) => void
restoreDraft: (draft: ChatStoreState, text: string) => void
setView: (draft: ChatStoreState, view: string) => void
}
/**
* 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).
*/
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: {
select: (d, target: SelectionTarget | null) => { d.selection = target },
setDraft: (d, text: string) => { d.draft = text },
clearDraft: (d) => { d.draft = '' },
// Optimistic-send failure restore: only when the user typed nothing new
// since the clear (send choreography lives in the inject factory).
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
setView: (d, view: string) => { d.view = view },
},
})
}

View File

@@ -1,20 +1,32 @@
// Bash toolview sample, written in third-party posture: everything below uses
// only the public registration surface (ctx.toolviews.register + ToolViewProps)
// — the differential-rendering acceptance proof for the registry chain.
// Two registrations: a global bash row, and a scope-filtered variant that
// takes over for matching sessions only (later registration wins its tier).
// 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.
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolViewProps } from '../contract/toolview.ts'
import type { ToolViewRegistry } from './registry.ts'
import { toolRowModel, type ToolCallBlock } from '../contract/tool-call-model.ts'
import type { Context } from 'cordis'
import type { ToolRowProps } from '../contract/slots.ts'
import { toolRowModel } from '../contract/tool-call-model.ts'
import css from './bash-sample.module.css'
/** Global bash row: command-first monospace summary (replaces the generic row). */
export function BashRow({ toolName, block, actions }: ToolViewProps) {
const model = toolRowModel(toolName, block as ToolCallBlock)
/** 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) {
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>
)
}
return (
<div className={css.row} data-sample="bash-global" onClick={actions.openDetails}>
<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>}
@@ -22,31 +34,20 @@ export function BashRow({ toolName, block, actions }: ToolViewProps) {
)
}
/** Scoped variant: visually distinct so the differential hit is observable. */
export function ScopedBashRow({ toolName, block, actions }: ToolViewProps) {
const model = toolRowModel(toolName, block as ToolCallBlock)
return (
<div className={css.row} data-sample="bash-scoped" onClick={actions.openDetails}>
<span className={css.scopeBadge}>scoped</span>
<span className={css.command}>{model.summary}</span>
</div>
)
}
/**
* Register both sample rows.
* @param toolviews - the conversation plugin's registry service.
* @param scope - session filter for the scoped variant.
* @returns disposer removing both registrations.
* The sample as a plain registrant plugin. `inject` carries the load-order
* seam: requiring the conversation service guarantees the chat entry (and
* with it the 'conversation.chat.toolview' declaration) is registered —
* ui-conversation's apply mounts the service after the chat entry.
*/
export function registerBashSamples(
toolviews: ToolViewRegistry,
scope: (sessionId: SessionId) => boolean,
): () => void {
const offGlobal = toolviews.register('bash', BashRow)
const offScoped = toolviews.register('bash', ScopedBashRow, { scope })
return () => {
offGlobal()
offScoped()
}
export const bashToolviewSample = {
name: 'bash-toolview-sample',
inject: ['slots', 'conversation'],
/**
* Register the bash 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: 'bash' }, BashRow)
},
}

View File

@@ -1,103 +0,0 @@
/**
* ToolViewRegistry: named per-tool component registry, session-scope aware
* (api-contracts v3 section 7). Consumed by chat now, trajectory/waterfall
* later — deliberately a named service, not a SlotMap key. The tool key set
* is deliberately open (model-side tools arrive at runtime): the strong
* typing lives inside the Entry — `I` is inferred from the inject factory at
* the register site and proves component props ⊇ ToolViewProps & I.
*/
import type { FC } from 'react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ResolvedToolView, ToolViewOptions, ToolViewProps } from '../contract/toolview.ts'
/** Stored registration: the per-registration inject parameter is erased
* (storage-erase/read-restore is the typed-Map boundary, one cast budgeted). */
interface Registration extends ToolViewOptions {
component: FC<ToolViewProps & object>
}
/**
* Per-tool renderer registry. Resolution order: scope match (later
* registration wins) > global (same tie-break) > undefined, where the caller
* falls back to GenericToolCard.
*/
export class ToolViewRegistry {
private byTool = new Map<string, Registration[]>()
private version = 0
private listeners = new Set<() => void>()
/**
* Register a tool row renderer. The component must accept the shared
* ToolViewProps plus its own injected share `I` — mismatches (missing keys,
* wrong types, an inject factory that does not produce what the component
* declares) are register-site compile errors.
* @param tool - tool name the renderer takes over.
* @param component - row component over ToolViewProps & I.
* @param opts - optional session-scope filter and private inject factory.
* @returns disposer removing this registration.
*/
register<I extends object = object>(
tool: string, component: FC<ToolViewProps & I>, opts?: ToolViewOptions<I>): () => void {
const list = this.byTool.get(tool) ?? []
if (list.length === 0) this.byTool.set(tool, list)
// Storage erases I (heterogeneous registrations share one list); resolve
// restores the erased shape on the read face.
const entry: Registration = { component: component as FC<ToolViewProps & object>, ...opts }
list.push(entry)
this.bump()
let disposed = false
return () => {
if (disposed) return
disposed = true
const at = list.indexOf(entry)
/* v8 ignore next -- negative arm: an entry lives in one list and only its
own once-guarded disposer removes it, so a live disposer always finds it. */
if (at >= 0) list.splice(at, 1)
if (list.length === 0) this.byTool.delete(tool)
this.bump()
}
}
/**
* Resolve the renderer for a tool in a session.
* @param tool - tool name.
* @param sessionId - session the row renders in (fed to scope filters).
* @returns resolved view, or undefined when nothing matches.
*/
resolve(tool: string, sessionId: SessionId): ResolvedToolView | undefined {
const list = this.byTool.get(tool)
if (list === undefined) return undefined
let global: Registration | undefined
let scoped: Registration | undefined
for (const entry of list) {
if (entry.scope === undefined) global = entry
else if (entry.scope(sessionId)) scoped = entry
}
const hit = scoped ?? global
if (hit === undefined) return undefined
return hit.inject === undefined ? { component: hit.component } : { component: hit.component, inject: hit.inject }
}
/**
* Subscribe to registration changes (render outlets re-resolve on notify).
* @param fn - change listener.
* @returns disposer.
*/
subscribe(fn: () => void): () => void {
this.listeners.add(fn)
return () => this.listeners.delete(fn)
}
/**
* Monotonic registration version for uSES getSnapshot.
* @returns current version.
*/
getVersion(): number {
return this.version
}
private bump(): void {
this.version += 1
for (const fn of this.listeners) fn()
}
}

View File

@@ -15,11 +15,10 @@ export const name = 'client-ui-conversation-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: the conversation service emits no cordis events — its
* view and toolview registries notify through package-local subscribe faces
* whose ordering (synchronous version bump before notification) is exercised
* directly by the behavior specs, and the per-scope store accounts are owned
* mutable state with no cross-plugin observer to contradict.
* No runtime invariant: the conversation service emits no cordis events, and
* both rings this package owns (the 'conversation.view' tab ring and the
* 'conversation.chat.toolview' row hole) ride the slot system, whose ledger
* invariants live with the runtime slots package.
*/
const install: InvariantInstaller = () => {}

View File

@@ -1,26 +1,34 @@
// @vitest-environment jsdom
// apply inject factories exercised end to end: the conversation slot surface
// (ancestry feed, views triple, active view, composer choreography incl.
// optimistic clear + failure restore, renderView chrome assembly, watch-driven
// open), the details surface, and the empty-state surface (cwd derivation
// cache). Complements chat-apply.spec.tsx, which stops at registration.
// apply inject factories exercised end to end against the terminal thin
// shape: the conversation surface (views triple, send choreography incl.
// optimistic clear + failure restore THROUGH the declared store actions,
// openDetails = select action + layout orchestration, sessions.open
// navigation), the injectless-but-closeDetails details surface, and the
// one-callback empty surface. Complements chat-apply.spec.tsx (registration)
// and selection-survival.spec.ts (store axis). History opening is NOT an
// inject concern anymore — the runtime sessions service opens on watch
// (sessions-service.spec.ts owns that behavior).
import { Context } from 'cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { createElement } from 'react'
import { createSnapshotStore, bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { cleanup } from '@testing-library/react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService, scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationService, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-web-react'
import { ConversationService, apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { createChatStore } from '../src/client/stores.ts'
afterEach(cleanup)
const ROOT = 'root-1' as SessionId
type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
type ChatActions = ChatInstance['actions']
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
@@ -35,28 +43,18 @@ const SCOPE_TAG: symbol = (() => {
return symbol
})()
function snapshotBase(): ConversationSnapshot {
return {
sessionId: ROOT, nodes: [], foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
}
async function bench() {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
const listStore = createSnapshotStore<SessionListState>({
ids: [ROOT],
byId: { [ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', cwd: '/proj', running: false, updatedAt: 1 } },
})
const snap = snapshotBase()
current: ROOT,
} as SessionListState)
const sessionFake = {
getSnapshot: () => snap,
subscribe: () => () => {},
useSelector: undefined as unknown,
open: vi.fn(() => Promise.resolve()),
loadOlder: vi.fn(() => Promise.resolve()),
prompt: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
@@ -64,7 +62,6 @@ async function bench() {
cancel: vi.fn<() => Promise<{ ok: boolean; value?: object; error?: { code: string; message: string } }>>(
() => Promise.resolve({ ok: true, value: { accepted: true } })),
}
sessionFake.useSelector = bindSnapshotSelector(sessionFake as never)
const scopes = new Map<SessionId, Context>()
const mint = (id: SessionId): Context => {
let scoped = scopes.get(id)
@@ -77,200 +74,189 @@ async function bench() {
const sessionsFake = {
list: listStore,
manager: { get: () => sessionFake },
ancestry: (id: SessionId) => {
const s = listStore.getSnapshot().byId[id]
return s === undefined ? [] : [s]
},
scope: (id: SessionId) => mint(id),
cell: () => undefined,
create: vi.fn(() => Promise.resolve(ROOT)),
open: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
const layoutFake = {
current: createSnapshotStore<{ sessionId?: SessionId; viewFor: Record<string, string> }>({ viewFor: {} }),
open: vi.fn(), openView: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(),
}
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
ctx.provide('layout', layoutFake)
ctx.provide('i18n', { bind: () => (key: string) => key })
const slots = ctx.get('slots') as SlotsService
slots.define('conversation', { kind: 'single', scope: 'session' })
slots.define('details', { kind: 'single', scope: 'session' })
slots.define('conversation.empty', { kind: 'single', scope: 'root' })
// The AppFrame role: the three conversation-package slots must be declared
// by a live entry before apply can contribute into them (the stand-in
// consumes renderSlot to satisfy the declare-means-render check).
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, (_p: { renderSlot?: unknown }) => null)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const binding: SessionBinding = {
sessionId: ROOT as never,
session: { useSelector: sessionFake.useSelector } as never,
ctx: mint(ROOT) as never,
// Reach the render-side entry view (inject + store handle) the way the
// renderer does: through the host face.
let host: SlotRendererHost | undefined
slots.install({ renderRoot: (h) => { host = h; return null } })
slots.renderSlot('root', {})
const hostFace = host!
const entryOf = (key: 'conversation' | 'conversation.view' | 'details' | 'conversation.empty') => hostFace.entriesOf(key)[0]!
/** Resolve store instance + call the inject the way the outlet would. */
const conversationSurface = (id: SessionId) => {
const entry = entryOf('conversation')
const instance = hostFace.storeOf(entry, id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationInjected)(
id, instance.actions)
return { instance, injected }
}
const entryOf = (key: 'conversation' | 'details' | 'conversation.empty') => {
const entries = slots.entries(key)
return entries[0]! as { options: { inject: (b: unknown) => Record<string, unknown> } }
/** Same resolution for the chat entry riding the view ring. */
const chatViewSurface = (id: SessionId) => {
const entry = entryOf('conversation.view')
const instance = hostFace.storeOf(entry, id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)(
id, instance.actions)
return { instance, injected }
}
return { ctx, slots, binding, sessionFake, sessionsFake, layoutFake, mint, entryOf }
return { ctx, slots, hostFace, entryOf, conversationSurface, chatViewSurface, sessionFake, sessionsFake, layoutFake, mint }
}
describe('conversation slot inject surface', () => {
it('assembles the full surface and pulls history through the watch signal', async () => {
it('assembles the thin surface side-effect-free, navigates via sessions.open', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
useAncestry: () => readonly { id: SessionId }[]
views: { list(): readonly ViewEntry[]; version(): number; subscribe(fn: () => void): () => void }
useActiveView: () => string | undefined
composer: { useDraft: () => string; setDraft(t: string): void; send(m: string): void; stop(): void }
actions: { openView(v: string): void; open(id: SessionId): void }
renderView: (entry: ViewEntry) => unknown
}
expect(b.sessionFake.open).toHaveBeenCalledTimes(1)
const { injected } = b.conversationSurface(ROOT)
// Assembly has no session side effects: opening the event window belongs
// to the runtime watch path, not the inject factory.
expect(b.sessionFake.open).not.toHaveBeenCalled()
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
injected.actions.openView('chat')
expect(b.layoutFake.openView).toHaveBeenCalledWith(ROOT, 'chat')
injected.actions.open(ROOT)
expect(b.layoutFake.open).toHaveBeenCalledWith(ROOT)
injected.open(ROOT)
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
// loadOlder moved to the chat view entry's face (the ring rider).
const chatView = b.chatViewSurface(ROOT)
chatView.injected.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
})
it('composer send trims, optimistically clears, and restores on failure; stop swallows rejection', async () => {
it('send trims, optimistically clears through actions, restores on failure without clobbering new typing', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
composer: { setDraft(t: string): void; send(m: 'queue'): void; stop(): void }
}
const scoped = b.mint(ROOT).get('conversation') as ConversationService
// Whitespace-only draft: no send.
scoped.drafts.set(' ')
injected.composer.send('queue')
const { instance, injected } = b.conversationSurface(ROOT)
// Whitespace-only: no send, and the (whitespace) draft is not cleared.
instance.actions.setDraft(' ')
injected.send(' ', 'queue')
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
expect(instance.store.getSnapshot().draft).toBe(' ')
// Success: cleared and stays cleared.
injected.composer.setDraft('hello')
injected.composer.send('queue')
expect(scoped.drafts.getSnapshot()).toBe('')
instance.actions.setDraft('hello')
injected.send('hello', 'queue')
expect(instance.store.getSnapshot().draft).toBe('')
await Promise.resolve()
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
// Failure: restored (draft still empty when the rejection lands).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
injected.composer.setDraft('retry me')
injected.composer.send('queue')
instance.actions.setDraft('retry me')
injected.send('retry me', 'queue')
await vi.waitFor(() => {
expect(scoped.drafts.getSnapshot()).toBe('retry me')
expect(instance.store.getSnapshot().draft).toBe('retry me')
})
// Failure with new typing: no clobber.
// Failure landing after new typing: no clobber (restoreDraft fills empty only).
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } })
injected.composer.send('queue')
injected.composer.setDraft('typed during flight')
injected.send('retry me', 'queue')
instance.actions.setDraft('typed during flight')
await new Promise(r => setTimeout(r, 0))
expect(scoped.drafts.getSnapshot()).toBe('typed during flight')
expect(instance.store.getSnapshot().draft).toBe('typed during flight')
// Stop failure is swallowed (promptError owns the surface).
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } })
injected.composer.stop()
injected.stop()
await new Promise(r => setTimeout(r, 0))
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
})
it('view actions forward: openDetails writes selection through the scoped service, loadOlder hits the session', async () => {
it('inject fails loud when the session resolves no scope or the scope lacks the service', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
// viewProps rides renderView's closure; reach the actions through a rendered entry.
renderView: (entry: ViewEntry) => React.ReactNode
}
let captured: { openDetails(t: { turnSeq: number; callId?: string }): void; loadOlder(): void } | undefined
const Probe = (p: { actions: typeof captured }) => {
captured = p.actions
return null
}
render(createElement('div', null, injected.renderView({
id: 'chat', label: 'Chat', component: Probe,
} as unknown as ViewEntry)))
captured!.openDetails({ turnSeq: 2, callId: 'c1' })
const entry = b.entryOf('conversation')
const instance = b.hostFace.storeOf(entry, ROOT) as ChatInstance
const injectFn = entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationInjected
// Unknown session: sessions.scope answers nothing.
;(b.sessionsFake.scope as unknown) = () => undefined
expect(() => injectFn(ROOT, instance.actions)).toThrow(/resolved no scope/)
// A scope minted outside the service tree: no conversation service on it.
const foreign = new Context()
;(b.sessionsFake.scope as unknown) = () => foreign.plugin(() => {}).ctx.extend({})
expect(() => injectFn(ROOT, instance.actions)).toThrow(/unavailable through the session scope/)
})
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
const b = await bench()
const { instance, injected } = b.chatViewSurface(ROOT)
injected.openDetails({ turnSeq: 2, callId: 'c1' })
expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' })
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
const scoped = b.mint(ROOT).get('conversation') as import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
expect(scoped.selection.getSnapshot()).toEqual({ turnSeq: 2, callId: 'c1' })
captured!.loadOlder()
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
// The chat view shares the conversation entry's store instance: selection
// writes land where the skeleton and details read.
const conv = b.conversationSurface(ROOT)
expect(conv.instance).toBe(instance)
})
it('renderView mounts chrome header/footer around the view body', async () => {
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
const b = await bench()
const injected = b.entryOf('conversation').options.inject(b.binding) as {
renderView: (entry: ViewEntry) => React.ReactNode
}
const entry = {
id: 'chat', label: 'Chat',
component: () => createElement('div', { 'data-testid': 'body' }),
chrome: {
header: () => createElement('div', { 'data-testid': 'hd' }),
footer: () => createElement('div', { 'data-testid': 'ft' }),
},
} as unknown as ViewEntry
const view = render(createElement('div', null, injected.renderView(entry)))
expect(view.getByTestId('hd')).toBeTruthy()
expect(view.getByTestId('body')).toBeTruthy()
expect(view.getByTestId('ft')).toBeTruthy()
// Ancestry and draft/active-view hooks execute inside a component tree.
const HookProbe = () => {
const injected2 = b.entryOf('conversation').options.inject(b.binding) as {
useAncestry: () => readonly { displayTitle: string }[]
useActiveView: () => string | undefined
composer: { useDraft: () => string }
}
const chain = injected2.useAncestry()
const active = injected2.useActiveView()
const draft = injected2.composer.useDraft()
return createElement('i', { 'data-testid': 'probe' }, `${chain.length}|${active ?? 'none'}|${draft}`)
}
const probe = render(createElement(HookProbe))
// Draft content carries over from the composer case (per-scope store is
// process-resident); the probe asserts hook wiring, not draft value.
expect(probe.getByTestId('probe').textContent).toMatch(/^1\|none\|/)
// A list-store update while mounted drives the ancestry selector's
// shallowEqual arm (same derived chain → short-circuit, no re-render churn).
await act(async () => {
b.sessionsFake.list.update((d: { byId: Record<string, { updatedAt: number }> }) => {
d.byId[ROOT]!.updatedAt = 2
})
})
expect(probe.getByTestId('probe').textContent).toMatch(/^1\|none\|/)
// The views read-face triple forwards to the service registry.
const injected3 = b.entryOf('conversation').options.inject(b.binding) as {
views: { list(): readonly { id: string }[]; subscribe(fn: () => void): () => void; version(): number }
}
expect(injected3.views.list().map(v => v.id)).toEqual(['chat'])
const beforeVersion = injected3.views.version()
const { injected } = b.conversationSurface(ROOT)
const before = injected.views.version()
const listener = vi.fn()
const unsub = injected3.views.subscribe(listener)
const conversation = b.ctx.get('conversation') as import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
const offExtra = conversation.registerView({ id: 'chat2', label: 'X', component: () => null } as never)
const unsub = injected.views.subscribe(listener)
// A second ring rider (what ui-trajectory does in production).
const off = b.slots.register(
{ name: 'conversation.view', id: 'chat2', order: 5, label: 'X' } as never, (() => null) as never)
await Promise.resolve() // ledger notifications batch per microtask
expect(listener).toHaveBeenCalled()
expect(injected3.views.version()).toBeGreaterThan(beforeVersion)
offExtra()
expect(injected.views.version()).toBeGreaterThan(before)
expect(injected.views.list().map(v => v.id)).toEqual(['chat', 'chat2'])
// Label falls back to the id when a rider declares none.
const off2 = b.slots.register(
{ name: 'conversation.view', id: 'bare', order: 6 } as never, (() => null) as never)
expect(injected.views.list().map(v => v.label)).toEqual(['Chat', 'X', 'bare'])
off()
off2()
unsub()
})
})
describe('details and empty inject surfaces', () => {
it('details surface wires selection and closeDetails', async () => {
it('details injects the one layout callback; selection rides the shared store instead', async () => {
const b = await bench()
const injected = b.entryOf('details').options.inject(b.binding) as {
useSelection: unknown
actions: { closeDetails(): void }
}
expect(injected.useSelection).toBeTypeOf('function')
injected.actions.closeDetails()
const entry = b.entryOf('details')
const injected = (entry.inject as unknown as () => DetailsInjected)()
expect(Object.keys(injected)).toEqual(['closeDetails'])
injected.closeDetails()
expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
// The shared handle: details resolves the SAME instance conversation writes.
const conv = b.hostFace.storeOf(b.entryOf('conversation'), ROOT)
const details = b.hostFace.storeOf(entry, ROOT)
expect(details).toBe(conv)
})
it('empty surface derives the deduped cwd set with a per-state cache and starts sessions', async () => {
it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => {
const b = await bench()
const injected = b.entryOf('conversation.empty').options.inject({ ctx: b.ctx }) as {
useCwds: (sel: (s: readonly string[]) => unknown, eq?: unknown) => unknown
actions: { startSession(opts: { text: string; mode: 'queue' }): Promise<void> }
}
const CwdsProbe = () => {
const cwds = injected.useCwds(s => s) as readonly string[]
return createElement('i', { 'data-testid': 'cwds' }, cwds.join(','))
}
const view = render(createElement(CwdsProbe))
expect(view.getByTestId('cwds').textContent).toBe('/proj')
await injected.actions.startSession({ text: 'go', mode: 'queue' })
const entry = b.entryOf('conversation.empty')
expect(entry.store).toBeUndefined()
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
expect(Object.keys(injected)).toEqual(['startSession'])
await injected.startSession({ text: 'go', mode: 'queue' })
expect(b.sessionsFake.create).toHaveBeenCalled()
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
})
it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => {
const b = await bench()
const injected = (b.entryOf('conversation.empty').inject as unknown as () => EmptyStateInjected)()
// Tear the service's own fiber (registry keyed by the class): the slot
// entries survive, so the gesture-time read hits the loud branch.
b.ctx.registry.delete(ConversationService)
await vi.waitFor(() => { expect(b.ctx.get('conversation')).toBeUndefined() })
expect(() => injected.startSession({ text: 'go', mode: 'queue' })).toThrow(/conversation service unavailable/)
})
})

View File

@@ -1,19 +1,19 @@
// @vitest-environment jsdom
// apply wiring: services provided, chat view + footer chrome registered, the
// three slot registrations land against ui-layout-shaped specs, and the bash
// samples resolve differentially (sub-session default scope). Full-chain
// rendering belongs to the shell e2e; this spec stops at the assembly surface.
// apply wiring: the conversation service provided, the chat view registered
// as the first 'conversation.view' ring entry declaring the keyed toolview
// hole, the three slot registrations land against a root entry's children
// declarations (the AppFrame role), the shared store handle rides all session
// entries, and the bash sample mounts through the load-order seam as a keyed
// entry. Full-chain rendering belongs to the machinery spec
// (chat-toolview-slot.spec.tsx) and the shell e2e; this spec stops at the
// assembly surface.
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Type-only: pulls ui-layout's SlotMap declaration merge into this spec's
// program so the slot keys below typecheck in the client lane.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
const ROOT = 'root-1' as SessionId
const CHILD = 'child-1' as SessionId
@@ -29,77 +29,101 @@ async function bench() {
[ROOT]: { id: ROOT, title: 'R', displayTitle: 'R', running: false, updatedAt: 1 },
[CHILD]: { id: CHILD, title: 'C', displayTitle: 'C', parentId: ROOT, running: false, updatedAt: 2 },
},
})
current: undefined,
} as SessionListState)
const sessionsFake = {
list: listStore,
manager: { get: vi.fn() },
ancestry: () => [],
scope: () => undefined,
cell: () => undefined,
create: vi.fn(),
open: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
ctx.provide('layout', {
current: createSnapshotStore<{ viewFor: Record<string, string> }>({ viewFor: {} }),
open: vi.fn(), openView: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('i18n', { bind: () => (key: string) => key })
// Specs owned by ui-layout in production; declared here so registrations land.
// Declared by ui-layout's root entry in production; a stand-in root
// occupant declares them here so the contributions land (it consumes
// renderSlot to satisfy the declare-means-render check).
const slots = ctx.get('slots') as SlotsService
slots.define('conversation', { kind: 'single', scope: 'session' })
slots.define('details', { kind: 'single', scope: 'session' })
slots.define('conversation.empty', { kind: 'single', scope: 'root' })
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, (_p: { renderSlot?: unknown }) => null)
const fiber = ctx.plugin({ inject: [...inject], apply })
return { ctx, fiber, slots }
}
/** First stored entry for a key (inject/store live directly on StoredEntry). */
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'conversation.view' | 'details' | 'conversation.empty') {
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
}
describe('apply wiring', () => {
it('provides conversation and toolviews services', async () => {
it('provides the conversation service', async () => {
const b = await bench()
await b.fiber.await()
expect(b.ctx.get('conversation')).toBeDefined()
expect(b.ctx.get('toolviews')).toBeInstanceOf(ToolViewRegistry)
})
it('registers the chat view with the stats footer', async () => {
it('registers the chat view as the first ring entry, declaring the keyed toolview hole', async () => {
const b = await bench()
await b.fiber.await()
const conversation = b.ctx.get('conversation') as ConversationService
const views = conversation.views()
expect(views.map((v) => v.id)).toEqual(['chat'])
expect(views[0]?.chrome?.footer).toBeDefined()
const entries = b.slots.entries('conversation.view')
expect(entries.map((e) => e.options.id)).toEqual(['chat'])
expect(entries[0]?.options.label).toBe('Chat')
expect(entries[0]?.options.order).toBe(0)
// Declaring is claiming: the chat entry's registration put the hole on
// the ledger with the contract's kind/scope.
expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
})
it('occupies conversation/details/conversation.empty with inject factories', async () => {
it('occupies the three slots + the ring; session entries share one store handle, empty declares none', async () => {
const b = await bench()
await b.fiber.await()
for (const key of ['conversation', 'details', 'conversation.empty'] as const) {
const entries = b.slots.entries(key)
expect(entries, key).toHaveLength(1)
expect((entries[0]!.options as { inject?: unknown }).inject, key).toBeTypeOf('function')
}
const conversation = renderEntryOf(b.slots, 'conversation')
const chatView = renderEntryOf(b.slots, 'conversation.view')
const details = renderEntryOf(b.slots, 'details')
const empty = renderEntryOf(b.slots, 'conversation.empty')
expect(conversation?.inject).toBeTypeOf('function')
expect(chatView?.inject).toBeTypeOf('function')
expect(details?.inject).toBeTypeOf('function')
expect(empty?.inject).toBeTypeOf('function')
// The shared handle: one apply-built store value on ALL session entries.
expect(conversation?.store).toBeDefined()
expect(details?.store).toBe(conversation?.store)
expect(chatView?.store).toBe(conversation?.store)
// The empty slot is storeless (local state + useSessions derivation).
expect(empty?.store).toBeUndefined()
})
it('bash samples resolve differentially: scoped row for sub-sessions, global for roots', async () => {
it('mounts the bash sample as a keyed entry through the load-order seam', async () => {
const b = await bench()
await b.fiber.await()
const toolviews = b.ctx.get('toolviews') as ToolViewRegistry
const forChild = toolviews.resolve('bash', CHILD)
const forRoot = toolviews.resolve('bash', ROOT)
expect(forChild).toBeDefined()
expect(forRoot).toBeDefined()
expect(forChild!.component).not.toBe(forRoot!.component)
// The sample plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map((e) => e.options.key)).toEqual(['bash'])
})
it('plugin fiber disposal collects every registration (unload cascade)', async () => {
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
const b = await bench()
await b.fiber.await()
await b.fiber.dispose()
expect(b.slots.entries('conversation')).toHaveLength(0)
// The declared ring collapses with its declaring entry, and the chat
// entry's keyed hole (with the sample's registration) collapses with it.
expect(b.slots.entries('conversation.view')).toHaveLength(0)
expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
expect(b.slots.entries('details')).toHaveLength(0)
expect(b.slots.entries('conversation.empty')).toHaveLength(0)
expect(b.ctx.get('conversation')).toBeUndefined()
expect(b.ctx.get('toolviews')).toBeUndefined()
})
})

View File

@@ -1,41 +1,20 @@
// @vitest-environment jsdom
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
// ToolViewOutlet inject cache + crash fallback + retry, StatsLine no-cache
// join, PendingCard reason strip, AssistantMarkdown single-line reasoning,
// ChatView view-body fallbacks, and apply's action lambdas.
// StatsLine no-cache join, PendingCard reason strip, AssistantMarkdown
// single-line reasoning. (Tool-row dispatch tails live with the keyed-slot
// machinery specs since the tool ring dissolved into renderSlot.)
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { act } from '@testing-library/react'
import type { SessionId, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import { bindSnapshotSelector, createSessionProvider } from '@deepseek-ai/dsh-client-web-react'
import type { SessionBinding as ReactSessionBinding, UseSession } from '@deepseek-ai/dsh-client-web-react'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolViewProps, Translate } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { MessageItem } from '../src/client/chat/MessageItem.tsx'
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
import { ToolViewOutlet } from '../src/client/chat/ToolViewOutlet.tsx'
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
const result = (callId: string): ToolResultNode => ({
kind: 'tool-result', seq: 3, callId,
call: { name: 'bash', argsRaw: '{"command":"x"}' },
content: [], isError: false, callView: null, resultView: null,
})
const viewProps = (): ToolViewProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: ((k: string) => k) as Translate,
})
describe('MessageItem arms', () => {
it('steering bubbles carry the interjection badge and non-text rest blocks', () => {
const view = render(
@@ -85,72 +64,8 @@ describe('small branch tails', () => {
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession} />,
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
)
expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
})
})
describe('ToolViewOutlet dispatch', () => {
it('caches the inject factory per (registration x binding) and merges its props', () => {
const registry = new ToolViewRegistry()
const inject = vi.fn(() => ({ extra: 'injected' }))
registry.register('bash',
(p: ToolViewProps & { extra: string }) => <div data-testid="row">{p.extra}</div>,
{ inject })
// InjectedRow reads the session binding from context: mount through the
// real SessionProvider so the (factory x binding) cache path executes.
const binding: ReactSessionBinding = {
sessionId: SID,
session: { useSelector: (() => { throw new Error('unused') }) as never },
ctx: {},
}
const Provider = createSessionProvider({
useCurrent: () => SID,
resolveBinding: () => binding,
renderBody: () => (
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />
),
})
const view = render(<Provider />)
expect(view.getByTestId('row').textContent).toBe('injected')
expect(inject).toHaveBeenCalledTimes(1)
// Remount against the SAME binding: cache hit, factory not re-run.
view.unmount()
const second = render(<Provider />)
expect(second.getByTestId('row').textContent).toBe('injected')
expect(inject).toHaveBeenCalledTimes(1)
})
it('a crashing custom row falls back to GenericToolCard and retries on re-registration', () => {
const registry = new ToolViewRegistry()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
// React dev builds re-dispatch boundary-caught errors as window 'error'
// events (invokeGuardedCallback); swallow them so vitest sees the caught path.
const swallow = (e: Event): void => { e.preventDefault() }
window.addEventListener('error', swallow)
try {
const Bomb = () => { throw new Error('row bomb') }
registry.register('bash', Bomb as never)
const view = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
// Crash caught: generic row rendered instead.
expect(view.getByText('Bash')).toBeTruthy()
// A new registration bumps the version; the boundary retries the custom row.
act(() => { registry.register('bash', (() => <div data-testid="fixed" />) as never) })
expect(view.getByTestId('fixed')).toBeTruthy()
} finally {
window.removeEventListener('error', swallow)
consoleError.mockRestore()
}
})
it('registry miss renders the generic row directly', () => {
const registry = new ToolViewRegistry()
const view = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
expect(view.getByText('Bash')).toBeTruthy()
})
})

View File

@@ -1,21 +1,19 @@
// @vitest-environment jsdom
// StatsLine (chrome.footer first consumer): totals derivation + the RFC hard
// acceptance — zero renders during streaming. Bash sample: differential
// registry hits per session, teardown reverts to the generic row.
// StatsLine (rendered inside the chat view body): totals derivation + the RFC
// hard acceptance — zero renders during streaming. Bash sample row: the
// canonical sub-agent differential decided INSIDE the component off the
// standard useSessions kit (no registry predicates — tool ring dissolved).
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode,
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ChromeProps, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { StatsLine, deriveStats } from '../src/client/chat/StatsLine.tsx'
import { BashRow, ScopedBashRow, registerBashSamples } from '../src/client/toolviews/bash-sample.tsx'
import { ToolViewOutlet } from '../src/client/chat/ToolViewOutlet.tsx'
import { childSessionScope } from '../src/client/chat/register.ts'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { StatsLine, deriveStats, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
afterEach(cleanup)
@@ -77,8 +75,8 @@ describe('deriveStats', () => {
})
describe('StatsLine', () => {
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): ChromeProps {
return { sessionId: SID, useSession: bindSnapshotSelector(source) as unknown as UseSession }
function props(source: { getSnapshot(): ConversationSnapshot; subscribe(fn: () => void): () => void }): StatsLineProps {
return { useSession: bindSnapshotSelector(source) }
}
it('renders the joined stats row and hides with zero steps', () => {
@@ -95,7 +93,7 @@ describe('StatsLine', () => {
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
const { set, source } = makeSource({ nodes: [assistant(1, 1)] })
let renders = 0
function Counting(p: ChromeProps) {
function Counting(p: StatsLineProps) {
renders += 1
return <StatsLine {...p} />
}
@@ -109,70 +107,79 @@ describe('StatsLine', () => {
})
})
describe('bash toolview samples', () => {
describe('bash sample row', () => {
const ROOT = 'root-1' as SessionId
const CHILD = 'child-1' as SessionId
const result = (callId: string): ToolResultNode => ({
kind: 'tool-result', seq: 3, callId,
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
content: [], isError: false, callView: null, resultView: null,
})
const viewProps = (openDetails = vi.fn()): ToolViewProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails },
t: (k) => k,
})
function outlet(registry: ToolViewRegistry, sessionId: SessionId, p = viewProps()) {
return render(
<ToolViewOutlet registry={registry} sessionId={sessionId} toolName="bash" viewProps={p} />,
)
/** Real list-store engine: the family fixture the in-component parentId branch reads. */
function listStore() {
return createSnapshotStore<SessionListState>({
ids: [ROOT, CHILD],
byId: {
[ROOT]: { id: ROOT, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: 'c', displayTitle: 'c', parentId: ROOT, running: false, updatedAt: 0 },
},
current: undefined,
} as SessionListState)
}
it('differential rendering: scoped row for the matching session, global elsewhere', () => {
const registry = new ToolViewRegistry()
registerBashSamples(registry, (id) => id === ('swarm' as SessionId))
const scoped = outlet(registry, 'swarm' as SessionId)
const rowProps = (sessionId: SessionId, over?: {
store?: ReturnType<typeof listStore>
openDetails?: () => void
}): ToolRowProps => ({
callId: 'c1', toolName: 'bash', block: result('c1'),
openDetails: over?.openDetails ?? vi.fn(),
sessionId,
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
} as unknown as ToolRowProps)
it('differential rendering: the scoped variant in sub-sessions, global at roots', () => {
const scoped = render(<BashRow {...rowProps(CHILD)} />)
expect(scoped.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
const plain = outlet(registry, SID)
expect(scoped.getByText('scoped')).toBeTruthy()
const plain = render(<BashRow {...rowProps(ROOT)} />)
expect(plain.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
})
it('teardown removes both registrations and falls back to the generic row', () => {
const registry = new ToolViewRegistry()
const off = registerBashSamples(registry, () => true)
const view = outlet(registry, SID)
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
act(() => off())
expect(view.container.querySelector('[data-sample]')).toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
it('a session outside the list renders the global arm (no parent known)', () => {
const view = render(<BashRow {...rowProps('gone' as SessionId)} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
})
it('childSessionScope matches sub-sessions via the injected list read face', () => {
const child = 'child' as SessionId
const root = 'root' as SessionId
const scope = childSessionScope({
getSnapshot: () => ({
ids: [root, child],
byId: {
[root]: { id: root, title: 'r', displayTitle: 'r', running: false, updatedAt: 0 },
[child]: { id: child, title: 'c', displayTitle: 'c', parentId: root, running: false, updatedAt: 0 },
},
}),
it('a live parentId write flips the row to the scoped variant (store subscription)', () => {
const store = listStore()
const orphan = 'late-child' as SessionId
store.update((d) => {
d.ids.push(orphan)
d.byId[orphan] = { id: orphan, title: 'l', displayTitle: 'l', running: false, updatedAt: 0 }
})
expect(scope(child)).toBe(true)
expect(scope(root)).toBe(false)
expect(scope('gone' as SessionId)).toBe(false)
const view = render(<BashRow {...rowProps(orphan, { store })} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
act(() => {
store.update((d) => { d.byId[orphan]!.parentId = ROOT })
})
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
})
it('sample rows summarize the command and hand clicks to openDetails', () => {
const open = vi.fn()
const p = viewProps(open)
const global = render(<BashRow {...p} />)
expect(global.getByText('Build')).toBeTruthy()
fireEvent.click(global.getByText('Build'))
expect(open).toHaveBeenCalledTimes(1)
const scoped = render(<ScopedBashRow {...p} />)
expect(scoped.getByText('scoped')).toBeTruthy()
it('summarizes the command and hands clicks to openDetails on both arms', () => {
const openGlobal = vi.fn()
const global = render(<BashRow {...rowProps(ROOT, { openDetails: openGlobal })} />)
// Two renders share document.body: query inside each container.
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
expect(globalRow.textContent).toContain('Build')
fireEvent.click(globalRow)
expect(openGlobal).toHaveBeenCalledTimes(1)
const openScoped = vi.fn()
const scoped = render(<BashRow {...rowProps(CHILD, { openDetails: openScoped })} />)
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
expect(scopedRow.textContent).toContain('Build')
fireEvent.click(scopedRow)
expect(openScoped).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,94 @@
// @vitest-environment jsdom
/**
* createChatStore unit account (slot terminal design §4): the declared
* actions write set, persist round-trip through the scope-suffixed key, and
* factory purity (every create() is an independent instance; the factory
* itself holds no singleton state).
*/
import { beforeEach, describe, expect, it } from 'vitest'
import { createChatStore } from '../src/client/stores.ts'
const KEY = 'dsh.conversation.chat'
beforeEach(() => {
localStorage.clear()
})
describe('createChatStore', () => {
it('init shape: empty selection/draft/view', () => {
const store = createChatStore().create()
expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
})
it('actions cover the declared write set', () => {
const store = createChatStore().create()
store.actions.select({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
expect(store.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
store.actions.select(null)
expect(store.store.getSnapshot().selection).toBeNull()
store.actions.setDraft('hello')
expect(store.store.getSnapshot().draft).toBe('hello')
store.actions.clearDraft()
expect(store.store.getSnapshot().draft).toBe('')
store.actions.setView('chat')
expect(store.store.getSnapshot().view).toBe('chat')
})
it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {
const store = createChatStore().create()
// Rollback path: draft was cleared by send, nothing typed since.
store.actions.restoreDraft('failed text')
expect(store.store.getSnapshot().draft).toBe('failed text')
// The user typed something new before the failure landed: keep theirs.
store.actions.setDraft('newer input')
store.actions.restoreDraft('stale text')
expect(store.store.getSnapshot().draft).toBe('newer input')
})
it('persists per scope key and rehydrates a fresh instance', () => {
const handle = createChatStore()
const s1 = handle.create('sess-1')
s1.actions.setDraft('draft for one')
s1.actions.select({ turnSeq: 1 })
// Scope-suffixed key: each session persists separately.
expect(localStorage.getItem(`${KEY}.sess-1`)).not.toBeNull()
expect(localStorage.getItem(`${KEY}.sess-2`)).toBeNull()
// A rebuilt instance under the same scope key rehydrates the state.
const again = createChatStore().create('sess-1')
expect(again.store.getSnapshot().draft).toBe('draft for one')
expect(again.store.getSnapshot().selection).toEqual({ turnSeq: 1 })
// A sibling scope starts clean.
const other = createChatStore().create('sess-2')
expect(other.store.getSnapshot().draft).toBe('')
})
it('clearPersisted removes the scope entry (session-death cleanup hook)', () => {
const store = createChatStore().create('sess-9')
store.actions.setDraft('doomed')
expect(localStorage.getItem(`${KEY}.sess-9`)).not.toBeNull()
store.clearPersisted()
expect(localStorage.getItem(`${KEY}.sess-9`)).toBeNull()
})
it('every create() is an independent instance; the factory holds no singleton', () => {
const handle = createChatStore()
const a = handle.create()
const b = handle.create()
a.actions.setDraft('only in a')
expect(b.store.getSnapshot().draft).toBe('')
// Two factory calls likewise share no LIVE state (identity is per handle
// VALUE, not per module — the sharing contract lives in the framework's
// handle x scope-key resolution, not in module state). Persistence is the
// one sanctioned cross-instance channel: clear it so this assertion sees
// memory identity, not rehydration (covered by the persist case above).
localStorage.clear()
const c = createChatStore().create()
expect(c.store.getSnapshot().draft).toBe('')
})
})

View File

@@ -4,11 +4,11 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
afterEach(cleanup)
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { classifyTool, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
@@ -28,6 +28,8 @@ describe('tool-call-model', () => {
expect(classifyTool('web_fetch')).toBe('read')
expect(classifyTool('web_search')).toBe('search')
expect(classifyTool('grep')).toBe('search')
expect(classifyTool('write')).toBe('write')
expect(classifyTool('edit')).toBe('edit')
expect(classifyTool('todo_write')).toBe('others')
})
@@ -48,6 +50,8 @@ describe('tool-call-model', () => {
it('keeps summaries single-line and falls back for opaque args', () => {
expect(toolRowModel('bash', running({ argsRaw: '{"command":"a\\nb"}' })).summary).toBe('a')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/tmp/x.ts"}' })).summary).toBe('/tmp/x.ts')
expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/x.ts"}' })).summary).toBe('src/x.ts')
// Others rows prefix the real tool name into the summary slot (figma-flows
// ruling: static "Tool call" title, name rides the mutable summary).
expect(toolRowModel('x', running({ argsRaw: '{"n":1}' })).summary).toBe('x · {"n":1}')
@@ -114,12 +118,28 @@ describe('ToolRow', () => {
})
})
describe('ThinkRow', () => {
it('expands from either Think or the reasoning summary', () => {
const view = render(
<AssistantMarkdown
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
/>,
)
const row = view.getByRole('button')
fireEvent.click(view.getByText('Inspect the session'))
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/Check persistence/)).toBeTruthy()
fireEvent.click(view.getByText('Think'))
expect(row.getAttribute('aria-expanded')).toBe('false')
})
})
describe('GenericToolCard', () => {
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolViewProps => ({
callId: 'c1', toolName, block,
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: (k) => k,
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
callId: 'c1', toolName, block, openDetails: vi.fn(),
})
it('renders the classified variant row from the frozen slice', () => {
@@ -138,10 +158,36 @@ describe('GenericToolCard', () => {
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
})
it('row click reaches actions.openDetails', () => {
it('renders edit with its dedicated title, icon variant, and path summary', () => {
const view = render(
<GenericToolCard {...props('edit', running({
name: 'edit',
argsRaw: '{"file_path":"src/x.ts","old_string":"before","new_string":"after"}',
}))} />,
)
expect(view.getByText('Edit')).toBeTruthy()
expect(view.getByText('src/x.ts')).toBeTruthy()
expect(view.container.querySelector('[data-variant="edit"]')).not.toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
})
it('renders write with its dedicated title, icon variant, and path summary', () => {
const view = render(
<GenericToolCard {...props('write', running({
name: 'write',
argsRaw: '{"file_path":"src/x.ts","content":"hello"}',
}))} />,
)
expect(view.getByText('Write')).toBeTruthy()
expect(view.getByText('src/x.ts')).toBeTruthy()
expect(view.container.querySelector('[data-variant="write"]')).not.toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
})
it('row click reaches openDetails', () => {
const p = props('bash', result())
const view = render(<GenericToolCard {...p} />)
fireEvent.click(view.getByText('List files'))
expect(p.actions.openDetails).toHaveBeenCalledTimes(1)
expect(p.openDetails).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,232 @@
// @vitest-environment jsdom
// The dissolved tool ring's acceptance chain on the REAL machinery stack:
// cordis Context + SlotsService ledger + the web-react renderer + this
// package's own apply — no outlet twins. Proves the keyed
// 'conversation.chat.toolview' hole end to end: registered rows dispatch by
// entryKey (the bash sample lands through its plugin), unregistered tools
// fall back to GenericToolCard at the render site, live registration/unload
// flips rows in place, duplicate keys fail loud, the inject channel feeds
// (sessionId) => I into row components, and a registrant's
// inject: ['slots', 'conversation'] load-order seam suspends on real fiber
// semantics until the service (and with it the hole declaration) is present.
import { Context } from 'cordis'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { createSnapshotStore, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const SID = 's1' as SessionId
afterEach(cleanup)
// The chat store persists under its declared key; clear between cases.
beforeEach(() => {
localStorage.clear()
})
const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({
kind: 'tool-result', seq, callId,
call: { name, argsRaw: args },
content: [], isError: false, callView: null, resultView: null,
})
function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot {
return {
sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [],
pending: [], running: false, removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null,
} as ConversationSnapshot
}
/** Test-owned AppFrame role: declares the layout-owned children and renders the conversation area under the framework session provider. */
type AppRootProps = PropsRenderSlots<'conversation' | 'details' | 'conversation.empty'>
function AppRoot({ renderSlot, SessionProvider }: AppRootProps) {
return <SessionProvider>{() => renderSlot('conversation', {})}</SessionProvider>
}
/**
* Real-stack bench: SlotsService plugin, renderer installed, sessions/layout
* fakes at the service seams only (external boundaries), the package apply on
* its own fiber, and the test AppFrame occupying 'root'.
*/
async function bench(nodes: ToolResultNode[]) {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
const session = createSnapshotStore<ConversationSnapshot>(snapshotWith(nodes))
const list = createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, title: 'S', running: false, updatedAt: 1 } },
current: SID,
} as SessionListState)
// Identity-stable cell: the renderer caches hooks per source and inject
// results per cell, both by object identity.
const cell = { sessionId: SID, session }
const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) }
const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
ctx.provide('sessions', {
list,
manager: { get: () => ({ loadOlder: vi.fn() }) },
scope: () => ({ get: () => scoped }),
cell: (id: string) => (id === SID ? cell : undefined),
create: vi.fn(),
open: vi.fn(),
})
ctx.provide('layout', layout)
ctx.provide('i18n', { bind: () => (key: string) => key })
slots.install(createSlotRenderer())
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, AppRoot)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, session, list, layout }
}
/** Render the whole tree through the ctx-level root seam (the shell's own entry). */
function mountApp(slots: SlotsService) {
return render(<>{slots.renderSlot('root', {})}</>)
}
describe('keyed toolview hole through the real machinery', () => {
it('dispatches registered rows by entryKey and unregistered tools to the GenericToolCard fallback', async () => {
const b = await bench([
toolResult(3, 'c1', 'bash'),
toolResult(4, 'c2', 'mystery', '{"n":1}'),
])
const view = mountApp(b.slots)
// bash: the sample plugin's keyed registration took the row (root
// session → global arm, decided inside the component off useSessions).
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('Build')).toBeTruthy()
// mystery: no registration under that key → render-site fallback.
expect(view.getByText('Tool call')).toBeTruthy()
})
it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => {
const b = await bench([toolResult(3, 'c1', 'bash')])
const view = mountApp(b.slots)
view.getByText('Build').click()
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
})
it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => {
const b = await bench([toolResult(3, 'c2', 'mystery', '{"n":1}')])
const view = mountApp(b.slots)
expect(view.getByText('Tool call')).toBeTruthy()
let dispose = (): void => {}
await act(async () => {
dispose = b.slots.register(
{ name: 'conversation.chat.toolview', key: 'mystery' },
() => <div data-testid="mystery-row" />)
})
// Per-key version tick: the row flipped without a remount of the view.
expect(view.getByTestId('mystery-row')).toBeTruthy()
expect(view.queryByText('Tool call')).toBeNull()
await act(async () => { dispose() })
expect(view.queryByTestId('mystery-row')).toBeNull()
expect(view.getByText('Tool call')).toBeTruthy()
})
it('a duplicate key registration fails loud at load', async () => {
const b = await bench([])
// The bash sample already holds the 'bash' key (later-wins retired with
// the ring — the keyed ledger throws instead).
expect(() => b.slots.register(
{ name: 'conversation.chat.toolview', key: 'bash' },
() => null,
)).toThrow(/key "bash"/)
})
it('the inject channel feeds (sessionId) => I into the row component', async () => {
const b = await bench([toolResult(3, 'c3', 'probe', '{"x":1}')])
const poked: string[] = []
b.slots.register({
name: 'conversation.chat.toolview',
key: 'probe',
// Two-way business face: data derived from the session id out, a
// callback closing over it back in — the askuser-pattern inject shape.
inject: (sessionId: SessionId) => ({
mark: `for:${sessionId}`,
poke: () => { poked.push(sessionId) },
}),
}, ({ mark, poke }: ToolRowProps & { mark: string; poke: () => void }) => (
<button data-testid="probe-row" onClick={poke}>{mark}</button>
))
const view = mountApp(b.slots)
const row = view.getByTestId('probe-row')
expect(row.textContent).toBe(`for:${SID}`)
row.click()
expect(poked).toEqual([SID])
})
})
describe('registrant load-order seam', () => {
it("suspends a registrant on inject: ['slots', 'conversation'] until the service (and the hole) exists", async () => {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const slots = ctx.get('slots') as SlotsService
ctx.provide('sessions', {
list: createSnapshotStore<SessionListState>({ ids: [], byId: {}, current: undefined } as SessionListState),
manager: { get: vi.fn() },
scope: () => undefined,
cell: () => undefined,
create: vi.fn(),
open: vi.fn(),
})
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('i18n', { bind: () => (key: string) => key })
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
'conversation.empty': { kind: 'single', scope: 'root' },
},
}, AppRoot)
// Third-party posture, mounted BEFORE ui-conversation: real fiber inject
// semantics hold it — apply must not run while 'conversation' is absent.
// (Plain arrow, not vi.fn: mock functions carry a prototype and trip the
// fiber's isConstructor branch.)
let applyRuns = 0
const registrantApply = (registrantCtx: Context): void => {
applyRuns += 1
registrantCtx.slots.register(
{ name: 'conversation.chat.toolview', key: 'late' }, () => null)
}
const late = ctx.plugin({
name: 'late-registrant',
inject: ['slots', 'conversation'],
apply: registrantApply,
})
await Promise.resolve()
expect(applyRuns).toBe(0)
// Mounting the package resolves the seam: service present ⟹ the chat
// entry (and its hole declaration) is already on the ledger, so the
// suspended registrant lands without an undeclared-slot throw.
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await late.await()
expect(applyRuns).toBe(1)
expect(slots.entries('conversation.chat.toolview').map(e => e.options.key))
.toEqual(expect.arrayContaining(['bash', 'late']))
})
})

View File

@@ -3,20 +3,25 @@
// toolview dispatch and selection handoff — driven through a scripted
// ObservableSnapshot fake, no wire.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Profiler } from 'react'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode, UserMessageNode,
AssistantMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConvViewProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatView } from '../src/client/chat/ChatView.tsx'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
// Keyless create() persists under the bare declared key; clear between cases
// so one harness's selection cannot rehydrate into the next.
beforeEach(() => {
localStorage.clear()
})
const SID = 's1' as SessionId
@@ -62,39 +67,41 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, callView: null,
})
function makeHarness(init?: Partial<ConversationSnapshot>) {
const { set, source } = makeSource(init)
const registry = new ToolViewRegistry()
const ChatView = createChatView({ toolviews: registry, t: (k) => k })
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const loadOlder = vi.fn()
const selection = makeSelection()
const props: ConvViewProps = {
sessionId: SID,
useSession: bindSnapshotSelector(source) as unknown as UseSession,
useSelection: bindSnapshotSelector(selection.source),
actions: { openDetails, loadOlder },
slots: { renderSlot: () => null } as never,
}
return { set, registry, ChatView, props, openDetails, loadOlder, setSelection: selection.set }
/** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
return bindSnapshotSelector(store)
}
function makeSelection() {
let sel: SelectionTarget | null = null
const subs = new Set<() => void>()
return {
set(next: SelectionTarget | null) {
sel = next
for (const fn of [...subs]) fn()
},
source: {
getSnapshot: () => sel,
subscribe: (fn: () => void) => {
subs.add(fn)
return () => subs.delete(fn)
},
},
function makeHarness(init?: Partial<ConversationSnapshot>) {
const { set, source } = makeSource(init)
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const loadOlder = vi.fn()
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the PropsStore useStore share).
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
// every tool lands on GenericToolCard); keyed dispatch to registered rows
// is the slot machinery's behavior, covered by its own specs.
const chat = createChatStore().create()
const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot']
// SessionProvider seat arrives with the session-scope child declaration;
// ChatView never invokes it (render-prop pass-through stub).
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
const props: ChatViewSlotProps = {
sessionId: SID,
useSession: bindSnapshotSelector(source),
useSessions: emptySessions(),
useStore: bindSnapshotSelector(chat),
actions: chat.actions,
renderSlot,
SessionProvider: SessionProviderStub,
openDetails,
loadOlder,
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, ChatView, props, openDetails, loadOlder, setSelection }
}
describe('chat-flow derivation', () => {
@@ -179,11 +186,13 @@ describe('ChatView', () => {
const h = makeHarness({
nodes: [user(1, 'q'), assistant(2, 'old'), toolResult(3, 'a')],
})
// Count renderSlot invocations: the memo boundary holds when CallRow does
// not re-render, so the row's renderSlot call count freezes during chunks.
let rowRenders = 0
h.registry.register('bash', () => {
h.props.renderSlot = (((_key: string, _owner: object) => {
rowRenders += 1
return <div data-testid="counting-row" />
})
}) as unknown as ChatViewSlotProps['renderSlot'])
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('counting-row')).toBeTruthy()
const afterMount = rowRenders
@@ -221,21 +230,19 @@ describe('ChatView', () => {
expect(view.getByText('cmd-r1')).toBeTruthy()
})
it('a scoped toolview registration takes over rendering for its session only', () => {
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
h.registry.register('bash', () => <div data-testid="custom-bash" />, { scope: (id) => id === SID })
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('custom-bash')).toBeTruthy()
})
it('unregistering a toolview falls back to the generic row live', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const off = h.registry.register('bash', () => <div data-testid="custom-bash" />)
const view = render(<h.ChatView {...h.props} />)
expect(view.getByTestId('custom-bash')).toBeTruthy()
act(() => off())
expect(view.queryByTestId('custom-bash')).toBeNull()
expect(view.getByText('Bash')).toBeTruthy()
const calls: { key: string; entryKey?: string }[] = []
h.props.renderSlot = (((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
return opts?.fallback ?? null
}) as unknown as ChatViewSlotProps['renderSlot'])
render(<h.ChatView {...h.props} />)
// Keyed dispatch: slot name is the declared hole, entryKey the wire tool
// name, and the fallback (GenericToolCard) renders on an empty ledger.
// (Registered-row takeover and live unload are slot machinery behavior,
// owned by the slot system's own specs.)
expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }])
})
it('prepend compensates scrollTop by the height delta; a trailing user node force-scrolls', () => {

View File

@@ -1,23 +1,21 @@
// @vitest-environment jsdom
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
// PendingCard question arm, bash sample error pill, registry disposer
// idempotence re-entry, register.ts explicit bashSampleScope override, the
// node-half empty apply, and AssistantMarkdown reasoning/unknown block arms.
// PendingCard question arm, bash sample error pill, the node-half empty
// apply, and AssistantMarkdown reasoning/unknown block arms.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { RpcId } from '@deepseek-ai/dsh-client-connection/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationService, Translate, ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply as nodeApply } from '../src/index.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { PendingCard } from '../src/client/chat/PendingCard.tsx'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
import { registerChat } from '../src/client/chat/register.ts'
afterEach(cleanup)
@@ -67,11 +65,8 @@ describe('tails', () => {
call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
content: [], isError: false, callView: null, resultView: null,
}
const props: ToolViewProps = {
callId: 'c5', toolName: 'todo_write', block: settled,
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: ((k: string) => k) as Translate,
const props: ToolRowOwnerProps = {
callId: 'c5', toolName: 'todo_write', block: settled, openDetails: vi.fn(),
}
const view = render(<GenericToolCard {...props} />)
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
@@ -79,49 +74,25 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('BashRow shows the failed pill on error results', () => {
it('BashRow shows the failed pill on error results (root session arm)', () => {
const errorResult: ToolResultNode = {
kind: 'tool-result', seq: 1, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
content: [], isError: true, callView: null, resultView: null,
}
const props: ToolViewProps = {
callId: 'c1', toolName: 'bash', block: errorResult,
useSession: (() => { throw new Error('unused') }) as unknown as UseSession,
actions: { openDetails: vi.fn() },
t: ((k: string) => k) as Translate,
}
// Root session (no parentId): the global arm renders, error pill visible.
const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({
ids: [sid],
byId: { [sid]: { id: sid, title: 'r', running: false, updatedAt: 0 } },
current: undefined,
} as SessionListState)
const props = {
callId: 'c1', toolName: 'bash', block: errorResult, openDetails: vi.fn(),
sessionId: sid, useSessions: bindSnapshotSelector(list),
} as unknown as ToolRowProps
const view = render(<BashRow {...props} />)
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
expect(view.getByText('failed')).toBeTruthy()
})
it('registry disposer re-entry is a no-op after the entry was already removed', () => {
const registry = new ToolViewRegistry()
const off = registry.register('bash', (() => null) as never)
const v1 = registry.getVersion()
off()
const v2 = registry.getVersion()
off()
expect(registry.getVersion()).toBe(v2)
expect(v2).toBeGreaterThan(v1)
})
it('registerChat registers the chat view with the stats footer and disposes cleanly', () => {
const disposer = vi.fn()
const calls: unknown[] = []
const conversation = {
registerView: (entry: unknown) => {
calls.push(entry)
return disposer
},
} as unknown as ConversationService
const toolviews = new ToolViewRegistry()
const off = registerChat({ conversation, toolviews, t: ((k: string) => k) as Translate })
const entry = calls[0] as { id: string; chrome?: { footer?: unknown } }
expect(entry.id).toBe('chat')
// footer is a memo exotic component (object, not plain function).
expect(entry.chrome?.footer).toBeDefined()
off()
expect(disposer).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,23 +1,21 @@
// @vitest-environment jsdom
// Final branch tails for the coverage gate, post slot-phase-2: apply's need()
// throw + cwd cache hit/empty-cwd skip, AssistantMarkdown non-final reasoning,
// StatsLine usage-less node, ChatView tool-group selected passthrough +
// running-empty guard, DetailsPanel titleless selection, registry disposer
// after a foreign removal emptied the list.
// Final branch tails for the coverage gate, terminal slot form:
// AssistantMarkdown non-final reasoning, StatsLine usage-less node,
// DetailsPanel titleless selection. (The old cwd WeakMap-cache account
// retired with the mechanism — derivation lives in EmptyState now, covered
// by the skeleton specs.)
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { Context } from 'cordis'
import { createSnapshotStore, bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject, ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createChatStore } from '../src/client/stores.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
afterEach(cleanup)
@@ -31,53 +29,6 @@ function snapshotBase(): ConversationSnapshot {
} as ConversationSnapshot
}
describe('apply need() and cwd cache', () => {
it('apply fails loud when a required service is absent', () => {
// Call apply directly (no fiber machinery): need('sessions') on a bare
// context throws synchronously — the loud-failure branch without the
// fiber runner's internal rejection surface. Mount semantics (inject
// gating) are covered by the full bench in apply-inject.spec.
void inject
const ctx = new Context()
expect(() => { (apply as (c: Context) => void)(ctx) }).toThrow(/sessions service unavailable/)
})
it('cwd derivation caches per list state and skips empty cwd values', async () => {
const ctx = new Context()
const slotsFiber = ctx.plugin(SlotsService)
await slotsFiber.await()
const listStore = createSnapshotStore<SessionListState>({
ids: [SID, 'x2' as SessionId, 'x3' as SessionId],
byId: {
[SID]: { id: SID, title: 'a', displayTitle: 'a', cwd: '/proj', running: false, updatedAt: 1 },
['x2' as SessionId]: { id: 'x2' as SessionId, title: 'b', displayTitle: 'b', cwd: '', running: false, updatedAt: 1 },
['x3' as SessionId]: { id: 'x3' as SessionId, title: 'c', displayTitle: 'c', running: false, updatedAt: 1 },
},
})
ctx.provide('sessions', { list: listStore, manager: { get: vi.fn() }, ancestry: () => [], scope: () => undefined, create: vi.fn() })
ctx.provide('layout', { current: createSnapshotStore<{ viewFor: Record<string, string> }>({ viewFor: {} }), open: vi.fn(), openView: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn() })
ctx.provide('i18n', { bind: () => (k: string) => k })
const slots = ctx.get('slots') as SlotsService
slots.define('conversation', { kind: 'single', scope: 'session' })
slots.define('details', { kind: 'single', scope: 'session' })
slots.define('conversation.empty', { kind: 'single', scope: 'root' })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const entry = slots.entries('conversation.empty')[0]! as unknown as {
options: { inject: (b: unknown) => { useCwds: (sel: (s: readonly string[]) => readonly string[]) => readonly string[] } }
}
const injected = entry.options.inject({ ctx })
const Probe = () => {
const cwds = injected.useCwds(s => s)
const again = injected.useCwds(s => s)
// Cache hit: same state object yields the same derived array reference.
return <i data-testid="cwds">{`${cwds.join(',')}|${String(cwds === again)}`}</i>
}
const view = render(<Probe />)
expect(view.getByTestId('cwds').textContent).toBe('/proj|true')
})
})
describe('render branch tails', () => {
it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => {
const view = render(
@@ -101,7 +52,7 @@ describe('render branch tails', () => {
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine sessionId={SID} useSession={bindSnapshotSelector(source) as unknown as UseSession} />,
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
)
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
})
@@ -114,27 +65,23 @@ describe('render branch tails', () => {
})
it('DetailsPanel title falls to 详情 when the selection has no toolName and no material', () => {
const SEL: SelectionTarget = { turnSeq: 1, callId: 'ghost' }
localStorage.clear()
const snap = snapshotBase()
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
const emptyList = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined } as SessionListState)
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => snapshotBase(), subscribe: () => () => {} }) as unknown as UseSession}
useSelection={bindSnapshotSelector({ getSnapshot: () => SEL, subscribe: () => () => {} })}
actions={{ closeDetails: vi.fn() }}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSessions={bindSnapshotSelector(emptyList)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
expect(view.getByText('详情')).toBeTruthy()
expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
})
it('registry disposer tolerates the list already emptied by a sibling disposer', () => {
const registry = new ToolViewRegistry()
const offA = registry.register('bash', () => null)
const offB = registry.register('bash', () => null)
offA()
offB()
// Both entries gone; a re-register works from a fresh list.
registry.register('bash', () => null)
expect(registry.resolve('bash', SID)).toBeDefined()
})
})

View File

@@ -0,0 +1,26 @@
/**
* Test-local selector-hook binder: the engine carries no hook since the store
* migration (runtime is React-free); the renderer binds in production, specs
* bind here. Delegates to web-react's bindSnapshotSelector SOURCE (same
* with-selector uSES shim as production, so selector-level render economics —
* a top-level snapshot swap with an unchanged slice does NOT re-render — hold
* in Profiler-count specs). Source-relative import: the package dependency
* edge to web-react is gone (store migration §7); tests reach the sibling
* package the same way they reach their own src internals.
*/
import { bindSnapshotSelector } from '../../web-react/src/bind.ts'
/** Minimal observable source (engine stores and scripted fakes both satisfy it). */
export interface HookSource<T> {
getSnapshot(): T
subscribe(fn: () => void): () => void
}
/**
* Bind a selector hook over a snapshot source.
* @param src - the source.
* @returns a SnapshotSelectorHook-shaped hook.
*/
export function hookOf<T>(src: HookSource<T>) {
return bindSnapshotSelector<T>(src)
}

View File

@@ -1,16 +1,19 @@
// @vitest-environment jsdom
/**
* M1a regression pin: the per-scope selection must survive list refreshes.
* Drives the REAL SessionsService + ConversationService chain over the
* programmable wire fake — a late list refresh that upgrades the display
* title (bare id → cwd basename) and a reconnect-driven refreshList+resync
* must neither recreate the session scope nor clear the selection account.
* Selection survival across the store seat (terminal design §4): the chat
* store now carries what the per-scope selection account used to — this pins
* the same behavior contract in the new mechanism. Drives the REAL
* SlotsService store axis with the shared createChatStore handle (the exact
* apply.ts shape: one handle, two session-slot registrations): same session's
* two slots resolve one instance (conversation writes, details reads);
* sessions are isolated; a session's death buries its instance AND its
* persisted draft; a list refresh does not touch instance identity.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { beforeEach, describe, expect, it } from 'vitest'
import { SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { createChatStore } from '../src/client/stores.ts'
// The runtime package's programmable fake lives in its tests; import through
// the src path (same pattern the runtime specs use — test-support material).
@@ -22,15 +25,31 @@ interface Bench {
ctx: Context
api: FakeApiClient
sessions: SessionsService
conversation: ConversationService
slots: SlotsService
chat: ReturnType<typeof createChatStore>
}
function bench(): Bench {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const conversation = new ConversationService(ctx)
return { ctx, api, sessions, conversation }
// Service self-registers as ctx 'slots' (cordis Service constructor).
const slots = new SlotsService(ctx)
const chat = createChatStore()
// The apply.ts shape: one shared handle across both session-slot
// registrations. 'conversation'/'details' must first exist in the ledger —
// register a root occupant declaring them (the AppFrame role; the stand-in
// consumes renderSlot to satisfy the declare-means-render check).
slots.register({
name: 'root',
children: {
'conversation': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
},
}, (_p: { renderSlot?: unknown }) => null)
slots.register({ name: 'conversation', store: chat }, () => null)
slots.register({ name: 'details', store: chat }, () => null)
return { ctx, api, sessions, slots, chat }
}
async function flush(): Promise<void> {
@@ -48,8 +67,63 @@ function feed(b: Bench, rows: { id: string; cwd?: string; running?: boolean }[])
}) as never)
}
describe('selection survives list refreshes (M1a)', () => {
it('create → select → display-title-upgrading refresh keeps scope, binding, store and value', async () => {
/** Resolve the store instance the renderer would hand a slot's component for a session. */
function storeFor(b: Bench, slot: 'conversation' | 'details', sessionId: SessionId) {
const host = renderHost(b)
const entry = host.entriesOf(slot)[0]!
return host.storeOf(entry, sessionId)! as ReturnType<ReturnType<typeof createChatStore>['create']>
}
/** The host face is only built at renderSlot time; install a stub renderer once to reach it. */
function renderHost(b: Bench): import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost {
const captured = (b as unknown as { _host?: import('@deepseek-ai/dsh-client-ui-slots').SlotRendererHost })
if (captured._host === undefined) {
b.slots.install({
renderRoot: (host) => {
captured._host = host
return null
},
})
b.slots.renderSlot('root', {})
}
return captured._host!
}
beforeEach(() => {
localStorage.clear()
})
describe('selection survives on the store seat', () => {
it('one session, two slots: conversation writes, details reads the SAME instance', async () => {
const b = bench()
feed(b, [{ id: 's1' }])
await b.sessions.manager.refreshList()
await flush()
const conv = storeFor(b, 'conversation', sid('s1'))
const details = storeFor(b, 'details', sid('s1'))
conv.actions.select({ turnSeq: 3, callId: 'c1' })
expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
// Identity, not just value: the shared handle resolves one instance per scope key.
expect(details).toBe(conv)
})
it('sessions are isolated: s2 selection never bleeds into s1', async () => {
const b = bench()
feed(b, [{ id: 's1' }, { id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
const one = storeFor(b, 'conversation', sid('s1'))
const two = storeFor(b, 'conversation', sid('s2'))
expect(two).not.toBe(one)
one.actions.select({ turnSeq: 1, callId: 'a' })
two.actions.select({ turnSeq: 9, callId: 'z' })
expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' })
expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
})
it('a display-title-upgrading list refresh keeps instance identity and the selection value', async () => {
const b = bench()
// First-send shape: client-side create inserts the row without cwd (title = bare id).
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s1') }))
@@ -58,11 +132,9 @@ describe('selection survives list refreshes (M1a)', () => {
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 's1' })
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
const binding = b.sessions.binding(id)
expect(binding).toBeDefined()
const scoped = b.sessions.scope(id)!
const store = (scoped.get('conversation') as ConversationService).selection
store.set({ turnSeq: 3, callId: 'c1' })
const store = storeFor(b, 'conversation', id)
store.actions.select({ turnSeq: 3, callId: 'c1' })
store.actions.setDraft('half-typed')
// The late list refresh lands (host knows the cwd → better fallback label).
feed(b, [{ id: 's1', cwd: '/w/proj-a' }])
@@ -71,53 +143,40 @@ describe('selection survives list refreshes (M1a)', () => {
expect(b.sessions.list.getSnapshot().byId[id]).toMatchObject({ displayTitle: 'proj-a' })
expect(b.sessions.list.getSnapshot().byId[id]?.title).toBeUndefined()
// Scope, binding and the selection account must all be identity-stable.
expect(b.sessions.scope(id)).toBe(scoped)
expect(b.sessions.binding(id)).toBe(binding)
const after = (b.sessions.scope(id)!.get('conversation') as ConversationService).selection
const after = storeFor(b, 'conversation', id)
expect(after).toBe(store)
expect(after.getSnapshot()).toEqual({ turnSeq: 3, callId: 'c1' })
expect(after.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
expect(after.store.getSnapshot().draft).toBe('half-typed')
})
it('reconnect (handleConnected: refreshList + resync) keeps the selection account', async () => {
it('session death buries the instance and its persisted draft', async () => {
const b = bench()
feed(b, [{ id: 's1' }])
feed(b, [{ id: 's1' }, { id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
const scoped = b.sessions.scope(sid('s1'))!
const store = (scoped.get('conversation') as ConversationService).selection
store.set({ turnSeq: 1, callId: 'c9' })
// Mint the scope (store prune rides the scope-teardown axis: no scope,
// no teardown — the real page always resolves the binding to render).
b.sessions.binding(sid('s1'))
const doomed = storeFor(b, 'conversation', sid('s1'))
doomed.actions.setDraft('to be buried')
doomed.actions.select({ turnSeq: 1 })
expect(localStorage.getItem('dsh.conversation.chat.s1')).not.toBeNull()
// Reconnect generation: display-title fallback upgrade arrives with the re-pull.
feed(b, [{ id: 's1', cwd: '/w/proj-a', running: true }])
b.sessions.manager.handleConnected()
await flush()
await flush()
expect(b.sessions.scope(sid('s1'))).toBe(scoped)
const after = (b.sessions.scope(sid('s1'))!.get('conversation') as ConversationService).selection
expect(after).toBe(store)
expect(after.getSnapshot()).toEqual({ turnSeq: 1, callId: 'c9' })
})
it('a transiently failing list refresh does not prune live scopes', async () => {
const b = bench()
feed(b, [{ id: 's1' }])
// Watch elsewhere so s1's scope teardown is not deferred, then remove it.
b.sessions.binding(sid('s2'))
feed(b, [{ id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
const scoped = b.sessions.scope(sid('s1'))!
const store = (scoped.get('conversation') as ConversationService).selection
store.set({ turnSeq: 2, callId: 'c2' })
// Wire hiccup: the reconnect-time list RPC throws (transport error).
b.api.onList = () => Promise.reject(new Error('boom'))
b.sessions.manager.handleConnected()
// Persisted residue is gone with the session...
expect(localStorage.getItem('dsh.conversation.chat.s1')).toBeNull()
// ...and a re-created same-id session starts from a FRESH instance.
feed(b, [{ id: 's1' }, { id: 's2' }])
await b.sessions.manager.refreshList()
await flush()
await flush()
expect(b.sessions.scope(sid('s1'))).toBe(scoped)
expect((b.sessions.scope(sid('s1'))!.get('conversation') as ConversationService).selection.getSnapshot())
.toEqual({ turnSeq: 2, callId: 'c2' })
const reborn = storeFor(b, 'conversation', sid('s1'))
expect(reborn).not.toBe(doomed)
expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
})
})

View File

@@ -1,9 +1,11 @@
// @vitest-environment jsdom
/**
* ConversationService orchestration half: scope-addressed send/cancel (result
* folding, root throw), openDetails choreography, the startSession chain, and
* the service-unavailable loud failures. Store semantics live in
* service-stores.spec.ts.
* ConversationService orchestration half after the store-seat slimming:
* scope-addressed send/cancel (result folding, root throw), the startSession
* chain (create → sessions.open → scoped send), and the service-unavailable
* loud failures. Selection/draft state left this service for the declared
* chat store (chat-store.spec.ts / selection-survival.spec.ts); the view
* registry left for the 'conversation.view' slot (views-type-chain.spec.tsx).
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
@@ -13,7 +15,7 @@ import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/cli
const sid = (s: string): SessionId => s as SessionId
/** Recover the module-private scope tag through the public seam (same probe as service-stores.spec). */
/** Recover the module-private scope tag through the public seam (same probe as apply-inject.spec). */
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
@@ -33,7 +35,7 @@ interface SessionDouble {
cancel: ReturnType<typeof vi.fn>
}
async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
async function bench(opts?: { sessions?: boolean }) {
const ctx = new Context()
const sessionDoubles = new Map<SessionId, SessionDouble>()
const scopes = new Map<SessionId, Context>()
@@ -47,6 +49,7 @@ async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
return scoped
}
const createMock = vi.fn(() => Promise.resolve(sid('new-1')))
const openMock = vi.fn()
const sessionsFake = {
manager: {
get: (id: SessionId) => {
@@ -62,16 +65,16 @@ async function bench(opts?: { layout?: boolean; sessions?: boolean }) {
},
},
create: createMock,
open: openMock,
scope: (id: SessionId) => (id === sid('new-1') ? mint(id) : scopes.get(id)),
} as unknown as SessionsService
if (opts?.sessions !== false) ctx.provide('sessions', sessionsFake)
const layoutFake = { open: vi.fn(), openDetails: vi.fn() }
if (opts?.layout !== false) ctx.provide('layout', layoutFake)
const fiber = ctx.plugin((pluginCtx) => { void new ConversationService(pluginCtx) })
// Class-plugin mount — the same form apply.ts uses in production.
const fiber = ctx.plugin(ConversationService)
await fiber.await()
const svc = ctx.get('conversation') as ConversationService
const scopedSvc = (id: SessionId) => mint(id).get('conversation') as ConversationService
return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, createMock, layoutFake }
return { ctx, svc, scopedSvc, mint, sessionDoubles, sessionsFake, createMock, openMock }
}
describe('send / cancel', () => {
@@ -109,22 +112,12 @@ describe('send / cancel', () => {
})
})
describe('openDetails', () => {
it('writes the scoped selection then opens the layout panel', async () => {
const b = await bench()
const s = b.scopedSvc(sid('s1'))
s.openDetails({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
expect(s.selection.getSnapshot()).toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
})
})
describe('startSession chain', () => {
it('creates, navigates, then sends through the new scope', async () => {
it('creates, navigates through sessions.open, then sends through the new scope', async () => {
const b = await bench()
await b.svc.startSession({ cwd: '/proj', text: 'first', mode: 'queue' })
expect(b.createMock).toHaveBeenCalledWith({ cwd: '/proj' })
expect(b.layoutFake.open).toHaveBeenCalledWith(sid('new-1'))
expect(b.openMock).toHaveBeenCalledWith(sid('new-1'))
expect(b.sessionDoubles.get(sid('new-1'))!.prompt).toHaveBeenCalledWith(
[{ type: 'text', text: 'first' }], 'queue')
})
@@ -148,12 +141,6 @@ describe('service-unavailable loud failures', () => {
await expect(b.svc.startSession({ text: 't', mode: 'queue' })).rejects.toThrow(/sessions service unavailable/)
})
it('throws when layout is missing', async () => {
const b = await bench({ layout: false })
const s = b.scopedSvc(sid('s1'))
expect(() => { s.openDetails({ turnSeq: 1 }) }).toThrow(/layout service unavailable/)
})
it('startSession fails loud when the new scope cannot resolve conversation', async () => {
const b = await bench()
// A scope minted outside the service tree: scoped.get('conversation') finds nothing.
@@ -164,28 +151,3 @@ describe('service-unavailable loud failures', () => {
.rejects.toThrow(/conversation service unavailable through the new scope/)
})
})
describe('views ordering and draft persistence branches', () => {
it('orders by explicit order with undefined treated as zero (both comparator arms)', async () => {
const b = await bench()
const entry = (id: string, order?: number) => ({
id, label: id, component: () => null,
...(order !== undefined ? { order } : {}),
})
b.svc.registerView(entry('z-late', 5) as never)
b.svc.registerView(entry('default-zero') as never)
b.svc.registerView(entry('first', -1) as never)
expect(b.svc.views().map(v => v.id)).toEqual(['first', 'default-zero', 'z-late'])
})
it('draft store round-trips through localStorage and removes the key when emptied', async () => {
const b = await bench()
localStorage.setItem('dsh.conversation.draft.s9', 'restored')
const s = b.scopedSvc(sid('s9'))
expect(s.drafts.getSnapshot()).toBe('restored')
s.drafts.set('typed')
expect(localStorage.getItem('dsh.conversation.draft.s9')).toBe('typed')
s.drafts.set('')
expect(localStorage.getItem('dsh.conversation.draft.s9')).toBeNull()
})
})

View File

@@ -1,176 +0,0 @@
// @vitest-environment jsdom
/**
* ConversationService store half: scope-addressed selection/drafts accounts
* (lazy mint, per-scope isolation, root access throws, scope teardown
* collects), view registry (order, duplicate throw, effect-scoped disposal,
* uSES read face). Send/cancel/startSession orchestration live in
* service-orchestration.spec.ts.
*/
import { Context } from 'cordis'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConvViewProps, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string): SessionId => s as SessionId
/**
* The scope tag symbol is module-private to the runtime package; recover it
* through the public seam by recording which symbol scopeOf reads off a
* spying proxy (keeps this bench honest against the real tagging shape
* without dragging the full SessionsService + wire fake in here).
*/
const SCOPE_TAG: symbol = (() => {
const recorded: (string | symbol)[] = []
const spy = new Proxy(new Context(), {
get(target, prop, receiver): unknown {
recorded.push(prop)
return Reflect.get(target, prop, receiver)
},
})
void scopeOf(spy)
const symbol = recorded.find((p): p is symbol => typeof p === 'symbol')
if (symbol === undefined) throw new Error('scopeOf probe recorded no symbol read')
return symbol
})()
/** Scope bench: real cordis scope fibers tagged like SessionsService.resolve mints them. */
interface Bench {
ctx: Context
svc: ConversationService
mint: (id: SessionId) => Context
dispose: (id: SessionId) => Promise<void>
}
function bench(): Bench {
const ctx = new Context()
const fibers = new Map<SessionId, { fiber: ReturnType<Context['plugin']>; ctx: Context }>()
const mint = (id: SessionId): Context => {
let rec = fibers.get(id)
if (rec === undefined) {
const fiber = ctx.plugin(() => {})
const scoped = fiber.ctx.extend({ [SCOPE_TAG]: id })
rec = { fiber, ctx: scoped }
fibers.set(id, rec)
}
return rec.ctx
}
const dispose = async (id: SessionId): Promise<void> => {
const rec = fibers.get(id)
if (rec !== undefined) {
await rec.fiber.dispose()
fibers.delete(id)
}
}
const sessions = { scope: (id: SessionId) => fibers.get(id)?.ctx } as unknown as SessionsService
ctx.provide('sessions', sessions)
const svc = new ConversationService(ctx)
return { ctx, svc, mint, dispose }
}
/** Scoped service view: ctx.get binds the root singleton to the scoped ctx (scope addressing seam). */
function convo(scoped: Context): ConversationService {
const service = scoped.get('conversation')
if (service === undefined) throw new Error('bench: conversation unavailable')
return service
}
const viewComp = (() => null) as unknown as FC<ConvViewProps>
const entry = (id: string, order?: number): ViewEntry =>
({ id, label: id, component: viewComp, ...(order !== undefined ? { order } : {}) }) as unknown as ViewEntry
beforeEach(() => { localStorage.clear() })
describe('scope addressing of stores', () => {
it('root-context selection/drafts access throws with the addressing hint', () => {
const b = bench()
expect(() => b.svc.selection).toThrow(/requires a session scope/)
expect(() => b.svc.drafts).toThrow(/requires a session scope/)
})
it('mints one store per scope and keeps identity per session', () => {
const b = bench()
const c1 = b.mint(sid('s1'))
const c2 = b.mint(sid('s2'))
const sel1 = convo(c1).selection
const sel2 = convo(c2).selection
expect(sel1).not.toBe(sel2)
expect(convo(c1).selection).toBe(sel1)
sel1.set({ turnSeq: 3 })
expect(sel1.getSnapshot()).toEqual({ turnSeq: 3 })
expect(sel2.getSnapshot()).toBeNull()
})
it('persists drafts keyed by session id and evolves independently', async () => {
const b = bench()
const c1 = b.mint(sid('s1'))
convo(c1).drafts.set('hello')
expect(localStorage.getItem('dsh.conversation.draft.s1')).toBe('hello')
const c2 = b.mint(sid('s2'))
expect(convo(c2).drafts.getSnapshot()).toBe('')
// Re-minting after teardown rehydrates from storage; clearing removes the key.
await b.dispose(sid('s1'))
expect(convo(b.mint(sid('s1'))).drafts.getSnapshot()).toBe('hello')
convo(b.mint(sid('s1'))).drafts.set('')
expect(localStorage.getItem('dsh.conversation.draft.s1')).toBeNull()
})
it('scope fiber disposal collects the store account (fresh store on re-mint)', async () => {
const b = bench()
const c1 = b.mint(sid('s1'))
const sel = convo(c1).selection
sel.set({ turnSeq: 1 })
await b.dispose(sid('s1'))
const again = b.mint(sid('s1'))
const sel2 = convo(again).selection
expect(sel2).not.toBe(sel)
expect(sel2.getSnapshot()).toBeNull()
})
})
describe('view registry', () => {
it('orders by order (ties keep registration sequence) with a stable cache reference', () => {
const b = bench()
b.svc.registerView(entry('chat', 0))
b.svc.registerView(entry('waterfall', 2))
b.svc.registerView(entry('trajectory', 1))
const views = b.svc.views()
expect(views.map(v => v.id)).toEqual(['chat', 'trajectory', 'waterfall'])
expect(b.svc.views()).toBe(views)
})
it('duplicate id throws; disposer removes and bumps the version', () => {
const b = bench()
const fn = vi.fn()
b.svc.subscribeViews(fn)
const off = b.svc.registerView(entry('chat'))
expect(() => b.svc.registerView(entry('chat'))).toThrow(/already registered/)
const v1 = b.svc.viewsVersion()
off()
expect(b.svc.viewsVersion()).toBeGreaterThan(v1)
expect(b.svc.views()).toEqual([])
expect(fn).toHaveBeenCalled()
})
it('unsubscribe stops notifications', () => {
const b = bench()
const fn = vi.fn()
const unsub = b.svc.subscribeViews(fn)
unsub()
b.svc.registerView(entry('chat'))
expect(fn).not.toHaveBeenCalled()
})
it('a registering plugin fiber unloading collects its views (effect cascade)', async () => {
const b = bench()
const fiber = b.ctx.plugin((pluginCtx: Context) => {
convo(pluginCtx).registerView(entry('chat'))
})
await fiber.await()
expect(b.svc.views().map(v => v.id)).toEqual(['chat'])
await fiber.dispose()
expect(b.svc.views()).toEqual([])
})
})

View File

@@ -1,16 +1,22 @@
// @vitest-environment jsdom
// Skeleton branch tails for the coverage gate (complements skeleton.spec.tsx
// acceptance flows): breadcrumb ancestry rendering + error strip in
// ConversationRoot, DetailsPanel non-JSON args / non-text result blocks /
// error-only results, EmptyState failure surface and custom-directory swap.
// acceptance flows), four-share props form: breadcrumb ancestry derivation +
// error strip in ConversationRoot, DetailsPanel non-JSON args / non-text
// result blocks / error-only results over the shared store, EmptyState
// failure surface and custom-directory swap with in-component cwd derivation.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { ConversationRoot, DetailsPanel, EmptyState } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { hookOf } from './hook.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
// Export discipline: packages/client/AGENTS.md.
import { createChatStore } from '../src/client/stores.ts'
import { ConversationRoot, type ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
afterEach(cleanup)
@@ -32,37 +38,54 @@ function sessionSource(over?: Partial<ConversationSnapshot>) {
}
}
const summary = (id: string, title: string): SessionSummary =>
({ id: id as SessionId, title: `durable ${title}`, displayTitle: title, running: false, updatedAt: 1 })
/** Sessions-list stub over a snapshot store (the standard useSessions hook shape). */
function listHook(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
const store = createSnapshotStore<SessionListState>({
ids: rows.map(r => r.id as SessionId),
byId: Object.fromEntries(rows.map(r => [r.id, {
id: r.id as SessionId, title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentId: r.parentId as SessionId } : {}),
}])),
current: undefined,
} as SessionListState)
return hookOf(store)
}
describe('ConversationRoot branches', () => {
const chatEntry: ViewEntry = {
id: 'chat', label: 'Chat', component: () => null,
} as unknown as ViewEntry
const chatTab: ViewTab = { id: 'chat', label: 'Chat' }
/** renderSlot stub in the outlet's baked shape (ring key + only filter marker). */
const stubRenderSlot = (() => <div data-testid="view-body" />) as unknown as ConversationRootProps['renderSlot']
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
function rootProps(over?: {
ancestry?: readonly SessionSummary[]
rows?: { id: string; title: string; parentId?: string }[]
snapshot?: Partial<ConversationSnapshot>
}) {
const open = vi.fn()
const chat = createChatStore().create()
const view = render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource(over?.snapshot)) as unknown as UseSession}
useAncestry={() => over?.ancestry ?? []}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
useActiveView={() => undefined}
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
actions={{ openView: vi.fn(), open }}
renderView={() => <div data-testid="view-body" />}
useSession={hookOf(sessionSource(over?.snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook(over?.rows ?? [])}
useStore={hookOf(chat)}
actions={chat.actions}
renderSlot={stubRenderSlot}
SessionProvider={SessionProviderStub}
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
send={vi.fn()}
stop={vi.fn()}
open={open}
/>,
)
return { view, open }
return { view, open, chat }
}
it('renders the ancestry breadcrumb with separators and navigates on ancestor click', () => {
it('derives the ancestry breadcrumb from the sessions list and navigates on ancestor click', () => {
const { view, open } = rootProps({
ancestry: [summary('root-1', 'Workspace'), summary('s1', 'Current')],
rows: [{ id: 'root-1', title: 'Workspace' }, { id: 's1', title: 'Current', parentId: 'root-1' }],
})
expect(view.getByText('Workspace')).toBeTruthy()
expect(view.getByText('/')).toBeTruthy()
@@ -73,6 +96,14 @@ describe('ConversationRoot branches', () => {
expect(open).toHaveBeenCalledTimes(1)
})
it('a broken parent link stops the ancestry walk at the known chain', () => {
const { view } = rootProps({
rows: [{ id: 's1', title: 'Orphan', parentId: 'vanished' }],
})
// The walk keeps s1 itself and stops where the parent is unknown.
expect(view.getByText('Orphan')).toBeTruthy()
})
it('falls back to the raw session id without ancestry and counts user turns', () => {
const { view } = rootProps({
snapshot: { nodes: [{ kind: 'user', seq: 1 } as never, { kind: 'assistant', seq: 2 } as never] },
@@ -88,31 +119,41 @@ describe('ConversationRoot branches', () => {
expect(view.getByText(/停止失败:halt(internal)/)).toBeTruthy()
})
it('an unknown active view id falls back to the first registered view', () => {
it('an unknown stored view id falls back to the first registered view', () => {
const { chat } = rootProps({})
cleanup()
chat.actions.setView('gone')
const view = render(
<ConversationRoot
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource()) as unknown as UseSession}
useAncestry={() => []}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
useActiveView={() => 'gone' as never}
composer={{ useDraft: () => '', setDraft: vi.fn(), send: vi.fn(), stop: vi.fn() }}
actions={{ openView: vi.fn(), open: vi.fn() }}
renderView={(entry) => <div data-testid={`body-${entry.id}`} />}
useSession={hookOf(sessionSource()) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={hookOf(chat)}
actions={chat.actions}
renderSlot={stubRenderSlot}
SessionProvider={SessionProviderStub}
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
send={vi.fn()}
stop={vi.fn()}
open={vi.fn()}
/>,
)
expect(view.getByTestId('body-chat')).toBeTruthy()
expect(view.getByTestId('view-body')).toBeTruthy()
})
})
describe('DetailsPanel branches', () => {
function panel(selection: SelectionTarget | null, snapshot?: Partial<ConversationSnapshot>) {
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
return render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector(sessionSource(snapshot)) as unknown as UseSession}
useSelection={bindSnapshotSelector({ getSnapshot: () => selection, subscribe: () => () => {} })}
actions={{ closeDetails: vi.fn() }}
useSession={hookOf(sessionSource(snapshot)) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={hookOf(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
}
@@ -139,13 +180,16 @@ describe('DetailsPanel branches', () => {
return () => subs.delete(fn)
},
}
const SEL: SelectionTarget = { turnSeq: 1, callId: 'c9' }
const chat = createChatStore().create()
chat.actions.select({ turnSeq: 1, callId: 'c9' })
const view = render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector(source) as unknown as UseSession}
useSelection={bindSnapshotSelector({ getSnapshot: () => SEL, subscribe: () => () => {} })}
actions={{ closeDetails: vi.fn() }}
useSession={hookOf(source) as unknown as UseSession<ConversationSnapshot>}
useSessions={listHook([])}
useStore={hookOf(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
expect(view.getByText(/"a": 1/)).toBeTruthy()
@@ -189,18 +233,10 @@ describe('DetailsPanel branches', () => {
})
describe('EmptyState branches', () => {
// getSnapshot must return a stable reference (uSES contract) — a fresh
// array per call loops the selector forever.
const CWDS: readonly string[] = ['/proj']
const NO_CWDS: readonly string[] = []
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
const startSession = vi.fn(() => Promise.reject(new Error('create down')))
const view = render(
<EmptyState
useCwds={bindSnapshotSelector({ getSnapshot: () => CWDS, subscribe: () => () => {} })}
actions={{ startSession }}
/>,
<EmptyState useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])} startSession={startSession} />,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'first task' } })
@@ -212,10 +248,7 @@ describe('EmptyState branches', () => {
it('non-Error rejection reasons stringify into the error strip', async () => {
const startSession = vi.fn(() => Promise.reject('plain-string'))
const view = render(
<EmptyState
useCwds={bindSnapshotSelector({ getSnapshot: () => NO_CWDS, subscribe: () => () => {} })}
actions={{ startSession }}
/>,
<EmptyState useSessions={listHook([])} startSession={startSession} />,
)
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'go' } })
@@ -223,15 +256,20 @@ describe('EmptyState branches', () => {
await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy())
})
it('cwd select picks an option, swaps to free-form on 新目录, and submits the typed path', async () => {
it('cwd derivation skips blank cwds; select picks, swaps to free-form, submits the typed path', async () => {
const startSession = vi.fn(() => Promise.resolve())
const view = render(
<EmptyState
useCwds={bindSnapshotSelector({ getSnapshot: () => CWDS, subscribe: () => () => {} })}
actions={{ startSession }}
useSessions={listHook([
{ id: 'a', title: 'a', cwd: '/proj' },
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
])}
startSession={startSession}
/>,
)
const select = view.container.querySelector('select')!
expect([...(select as HTMLSelectElement).options].map(o => o.value))
.toEqual(['', '/proj', '::new-directory'])
fireEvent.change(select, { target: { value: '/proj' } })
expect((select as HTMLSelectElement).value).toBe('/proj')
fireEvent.change(select, { target: { value: '::new-directory' } })

View File

@@ -1,25 +1,33 @@
// @vitest-environment jsdom
/**
* Skeleton acceptance: empty-state transition (same InputBar component in
* hero position, startSession submit), ConversationRoot view switching over
* the registry face, DetailsPanel open/close linkage against a layout-shaped
* fake. Components stay framework-free — everything arrives via props here,
* exactly as the inject factories will assemble them.
* Skeleton acceptance over the four-share props form: empty-state transition
* (same InputBar component in hero position, startSession submit, in-component
* cwd derivation), ConversationRoot view switching through the store's view
* field, DetailsPanel selection through the shared store. Components stay
* pure — the framework shares are stubbed (useSession/useSessions), the store
* share is a REAL createChatStore().create() instance (same construction path
* as production), injected callbacks are spies.
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import { bindSnapshotSelector, createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import {
ConversationRoot, DetailsPanel, EmptyState,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SelectionTarget, ViewEntry, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SelectionTarget, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ConversationRootProps } from '../src/client/skeleton/ConversationRoot.tsx'
// Export discipline: packages/client/AGENTS.md.
import { createChatStore } from '../src/client/stores.ts'
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { EmptyState } from '../src/client/skeleton/EmptyState.tsx'
const sid = (s: string): SessionId => s as SessionId
afterEach(cleanup)
beforeEach(() => {
localStorage.clear()
})
/** Minimal conversation snapshot slice the skeleton reads. */
interface FakeSnapshot {
@@ -34,17 +42,41 @@ function fakeSession(init: Partial<FakeSnapshot> = {}) {
const store = createSnapshotStore<FakeSnapshot>({
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init,
})
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession }
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
/** Sessions-list stub: the standard useSessions hook over a snapshot store. */
function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?: string }[]) {
const store = createSnapshotStore<SessionListState>({
ids: rows.map(r => sid(r.id)),
byId: Object.fromEntries(rows.map(r => [r.id, {
id: sid(r.id), title: `durable ${r.title}`, displayTitle: r.title, running: false, updatedAt: 1,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentId: sid(r.parentId) } : {}),
}])),
current: undefined,
} as SessionListState)
return { store, useSessions: bindSnapshotSelector(store) }
}
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
describe('EmptyState', () => {
it('submits startSession with the typed text and picked cwd; failure surfaces locally', async () => {
const cwds = createSnapshotStore<readonly string[]>(['/w/app', '/w/lib'])
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
const { useSessions } = fakeSessions([
{ id: 'a', title: 'a', cwd: '/w/app' },
{ id: 'b', title: 'b', cwd: '/w/lib' },
{ id: 'c', title: 'c', cwd: '/w/app' }, // duplicate cwd dedupes
])
let reject!: (e: Error) => void
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
render(<EmptyState useCwds={cwds.useSelector} actions={{ startSession }} />)
render(<EmptyState useSessions={useSessions} startSession={startSession} />)
fireEvent.change(screen.getByRole('combobox', { name: '项目目录' }), { target: { value: '/w/app' } })
const select = screen.getByRole('combobox', { name: '项目目录' })
expect([...(select as HTMLSelectElement).options].map(o => o.value))
.toEqual(['', '/w/app', '/w/lib', '::new-directory'])
fireEvent.change(select, { target: { value: '/w/app' } })
const box = screen.getByPlaceholderText('Message to run task, plan and build')
fireEvent.change(box, { target: { value: '造一个轮子' } })
fireEvent.keyDown(box, { key: 'Enter' })
@@ -57,8 +89,8 @@ describe('EmptyState', () => {
})
it('new-directory option swaps the select for a free-form input', () => {
const cwds = createSnapshotStore<readonly string[]>([])
render(<EmptyState useCwds={cwds.useSelector} actions={{ startSession: () => Promise.resolve() }} />)
const { useSessions } = fakeSessions([])
render(<EmptyState useSessions={useSessions} startSession={() => Promise.resolve()} />)
fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } })
const custom = screen.getByPlaceholderText(/目录路径/)
fireEvent.change(custom, { target: { value: '/tmp/fresh' } })
@@ -67,90 +99,108 @@ describe('EmptyState', () => {
})
describe('ConversationRoot', () => {
function bench(views: ViewEntry[], active?: string) {
function bench(tabs: ViewTab[], activeView?: string) {
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }] })
const activeStore = createSnapshotStore<string | undefined>(active)
const openView = vi.fn((v: string) => { activeStore.set(v) })
const open = vi.fn()
const drafts = createSnapshotStore<string>('')
const { useSessions } = fakeSessions([
{ id: 'root', title: 'proj' },
{ id: 's1', title: 'child', parentId: 'root' },
])
const chat = createChatStore().create()
if (activeView !== undefined) chat.actions.setView(activeView)
const send = vi.fn()
const stop = vi.fn()
const ancestry: SessionSummary[] = [
{ id: sid('root'), title: 'proj', displayTitle: 'proj', running: false, updatedAt: 1 },
{ id: sid('s1'), title: 'child', displayTitle: 'child', running: false, updatedAt: 1, parentId: sid('root') },
]
const rendered: string[] = []
const open = vi.fn()
// The renderSlot share as the outlet would bake it: renders a marker for
// the ring key carrying the active-id filter (a Mock cannot satisfy the
// generic method type directly — cast once at the prop seam).
const renderSlot = vi.fn((key: string, _owner: object, opts?: { only?: string }) => (
<div data-testid={`view-${opts?.only ?? '(all)'}`} data-slot={key} />
))
const ui = render(
<ConversationRoot
sessionId={sid('s1')}
useSession={useSession}
useAncestry={() => ancestry}
useSessions={useSessions}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']}
SessionProvider={SessionProviderStub}
views={{
list: () => views,
list: () => tabs,
subscribe: () => () => {},
version: () => 1,
}}
useActiveView={() => activeStore.useSelector(s => s) as ViewId | undefined}
composer={{
useDraft: () => drafts.useSelector(s => s),
setDraft: (t) => { drafts.set(t) },
send, stop,
}}
actions={{ openView: openView as (v: never) => void, open }}
renderView={(entry) => { rendered.push(entry.id); return <div data-testid={`view-${entry.id}`} /> }}
send={send}
stop={stop}
open={open}
/>)
return { ui, openView, open, rendered, send, drafts }
return { ui, chat, send, stop, open, renderSlot }
}
const comp = (() => null) as unknown as FC<never>
const view = (id: string, label: string): ViewEntry =>
({ id, label, component: comp }) as unknown as ViewEntry
const tab = (id: string, label: string): ViewTab => ({ id, label })
it('renders breadcrumb chain, meta turns, and the active view (default chat)', () => {
const { rendered, open } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
it('renders breadcrumb chain (useSessions-derived), meta turns, and the default chat view', () => {
const { open } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
expect(screen.getByText('proj')).toBeTruthy()
expect(screen.getByText('child')).toBeTruthy()
expect(screen.getByText(/2 turns/)).toBeTruthy()
expect(rendered).toEqual(['chat'])
expect(screen.getByTestId('view-chat')).toBeTruthy()
// Ancestor crumb navigates; current crumb is disabled.
fireEvent.click(screen.getByRole('button', { name: 'proj' }))
expect(open).toHaveBeenCalledWith('root')
expect((screen.getByRole('button', { name: 'child' }) as HTMLButtonElement).disabled).toBe(true)
})
it('switches views through actions.openView and re-renders the new body', () => {
const { openView } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
it('switches views through the store view field and falls back on unknown ids', () => {
const { chat } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(openView).toHaveBeenCalledWith('trajectory')
expect(chat.store.getSnapshot().view).toBe('trajectory')
expect(screen.getByTestId('view-trajectory')).toBeTruthy()
cleanup()
// A stale persisted id (its view plugin unloaded) falls to the first view.
bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')], 'ghost-view')
expect(screen.getByTestId('view-chat')).toBeTruthy()
})
it('hides the tab strip with a single view and wires the composer send', () => {
const { send } = bench([view('chat', 'Chat')])
it('renders the active view through the declared ring slot with the only filter', () => {
const { renderSlot } = bench([tab('chat', 'Chat')])
// No owner share: views take everything from the standard kit (contract).
expect(renderSlot).toHaveBeenCalledWith('conversation.view', {}, { only: 'chat' })
expect(screen.getByTestId('view-chat').getAttribute('data-slot')).toBe('conversation.view')
})
it('hides the tab strip with a single view; composer writes the store draft and sends it', () => {
const { chat, send } = bench([tab('chat', 'Chat')])
expect(screen.queryByRole('tablist')).toBeNull()
const box = screen.getByPlaceholderText(/输入消息/)
fireEvent.change(box, { target: { value: 'hi' } })
// Typing goes through actions.setDraft into the shared store.
expect(chat.store.getSnapshot().draft).toBe('hi')
fireEvent.keyDown(box, { key: 'Enter' })
expect(send).toHaveBeenCalledWith('queue')
expect(send).toHaveBeenCalledWith('hi', 'queue')
})
})
describe('DetailsPanel', () => {
function benchDetails(snapshot: Partial<FakeSnapshot>, selection: SelectionTarget | null) {
const { useSession } = fakeSession(snapshot)
const selectionStore = createSnapshotStore<SelectionTarget | null>(selection)
const { useSessions } = fakeSessions([])
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const closeDetails = vi.fn()
render(
<DetailsPanel
sessionId={sid('s1')}
useSession={useSession}
useSelection={selectionStore.useSelector}
actions={{ closeDetails }}
useSessions={useSessions}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={closeDetails}
/>)
return { closeDetails, selectionStore }
return { closeDetails, chat }
}
it('renders the selected call args and result; close fires the layout-linked action', () => {
it('renders the selected call args and result off the shared store; close fires the injected callback', () => {
const { closeDetails } = benchDetails({
nodes: [{
kind: 'tool-result', callId: 'c1',

View File

@@ -1,62 +0,0 @@
/**
* Tool-ring Entry typing (design §7): I inferred from the inject factory at
* the register site, component must accept ToolViewProps & I, and the resolve
* read face carries the erased-but-present inject. Compile-time checks via
* @ts-expect-error pairs; the runtime assertions just keep vitest happy.
*/
import { describe, expect, it } from 'vitest'
import type { FC } from 'react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string): SessionId => s as SessionId
// Positive control: component's own injected share matches the factory's product.
interface RowInjected { useMyStore: () => number }
const InjectedRowComp: FC<ToolViewProps & RowInjected> = () => null
// Plain rows take the shared props only.
const PlainRowComp: FC<ToolViewProps> = () => null
describe('tool-ring entry typing', () => {
it('register infers I from the inject factory and accepts a matching component', () => {
const reg = new ToolViewRegistry()
const off = reg.register('bash', InjectedRowComp, {
inject: () => ({ useMyStore: () => 1 }),
})
expect(reg.resolve('bash', sid('s'))?.inject).toBeDefined()
off()
})
it('injectless registration needs no options and resolves without inject', () => {
const reg = new ToolViewRegistry()
reg.register('read', PlainRowComp)
expect('inject' in (reg.resolve('read', sid('s')) ?? {})).toBe(false)
})
it('compile-time: factory product must cover the component injected share', () => {
const reg = new ToolViewRegistry()
reg.register('bash', InjectedRowComp, {
// @ts-expect-error the factory misses useMyStore, which the component requires
inject: () => ({ somethingElse: 1 }),
})
expect(true).toBe(true)
})
// Known boundary (not asserted): a component demanding an injected share CAN
// register bare — with I defaulting to `object`, FC<ToolViewProps & RowInjected>
// is structurally assignable to FC<ToolViewProps & object> (parameter
// bivariance over a wider props type). The register-site guarantee holds in
// the direction that matters: WITH an inject factory, its product must cover
// the component's share (previous case). The bare-register gap is the same
// one SlotMap's single-kind register has and is accepted by design §7.
it('compile-time: scope filter receives the branded SessionId', () => {
const reg = new ToolViewRegistry()
reg.register('bash', PlainRowComp, {
// @ts-expect-error number is not assignable to SessionId
scope: (id: number) => id > 0,
})
expect(true).toBe(true)
})
})

View File

@@ -1,101 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ToolViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
const sid = (s: string) => s as SessionId
const comp = (name: string) => {
const fc = () => null
fc.displayName = name
return fc as unknown as import('react').FC<ToolViewProps>
}
describe('ToolViewRegistry', () => {
it('resolves a global registration for any session', () => {
const reg = new ToolViewRegistry()
const bash = comp('Bash')
reg.register('bash', bash)
expect(reg.resolve('bash', sid('a'))?.component).toBe(bash)
expect(reg.resolve('bash', sid('b'))?.component).toBe(bash)
expect(reg.resolve('read', sid('a'))).toBeUndefined()
})
it('prefers a matching scope filter over the global registration', () => {
const reg = new ToolViewRegistry()
const global = comp('Global')
const swarm = comp('Swarm')
reg.register('bash', global)
reg.register('bash', swarm, { scope: id => id === sid('swarm-1') })
expect(reg.resolve('bash', sid('swarm-1'))?.component).toBe(swarm)
expect(reg.resolve('bash', sid('plain'))?.component).toBe(global)
})
it('later registration wins within the same tier, scoped and global', () => {
const reg = new ToolViewRegistry()
const s1 = comp('S1')
const s2 = comp('S2')
const g1 = comp('G1')
const g2 = comp('G2')
reg.register('bash', g1)
reg.register('bash', s1, { scope: () => true })
reg.register('bash', s2, { scope: () => true })
reg.register('bash', g2)
expect(reg.resolve('bash', sid('x'))?.component).toBe(s2)
const scopeless = new ToolViewRegistry()
scopeless.register('bash', g1)
scopeless.register('bash', g2)
expect(scopeless.resolve('bash', sid('x'))?.component).toBe(g2)
})
it('a non-matching scope filter falls through to global, then undefined', () => {
const reg = new ToolViewRegistry()
const scoped = comp('Scoped')
reg.register('bash', scoped, { scope: () => false })
expect(reg.resolve('bash', sid('x'))).toBeUndefined()
const global = comp('Global')
reg.register('bash', global)
expect(reg.resolve('bash', sid('x'))?.component).toBe(global)
})
it('disposer removes exactly its registration and is idempotent', () => {
const reg = new ToolViewRegistry()
const g = comp('G')
const s = comp('S')
const off = reg.register('bash', s, { scope: () => true })
reg.register('bash', g)
off()
off()
expect(reg.resolve('bash', sid('x'))?.component).toBe(g)
})
it('unregistering the last entry resolves undefined (GenericToolCard fallback)', () => {
const reg = new ToolViewRegistry()
const off = reg.register('bash', comp('B'))
off()
expect(reg.resolve('bash', sid('x'))).toBeUndefined()
})
it('carries the inject factory through resolve', () => {
const reg = new ToolViewRegistry()
const inject = () => ({})
reg.register('bash', comp('B'), { inject })
expect(reg.resolve('bash', sid('x'))?.inject).toBe(inject)
reg.register('read', comp('R'))
expect('inject' in reg.resolve('read', sid('x'))!).toBe(false)
})
it('notifies subscribers and bumps the version on register and dispose', () => {
const reg = new ToolViewRegistry()
const fn = vi.fn()
const unsub = reg.subscribe(fn)
const v0 = reg.getVersion()
const off = reg.register('bash', comp('B'))
expect(fn).toHaveBeenCalledTimes(1)
expect(reg.getVersion()).toBeGreaterThan(v0)
off()
expect(fn).toHaveBeenCalledTimes(2)
unsub()
reg.register('read', comp('R'))
expect(fn).toHaveBeenCalledTimes(2)
})
})

View File

@@ -1,96 +0,0 @@
// Tool-ring type-chain samples (design §9 item 5, toolviews half): the
// register→inject→resolve chain where `I` is inferred from the inject
// factory and proved against the component at the register site, plus
// expect-error duals. Tool names stay an open set (no per-tool props table —
// design §7); the strong typing under test is Entry-internal. The known
// bare-register variance edge (FC<Props & I> assignable to FC<Props & object>
// without an inject factory) is accepted by design §7 and deliberately not
// pinned here. Follows the slots-ring exemplar's shape.
import { describe, expect, it } from 'vitest'
import type { FC, ReactNode } from 'react'
import type { SessionBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolViewOptions, ToolViewProps } from '../src/client/contract/toolview.ts'
import { ToolViewRegistry } from '../src/client/toolviews/registry.ts'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
const sid = (s: string): SessionId => s as SessionId
/** Registrant's own injected share (locally declared — ownership rule). */
interface RowInjected { useRuns: () => number; actions2: { rerun: () => void } }
const InjectedRow: FC<ToolViewProps & RowInjected> = () => null
const PlainRow: FC<ToolViewProps> = () => null
describe('tool-ring type-chain negatives (compile-time; body never runs)', () => {
it('holds the negative samples as expect-error sites', () => {
const negatives = (registry: ToolViewRegistry) => {
// 1. Inject factory under-produces the component's declared share:
// I infers from the factory, and the component position then fails.
registry.register(
'bash',
// @ts-expect-error component wants actions2, which the factory never produces
InjectedRow,
{ inject: () => ({ useRuns: () => 1 }) },
)
// 2. Inject factory produces a drifted value type for a declared key
// (I infers from the component position here, so TS flags the factory).
registry.register(
'bash',
InjectedRow,
// @ts-expect-error useRuns returns string here, component wants number
{ inject: () => ({ useRuns: () => 'one', actions2: { rerun: () => {} } }) },
)
// 3. Options object drifts: scope filter with a wrong parameter shape.
const badScope: ToolViewOptions<RowInjected> = {
// @ts-expect-error scope takes a SessionId, not a numeric index
scope: (index: number) => index > 0,
}
void badScope
// 4. Component demanding props outside ToolViewProps & I (a key neither
// standard nor injected) cannot register even with a full factory.
const Overreaching: FC<ToolViewProps & RowInjected & { fromNowhere: boolean }> = () => null
registry.register(
'bash',
// @ts-expect-error fromNowhere is neither a standard prop nor produced by the factory
Overreaching,
{ inject: (): RowInjected => ({ useRuns: () => 1, actions2: { rerun: () => {} } }) },
)
return null as ReactNode
}
expect(negatives).toBeTypeOf('function')
})
})
describe('tool-ring full chain (positive dual)', () => {
it('registers with an inferred inject share, resolves by scope order, and reads the erased face back', () => {
const registry = new ToolViewRegistry()
// Registration: I inferred from the factory, component proved ⊇ ToolViewProps & I.
const disposeGlobal = registry.register('bash', InjectedRow, {
inject: (b: SessionBinding): RowInjected => ({
useRuns: () => b.sessionId.length,
actions2: { rerun: () => {} },
}),
})
const disposeScoped = registry.register('bash', PlainRow, {
scope: id => id === sid('swarm-1'),
})
// Resolve: scope match beats global; elsewhere the global row wins.
expect(registry.resolve('bash', sid('swarm-1'))?.component).toBe(PlainRow)
const global = registry.resolve('bash', sid('other'))
expect(global?.component).toBe(InjectedRow)
// Read face: I is erased to object, the factory reference survives; the
// outlet-side restoration is the budgeted cast (same boundary as slots).
const injected = (global?.inject as (b: SessionBinding) => RowInjected)(
{ sessionId: 'ab', session: { useSelector: undefined }, ctx: undefined },
)
expect(injected.useRuns()).toBe(2)
// Unknown tool → undefined (caller falls back to the generic card).
expect(registry.resolve('ghost-tool', sid('other'))).toBeUndefined()
disposeScoped()
expect(registry.resolve('bash', sid('swarm-1'))?.component).toBe(InjectedRow)
disposeGlobal()
expect(registry.resolve('bash', sid('other'))).toBeUndefined()
})
})

View File

@@ -1,100 +1,122 @@
// View-ring type-chain samples (design §9 item 5, views half): the
// register→inject→render chain composed through ConversationViewMap's
// per-view extension shapes, plus expect-error duals for each stage.
// Follows the slots-ring exemplar (ui-slots/tests/type-chain.spec.tsx):
// negatives live in a never-executed function body; the positive dual runs
// the real ConversationService view registry.
// View-ring + toolview-hole type-chain samples, slot form: both are declared
// slots, so the register→inject→render chain and its compile-time locks are
// the slot system's (ui-slots/tests/type-chain.spec.tsx owns the generic
// duals). This spec pins the package-specific surface: the SlotMap rows
// (kind/scope/owner), list- and keyed-kind registration shapes, the ChatView
// and tool-row composed-props contracts, and the runtime dual — a real
// SlotsService ledger driving registration/order/disposal the way
// ConversationRoot's tab projection consumes it.
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { FC, ReactNode } from 'react'
import type {
ChromePropsOf, ConvViewProps, ConvViewPropsOf, ViewEntry,
} from '../src/client/contract/views.ts'
import { ConversationService } from '../src/client/service.ts'
import type { ReactNode } from 'react'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatViewSlotProps, ConvViewProps, ToolRowProps } from '../src/client/contract/slots.ts'
// Test-only view keys with distinct extension shapes (merged like
// ui-trajectory does; extension fields are optional per ViewEntryDef).
declare module '../src/client/contract/views.ts' {
interface ConversationViewMap {
'vt-extended': { chromeProps: { statLabel: string }; extraProps: { density: 'compact' | 'wide' } }
'vt-plain': object
}
}
const ExtendedView: FC<ConvViewPropsOf<'vt-extended'>> = ({ density }) => (density === 'compact' ? null : null)
const ExtendedChrome: FC<ChromePropsOf<'vt-extended'>> = ({ statLabel }) => (statLabel === '' ? null : null)
const PlainView: FC<ConvViewPropsOf<'vt-plain'>> = () => null
describe('view-ring type-chain negatives (compile-time; body never runs)', () => {
describe('view-ring type negatives (compile-time; body never runs)', () => {
it('holds the negative samples as expect-error sites', () => {
const negatives = (service: ConversationService) => {
// 1. Registration: a component missing the entry's declared extraProps
// cannot register under that id (props flow from the map entry).
const NarrowComp: FC<ConvViewProps & { density: number }> = () => null
service.registerView({
id: 'vt-extended',
label: 'x',
// @ts-expect-error density has the wrong value type vs the map entry's extraProps
component: NarrowComp,
})
// 2. Registration: chrome typed for another view's chromeProps drifts.
service.registerView({
id: 'vt-plain',
label: 'x',
component: PlainView,
// @ts-expect-error vt-plain declares no statLabel chromeProps
chrome: { footer: ExtendedChrome },
})
// 3. Registration: id outside the map is rejected at the entry.
service.registerView({
// @ts-expect-error unregistered view id
id: 'vt-ghost',
label: 'x',
component: PlainView,
})
// 4. Render side: per-view props narrow — the extended view's density
// is not accessible under another id's props type.
const renderPlain = (props: ConvViewPropsOf<'vt-plain'>): ReactNode => {
// @ts-expect-error density belongs to vt-extended's extension, not vt-plain
return props.density === 'compact' ? null : null
const negatives = (slots: SlotsService) => {
// 1. List-kind registration requires the id shape field.
// @ts-expect-error missing `id` on a list-slot registration
slots.register({ name: 'conversation.view', order: 1 }, (_p: ConvViewProps) => null)
// 2. A keyed-kind shape field is rejected on the list slot.
slots.register(
// @ts-expect-error `key` belongs to keyed slots, not the list ring
{ name: 'conversation.view', id: 'x', key: 'k' },
(_p: ConvViewProps) => null)
// 3. Component props must stay within the composed contract: an
// undeclared member cannot be required.
// @ts-expect-error component demands a prop no share supplies
slots.register(
{ name: 'conversation.view', id: 'y' },
(_p: ConvViewProps & { phantom: number }) => null)
// 4. Views receive no renderSlot — the ring's entries declare no children.
const renderless = (props: ConvViewProps): ReactNode => {
// @ts-expect-error views receive no renderSlot — no sub-slot delegation
void props.renderSlot
return null
}
void renderPlain
// 5. Entry-shape drift: ViewEntry<Id> ties chrome and component to the
// SAME id — mixing ids inside one entry fails.
const mixed: ViewEntry<'vt-extended'> = {
id: 'vt-extended',
label: 'x',
component: ExtendedView,
// @ts-expect-error chrome for vt-plain cannot ride a vt-extended entry
chrome: { header: (props: ChromePropsOf<'vt-plain'> & { onlyPlain: true }) => null },
void renderless
// 5. The chat entry's face is its own: openDetails does not exist on the
// base view props (store-less riders never see it).
const baseOnly = (props: ConvViewProps): ReactNode => {
// @ts-expect-error openDetails lives on ChatViewSlotProps, not the base
void props.openDetails
return null
}
void mixed
void baseOnly
// 6. ChatViewSlotProps carries the full composition (standard kit +
// store + inject face) — a handler with a wrong signature is red.
const chatProps = (props: ChatViewSlotProps): ReactNode => {
// @ts-expect-error openDetails takes a SelectionTarget, not a string
props.openDetails('nope')
return null
}
void chatProps
// 7. Keyed hole registration requires the key shape field.
// @ts-expect-error missing `key` on a keyed-slot registration
slots.register({ name: 'conversation.chat.toolview' }, (_p: ToolRowProps) => null)
// 8. A list-kind shape field is rejected on the keyed hole.
slots.register(
// @ts-expect-error `id`/`order` belong to list slots, not the keyed hole
{ name: 'conversation.chat.toolview', key: 'k', order: 1 },
(_p: ToolRowProps) => null)
// 9. Tool-row components stay within their composed contract: the
// owner share + standard kit supply no chat-view members.
const overreaching = (props: ToolRowProps): ReactNode => {
// @ts-expect-error loadOlder lives on ChatViewSlotProps, not the row contract
void props.loadOlder
return null
}
void overreaching
// 10. Owner-share drift is red at the row component seam: block is the
// call union, not arbitrary payload.
const drifted = (props: ToolRowProps): ReactNode => {
// @ts-expect-error the block union has no `argsParsed` member
void props.block.argsParsed
return null
}
void drifted
return null as ReactNode
}
expect(negatives).toBeTypeOf('function')
})
})
describe('view-ring full chain (positive dual)', () => {
it('registers, lists, and renders through the per-view extension shapes', () => {
describe('view-ring runtime dual (real ledger)', () => {
function bench() {
const ctx = new Context()
const service = new ConversationService(ctx)
// Registration: extension-typed component + same-id chrome compose cleanly.
const dispose = service.registerView({
id: 'vt-extended',
label: '扩展视图',
order: 7,
component: ExtendedView,
chrome: { footer: ExtendedChrome },
})
const entry = service.views().find(v => v.id === 'vt-extended')
expect(entry?.label).toBe('扩展视图')
// Render surface: the listed entry's component accepts the composed props
// (base ConvViewProps + the map extension), spelled here as the same type
// the runtime hands over.
expect(typeof entry?.component).toBe('function')
expect(typeof entry?.chrome?.footer).toBe('function')
dispose()
expect(service.views().some(v => v.id === 'vt-extended')).toBe(false)
const slots = new SlotsService(ctx)
// The conversation entry's role: declare the ring (declaring is claiming).
slots.register({
name: 'root',
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
}, (_p: { renderSlot?: unknown }) => null)
return { slots }
}
it('registers, orders, projects tabs, and disposes through the slot ledger', () => {
const { slots } = bench()
const offLate = slots.register(
{ name: 'conversation.view', id: 'z-late', order: 20, label: '晚' }, () => null)
const offEarly = slots.register(
{ name: 'conversation.view', id: 'early', order: 0, label: '早' }, () => null)
// Order-sorted ledger, label fallback for a labelless rider.
const offBare = slots.register(
{ name: 'conversation.view', id: 'bare', order: 10 }, () => null)
const tabs = slots.entries('conversation.view')
.map(e => ({ id: e.options.id, label: e.options.label ?? e.options.id }))
expect(tabs).toEqual([
{ id: 'early', label: '早' },
{ id: 'bare', label: 'bare' },
{ id: 'z-late', label: '晚' },
])
// Duplicate ids fail loud at load (the ring's uniqueness contract).
expect(() => slots.register({ name: 'conversation.view', id: 'early' }, () => null))
.toThrow(/already has an entry with id "early"/)
offEarly()
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['bare', 'z-late'])
offBare()
offLate()
expect(slots.entries('conversation.view')).toHaveLength(0)
})
})

View File

@@ -1,15 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"jsx": "react-jsx",
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
],
"types": []
"outDir": "lib/types"
},
"include": [
"src"