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