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