refactor(gui): dissolve the tool ring into per-view keyed slots

Four rounds of structural rework on the conversation surface, converging
on one registration model for the whole client:

- Review fixes: open() leaves the inject factory (SessionsService owns
  the semantic); ConversationService mounts via ctx.plugin(); the
  bespoke view registry retires into the 'conversation.view' list slot.
- Ring alignment: createChatView factory retired (components get
  everything through checkable shares at the register call site); the
  hand-rolled t/i18n threading is deleted wholesale — a future
  framework-level i18n will supply t as a standard prop keyed by slot
  name, so no interim manual channel.
- Toolview dissolution: ToolViewRegistry / ToolViewResolver /
  ToolViewOutlet / ctx.toolviews retire. Tool rows are entries of the
  'conversation.chat.toolview' keyed slot (scope: session) declared by
  the chat entry; ToolRowOwnerProps is the unified owner payload;
  GenericToolCard becomes the call-site fallback; registrants are plain
  plugins (inject ['slots','conversation'] as the load-order seam);
  session-dimension dispatch moves into components (useSessions reads
  parentId); trajectory/waterfall gain same-shape slots the day they
  render tool rows (RendersCheck rejects empty declarations). Slot
  names mirror the composition path (<domain>.<entry>.<hole>).
- Staging follows current: cell()/binding() are pure resolution
  (render-safe); the constructor subscribes to the list store and
  followCurrent opens the event window when the current session
  changes — staging IS the open signal, business verbs are the timing,
  React render/commit is decoupled from window lifecycle. A masked
  current (projection gap) keeps the stage untouched so deferred
  teardown semantics survive reconnects.

Agent Note: .agents/notes/implemented/architecture/
2026-07-23-toolview-dissolution.md (bilingual pair) records the
decision, the four rejected alternatives, and the accepted semantic
changes; the web client architecture note and packages/client/AGENTS.md
carry the current-state narrative.

Verified: typecheck 0, duplication 0 clones (478 files), full coverage
run 6190 passed with zero threshold errors, knip 0, doc-sync 24/24,
client aggregate tsc 0, render-count checks (one commit per chunk, zero
row re-renders under streaming) green.
This commit is contained in:
imccyu
2026-07-23 17:49:47 +08:00
parent fcd9af2033
commit bbde18caff
56 files changed
+1569 -1847

No files matched your search

@@ -2,10 +2,12 @@
// 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, watch-driven open,
// 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).
// 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'
@@ -13,10 +15,10 @@ 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 { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/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 {
ConversationInjected, DetailsInjected, EmptyStateInjected,
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { createChatStore } from '../src/client/stores.ts'
@@ -103,7 +105,7 @@ async function bench() {
slots.install({ renderRoot: (h) => { host = h; return null } })
slots.renderSlot('root', {})
const hostFace = host!
const entryOf = (key: 'conversation' | 'details' | 'conversation.empty') => hostFace.entriesOf(key)[0]!
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')
@@ -112,18 +114,30 @@ async function bench() {
id, instance.actions)
return { instance, injected }
}
return { ctx, slots, hostFace, entryOf, conversationSurface, sessionFake, sessionsFake, layoutFake, mint }
/** 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, hostFace, entryOf, conversationSurface, chatViewSurface, sessionFake, sessionsFake, layoutFake, mint }
}
describe('conversation slot inject surface', () => {
it('assembles the thin surface, pulls history through the watch signal, navigates via sessions.open', async () => {
it('assembles the thin surface side-effect-free, navigates via sessions.open', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
expect(b.sessionFake.open).toHaveBeenCalledTimes(1)
// 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.open(ROOT)
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
injected.loadOlder()
// 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)
})
@@ -161,27 +175,51 @@ describe('conversation slot inject surface', () => {
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
})
it('openDetails writes the selection through the store actions and opens the panel', async () => {
it('inject fails loud when the session resolves no scope or the scope lacks the service', async () => {
const b = await bench()
const { instance, injected } = b.conversationSurface(ROOT)
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)
// 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('views read face forwards to the service registry (subscribe/version)', async () => {
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
const b = await bench()
const { injected } = b.conversationSurface(ROOT)
const before = injected.views.version()
const listener = vi.fn()
const unsub = injected.views.subscribe(listener)
const conversation = b.ctx.get('conversation') as
import('@deepseek-ai/dsh-client-ui-conversation/client').ConversationService
const off = conversation.registerView({ id: 'chat2', label: 'X', component: () => null } as never)
// 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(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()
})
})
@@ -211,4 +249,14 @@ describe('details and empty inject surfaces', () => {
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/)
})
})
@@ -1,9 +1,11 @@
// @vitest-environment jsdom
// apply wiring: services provided, chat view + footer chrome registered, the
// three slot registrations land against a root entry's children declarations
// (the AppFrame role), the shared store handle rides both session slots, and
// the bash samples resolve differentially (sub-session default scope).
// Full-chain rendering belongs to the shell e2e; this spec stops at the
// 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'
@@ -11,8 +13,7 @@ import { describe, expect, it, vi } from 'vitest'
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'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
const ROOT = 'root-1' as SessionId
const CHILD = 'child-1' as SessionId
@@ -60,62 +61,69 @@ async function bench() {
}
/** First stored entry for a key (inject/store live directly on StoredEntry). */
function renderEntryOf(slots: SlotsService, key: 'conversation' | 'details' | 'conversation.empty') {
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 the three slots; session pair shares one store handle, empty declares none', 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()
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 BOTH session entries.
// 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()
})
})
@@ -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 { hookOf } from './hook.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
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,71 +64,8 @@ describe('small branch tails', () => {
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine sessionId={SID} useSession={hookOf(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 session) and merges its props', () => {
const registry = new ToolViewRegistry()
const inject = vi.fn((sessionId: SessionId) => ({ extra: `injected:${sessionId}` }))
registry.register('bash',
(p: ToolViewProps & { extra: string }) => <div data-testid="row">{p.extra}</div>,
{ inject })
// Pure props machinery: the outlet feeds its own sessionId to the
// factory — no provider/context needed (terminal channel form).
const view = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
expect(view.getByTestId('row').textContent).toBe(`injected:${SID}`)
expect(inject).toHaveBeenCalledTimes(1)
// Remount under the SAME session: cache hit, factory not re-run.
view.unmount()
const second = render(
<ToolViewOutlet registry={registry} sessionId={SID} toolName="bash" viewProps={viewProps()} />,
)
expect(second.getByTestId('row').textContent).toBe(`injected:${SID}`)
expect(inject).toHaveBeenCalledTimes(1)
// A different session is a distinct cache key: factory runs once more.
second.unmount()
const other = render(
<ToolViewOutlet registry={registry} sessionId={'s2' as SessionId} toolName="bash" viewProps={viewProps()} />,
)
expect(other.getByTestId('row').textContent).toBe('injected:s2')
expect(inject).toHaveBeenCalledTimes(2)
})
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()
})
})
@@ -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 { hookOf } from './hook.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
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 { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
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: hookOf(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,71 +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', running: false, updatedAt: 0 },
[CHILD]: { id: CHILD, title: '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],
current: undefined,
byId: {
[root]: { id: root, title: 'r', running: false, updatedAt: 0 },
[child]: { id: child, title: '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', 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)
})
})
@@ -4,12 +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-ui-slots'
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"}',
@@ -139,11 +138,8 @@ describe('ThinkRow', () => {
})
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', () => {
@@ -188,10 +184,10 @@ describe('GenericToolCard', () => {
expect(view.container.querySelector('svg')).not.toBeNull()
})
it('row click reaches actions.openDetails', () => {
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)
})
})
@@ -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']))
})
})
@@ -7,14 +7,13 @@ 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 { hookOf } from './hook.ts'
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConvViewProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ToolViewRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
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 { createChatView } from '../src/client/chat/ChatView.tsx'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts'
afterEach(cleanup)
@@ -68,23 +67,41 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, callView: null,
})
/** 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 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()
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the ConvViewProps useStore share).
// 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 props: ConvViewProps = {
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: hookOf(source) as unknown as UseSession,
useStore: hookOf(chat),
actions: { openDetails, loadOlder },
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, registry, ChatView, props, openDetails, loadOlder, setSelection }
return { set, ChatView, props, openDetails, loadOlder, setSelection }
}
describe('chat-flow derivation', () => {
@@ -169,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
@@ -211,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', () => {
@@ -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-ui-slots'
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)
})
})
@@ -1,18 +1,16 @@
// @vitest-environment jsdom
// Final branch tails for the coverage gate, terminal slot form: apply's
// need() throw, AssistantMarkdown non-final reasoning, StatsLine usage-less
// node, DetailsPanel titleless selection, registry disposer after a foreign
// removal emptied the list. (The old cwd WeakMap-cache account retired with
// the mechanism — derivation lives in EmptyState now, covered by the
// skeleton specs.)
// 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 { hookOf } from './hook.ts'
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-ui-slots'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import { 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'
@@ -54,7 +52,7 @@ describe('render branch tails', () => {
}
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine sessionId={SID} useSession={hookOf(source) as unknown as UseSession<ConversationSnapshot>} />,
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
)
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
})
@@ -76,9 +74,9 @@ describe('render branch tails', () => {
const view = render(
<DetailsPanel
sessionId={SID}
useSession={hookOf({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSessions={hookOf(emptyList)}
useStore={hookOf(chat)}
useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} }) as unknown as UseSession<ConversationSnapshot>}
useSessions={bindSnapshotSelector(emptyList)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
@@ -86,15 +84,4 @@ describe('render branch tails', () => {
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()
})
})
@@ -2,9 +2,10 @@
/**
* ConversationService orchestration half after the store-seat slimming:
* scope-addressed send/cancel (result folding, root throw), the startSession
* chain (create → sessions.open → scoped send), views ordering, 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).
* 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'
@@ -68,7 +69,8 @@ async function bench(opts?: { sessions?: boolean }) {
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 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
@@ -149,17 +151,3 @@ describe('service-unavailable loud failures', () => {
.rejects.toThrow(/conversation service unavailable through the new scope/)
})
})
describe('views ordering', () => {
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'])
})
})
@@ -11,10 +11,10 @@ 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, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/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 } from '../src/client/skeleton/ConversationRoot.tsx'
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'
@@ -53,9 +53,11 @@ function listHook(rows: { id: string; title: string; cwd?: string; parentId?: st
}
describe('ConversationRoot branches', () => {
const chatEntry: ViewEntry = {
id: 'chat', label: 'Chat', component: () => <div data-testid="view-body" />,
} 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?: {
rows?: { id: string; title: string; parentId?: string }[]
@@ -70,11 +72,11 @@ describe('ConversationRoot branches', () => {
useSessions={listHook(over?.rows ?? [])}
useStore={hookOf(chat)}
actions={chat.actions}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
renderSlot={stubRenderSlot}
SessionProvider={SessionProviderStub}
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
loadOlder={vi.fn()}
open={open}
/>,
)
@@ -120,7 +122,7 @@ describe('ConversationRoot branches', () => {
it('an unknown stored view id falls back to the first registered view', () => {
const { chat } = rootProps({})
cleanup()
chat.actions.setView('gone' as never)
chat.actions.setView('gone')
const view = render(
<ConversationRoot
sessionId={SID}
@@ -128,11 +130,11 @@ describe('ConversationRoot branches', () => {
useSessions={listHook([])}
useStore={hookOf(chat)}
actions={chat.actions}
views={{ list: () => [chatEntry], subscribe: () => () => {}, version: () => 1 }}
renderSlot={stubRenderSlot}
SessionProvider={SessionProviderStub}
views={{ list: () => [chatTab], subscribe: () => () => {}, version: () => 1 }}
send={vi.fn()}
stop={vi.fn()}
openDetails={vi.fn()}
loadOlder={vi.fn()}
open={vi.fn()}
/>,
)
@@ -10,12 +10,12 @@
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { FC } from 'react'
import { hookOf } from './hook.ts'
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-ui-slots'
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
import type { SelectionTarget, ViewEntry } from '@deepseek-ai/dsh-client-ui-conversation/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'
@@ -42,7 +42,7 @@ function fakeSession(init: Partial<FakeSnapshot> = {}) {
const store = createSnapshotStore<FakeSnapshot>({
nodes: [], runningCalls: [], running: false, removed: false, promptError: null, ...init,
})
return { store, useSession: hookOf(store) as unknown as UseSession<ConversationSnapshot> }
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
}
/** Sessions-list stub: the standard useSessions hook over a snapshot store. */
@@ -56,9 +56,12 @@ function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?
}])),
current: undefined,
} as SessionListState)
return { store, useSessions: hookOf(store) }
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('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
const { useSessions } = fakeSessions([
@@ -96,49 +99,48 @@ describe('EmptyState', () => {
})
describe('ConversationRoot', () => {
function bench(views: ViewEntry[], activeView?: string) {
function bench(tabs: ViewTab[], activeView?: string) {
const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }] })
const { useSessions } = fakeSessions([
{ id: 'root', title: 'proj' },
{ id: 's1', title: 'child', parentId: 'root' },
])
const chat = createChatStore().create()
if (activeView !== undefined) chat.actions.setView(activeView as never)
if (activeView !== undefined) chat.actions.setView(activeView)
const send = vi.fn()
const stop = vi.fn()
const openDetails = vi.fn()
const loadOlder = vi.fn()
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}
useSessions={useSessions}
useStore={hookOf(chat)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot as unknown as ConversationRootProps['renderSlot']}
SessionProvider={SessionProviderStub}
views={{
list: () => views,
list: () => tabs,
subscribe: () => () => {},
version: () => 1,
}}
send={send}
stop={stop}
openDetails={openDetails}
loadOlder={loadOlder}
open={open}
/>)
return { ui, chat, send, stop, open }
return { ui, chat, send, stop, open, renderSlot }
}
/** View bodies record their mount via testid (renderView is in-component now). */
const view = (id: string, label: string): ViewEntry =>
({
id, label,
component: (() => <div data-testid={`view-${id}`} />) as unknown as FC<never>,
}) as unknown as ViewEntry
const tab = (id: string, label: string): ViewTab => ({ id, label })
it('renders breadcrumb chain (useSessions-derived), meta turns, and the default chat view', () => {
const { open } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
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()
@@ -150,33 +152,25 @@ describe('ConversationRoot', () => {
})
it('switches views through the store view field and falls back on unknown ids', () => {
const { chat } = bench([view('chat', 'Chat'), view('trajectory', 'Trajectory')])
const { chat } = bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')])
fireEvent.click(screen.getByRole('tab', { name: '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([view('chat', 'Chat'), view('trajectory', 'Trajectory')], 'ghost-view')
bench([tab('chat', 'Chat'), tab('trajectory', 'Trajectory')], 'ghost-view')
expect(screen.getByTestId('view-chat')).toBeTruthy()
})
it('mounts chrome header/footer around the view body', () => {
const entry = {
id: 'chat', label: 'Chat',
component: () => <div data-testid="body" />,
chrome: {
header: () => <div data-testid="hd" />,
footer: () => <div data-testid="ft" />,
},
} as unknown as ViewEntry
bench([entry])
expect(screen.getByTestId('hd')).toBeTruthy()
expect(screen.getByTestId('body')).toBeTruthy()
expect(screen.getByTestId('ft')).toBeTruthy()
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([view('chat', 'Chat')])
const { chat, send } = bench([tab('chat', 'Chat')])
expect(screen.queryByRole('tablist')).toBeNull()
const box = screen.getByPlaceholderText(/输入消息/)
fireEvent.change(box, { target: { value: 'hi' } })
@@ -199,7 +193,7 @@ describe('DetailsPanel', () => {
sessionId={sid('s1')}
useSession={useSession}
useSessions={useSessions}
useStore={hookOf(chat)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={closeDetails}
/>)
@@ -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)
})
})
@@ -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)
})
})
@@ -1,94 +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 { 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, {
// Terminal channel form: the factory receives the session id only.
inject: (sessionId: SessionId): RowInjected => ({
useRuns: () => 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 (sessionId: SessionId) => RowInjected)(sid('ab'))
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()
})
})
@@ -1,111 +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
}
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 mixed
// 6. Zero-renderSlot inference: the view ring declares no children, so
// view props carry no delegation face (the old hand-written
// ScopedSlots<never> empty surface is retired, not replaced).
const renderless = (props: ConvViewPropsOf<'vt-plain'>): ReactNode => {
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
// @ts-expect-error the legacy slots face is gone from view props
void props.slots
return 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 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)
})
})