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:
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user