The composer seat spent nearly all its life disabled: a session's composition is fixed once a turn has run. Move the choice to the new-session screen beside the workspace picker, where it still works, and let the session header report what a running session runs. The hero pick is staged rather than applied — that screen precedes the session it belongs to. It lands when a session becomes current and is still blank, which covers both the session a workspace connect creates and the blank one it reuses; riding `sessions.create` would miss the second. It is spent on first use, matching the workspace picker. Fix the durability the header field claimed but never had: `agentPreset` was declared on `SessionHeader` and dropped by the JSONL header line, the SQLite sessions row, the derived query index, and the cold list projection, so every resumed session came back composed from nothing. Add the web e2e lane that would have caught it — the one lane that mounts the shipped roster, which needed `cordis:group` in the scaffold's Loader builtins, as `mountRootInclude` already registers.
125 lines
6.6 KiB
TypeScript
125 lines
6.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
// apply wiring: the conversation service provided, the chat view registered
|
|
// as the first 'conversation.view' ring entry declaring the keyed toolview
|
|
// hole, the slot registrations land against a root entry's children
|
|
// declarations (the AppFrame role), the shared store handle rides all strict
|
|
// session entries, and the bash sample + todo row mount through declaration
|
|
// injection as keyed entries. 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 { describe, expect, it, vi } from 'vitest'
|
|
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
|
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
|
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
|
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
|
|
// The service reads its initial locale from the browser; these specs assert
|
|
// the shipped Chinese copy, so they state the browser they assume.
|
|
usePinnedBrowserLanguages('zh-CN')
|
|
|
|
const ROOT = 'root-1' as SessionId
|
|
const CHILD = 'child-1' as SessionId
|
|
|
|
async function bench() {
|
|
const runtime = await SlotTestRuntime.create()
|
|
await runtime.sessions.add({ id: ROOT, summary: { title: 'R', displayTitle: 'R' } }, { current: false })
|
|
await runtime.sessions.add(
|
|
{ id: CHILD, summary: { title: 'C', displayTitle: 'C', parentId: ROOT } }, { current: false })
|
|
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
|
const locale = new LocaleService(runtime.ctx)
|
|
runtime.provide('locale', locale)
|
|
runtime.slots.installLocale(locale)
|
|
|
|
// Declared by ui-layout's root entry in production; the test root declares
|
|
// them here so the contributions land.
|
|
await runtime.root.declare({
|
|
'conversation': { kind: 'single', scope: 'session-maybe' },
|
|
'details': { kind: 'single', scope: 'session' },
|
|
'settings.general.item': { kind: 'list', scope: 'root' },
|
|
}, (_p: { renderSlot?: unknown }) => null)
|
|
|
|
const feature = await runtime.mount({ inject: [...inject], apply })
|
|
return { runtime, feature, slots: runtime.slots }
|
|
}
|
|
|
|
/** First stored entry for a key (inject/store live directly on StoredEntry). */
|
|
function renderEntryOf(slots: Awaited<ReturnType<typeof bench>>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') {
|
|
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
|
|
}
|
|
|
|
describe('apply wiring', () => {
|
|
it('provides the conversation service', async () => {
|
|
const b = await bench()
|
|
expect(b.runtime.ctx.get('conversation')).toBeDefined()
|
|
await b.runtime.dispose()
|
|
})
|
|
|
|
it('registers the chat view as the first ring entry, declaring the keyed toolview hole', async () => {
|
|
const b = await bench()
|
|
const entries = b.slots.entries('conversation.view')
|
|
expect(entries.map(e => e.options.id)).toEqual(['chat'])
|
|
// Label is a locale thunk resolving through the zh dictionary.
|
|
expect(resolveSlotLabel(entries[0]?.options.label)).toBe('对话')
|
|
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' })
|
|
await b.runtime.dispose()
|
|
})
|
|
|
|
it('occupies the slots + the ring; session entries share one store handle', async () => {
|
|
const b = await bench()
|
|
const conversation = renderEntryOf(b.slots, 'conversation')
|
|
const conversationSession = renderEntryOf(b.slots, 'conversation.session')
|
|
const conversationHeader = renderEntryOf(b.slots, 'conversation.session.header')
|
|
const chatView = renderEntryOf(b.slots, 'conversation.view')
|
|
const details = renderEntryOf(b.slots, 'details')
|
|
expect(conversation?.inject).toBeTypeOf('function')
|
|
expect(chatView?.inject).toBeTypeOf('function')
|
|
expect(details?.inject).toBeTypeOf('function')
|
|
// The shared handle: one apply-built store value on ALL session entries
|
|
// (the session-maybe 'conversation' shell carries no store by design).
|
|
expect(conversationSession?.store).toBeDefined()
|
|
expect(conversationHeader?.store).toBe(conversationSession?.store)
|
|
expect(details?.store).toBe(conversationSession?.store)
|
|
expect(chatView?.store).toBe(conversationSession?.store)
|
|
// The hero holes ride the conversation entry's children declaration (the
|
|
// empty-state occupant is gone). Both are root-scoped: the new-session
|
|
// screen precedes the session either would belong to.
|
|
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
|
|
expect(b.slots.spec('conversation.hero.agentPreset')).toEqual({ kind: 'single', scope: 'root' })
|
|
expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter'])
|
|
await b.runtime.dispose()
|
|
})
|
|
|
|
it('mounts the tool rows as keyed entries through declaration injection', async () => {
|
|
const b = await bench()
|
|
// The actual toolview declaration activates every registrant. The
|
|
// file-mutation registrant claims both write and edit for the diff card; the
|
|
// one search row registers under both grep and glob; the web rows register
|
|
// one component under both web tool names.
|
|
const entries = b.slots.entries('conversation.chat.toolview')
|
|
expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'edit', 'write', 'grep', 'glob', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
|
|
// Stats stick with the composer (not inside ChatView).
|
|
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
|
|
await b.runtime.dispose()
|
|
})
|
|
|
|
it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => {
|
|
const b = await bench()
|
|
await b.feature.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('settings.general.item')).toHaveLength(0)
|
|
expect(b.runtime.ctx.get('conversation')).toBeUndefined()
|
|
await b.runtime.dispose()
|
|
})
|
|
})
|