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
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-client-ui-trajectory
|
||||
|
||||
Trajectory/Waterfall placeholder views; the pure-consumer minimal plugin exemplar (registers two views, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
|
||||
Trajectory/Waterfall placeholder views; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
// TrajectoryStatsHeader: span totals row mounted as chrome.header on both
|
||||
// placeholder views — the second chrome-attachment consumer (chat's
|
||||
// StatsLine footer is the first), proving both mount points render.
|
||||
// Subscribes to `nodes` only: chunk batches never swap that reference, so
|
||||
// the row is quiet during streaming.
|
||||
// TrajectoryStatsHeader: span totals row rendered at the top of both
|
||||
// placeholder view bodies (chrome dissolved into the views — the header is
|
||||
// part of what these views ARE, not registration metadata). Subscribes to
|
||||
// `nodes` only: chunk batches never swap that reference, so the row is quiet
|
||||
// during streaming.
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ChromeProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { deriveSpans, deriveSpanStats } from './spans.ts'
|
||||
import css from './TrajectoryStatsHeader.module.css'
|
||||
|
||||
/** Per-view chrome extension (the view map entry's chromeProps slot). */
|
||||
export interface TrajectoryChromeProps {
|
||||
/** Render the tool-calls segment; defaults to true (waterfall lanes already
|
||||
* visualize calls, so that view may drop the redundant count). */
|
||||
showCalls?: boolean
|
||||
}
|
||||
/** Props: the conversation-snapshot selector hook (handed down by the view body). */
|
||||
export interface TrajectoryStatsHeaderProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||
|
||||
export const TrajectoryStatsHeader = memo(function TrajectoryStatsHeader({ useSession, showCalls }: ChromeProps & TrajectoryChromeProps) {
|
||||
const nodes = (useSession as SnapshotSelectorHook<ConversationSnapshot>)((s) => s.nodes)
|
||||
export const TrajectoryStatsHeader = memo(function TrajectoryStatsHeader({ useSession }: TrajectoryStatsHeaderProps) {
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const stats = useMemo(() => deriveSpanStats(deriveSpans(nodes)), [nodes])
|
||||
if (stats.turns === 0) return null
|
||||
const parts = [`${stats.turns} turns`, `${stats.steps} steps`]
|
||||
if (showCalls !== false) parts.push(`${stats.calls} tool calls`)
|
||||
return <div className={css.root}>{parts.join(' · ')}</div>
|
||||
return <div className={css.root}>{`${stats.turns} turns · ${stats.steps} steps · ${stats.calls} tool calls`}</div>
|
||||
})
|
||||
@@ -1,28 +1,30 @@
|
||||
// TrajectoryView: P-I placeholder body for the trajectory tab — per-turn
|
||||
// span list with node-count weights (no timing data exists yet; deviation
|
||||
// ledger #3 defers real rendering to P-III).
|
||||
// TrajectoryView: P-I placeholder body for the trajectory tab — span stats
|
||||
// header over a per-turn span list with node-count weights (no timing data
|
||||
// exists yet; deviation ledger #3 defers real rendering to P-III).
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { deriveSpans } from './spans.ts'
|
||||
import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx'
|
||||
import css from './views.module.css'
|
||||
|
||||
export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
const nodes = (useSession as SnapshotSelectorHook<ConversationSnapshot>)((s) => s.nodes)
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const spans = useMemo(() => deriveSpans(nodes), [nodes])
|
||||
if (spans.length === 0) return <div className={css.root}><p className={css.empty}>暂无轨迹数据</p></div>
|
||||
return (
|
||||
<div className={css.root}>
|
||||
{spans.map((span) => (
|
||||
<div key={span.turn} className={css.row}>
|
||||
<span className={css.turnTag}>turn {span.turn}</span>
|
||||
<span className={css.meta}>
|
||||
{span.steps} steps · {span.calls} calls · {span.nodes} nodes
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<TrajectoryStatsHeader useSession={useSession} />
|
||||
<div className={css.root}>
|
||||
{spans.map((span) => (
|
||||
<div key={span.turn} className={css.row}>
|
||||
<span className={css.turnTag}>turn {span.turn}</span>
|
||||
<span className={css.meta}>
|
||||
{span.steps} steps · {span.calls} calls · {span.nodes} nodes
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,18 @@
|
||||
// WaterfallView: P-I placeholder body for the waterfall tab — node-count
|
||||
// bars per turn stand in for duration lanes (no timing data yet; deviation
|
||||
// ledger #3 defers real rendering to P-III).
|
||||
// WaterfallView: P-I placeholder body for the waterfall tab — span stats
|
||||
// header over node-count bars per turn standing in for duration lanes (no
|
||||
// timing data yet; deviation ledger #3 defers real rendering to P-III).
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { deriveSpans } from './spans.ts'
|
||||
import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx'
|
||||
import css from './views.module.css'
|
||||
|
||||
/** Bar width scale: px per node, clamped so tiny windows still show a bar. */
|
||||
const PX_PER_NODE = 14
|
||||
const MIN_BAR_PX = 8
|
||||
|
||||
/** Per-view extension merged into the waterfall body's props through the
|
||||
* conversation view map ({ extraProps? } entry slot). */
|
||||
/** Optional density override (test/standalone knob; the register site passes nothing). */
|
||||
export interface WaterfallExtraProps {
|
||||
/** Bar-lane density in px per node; defaults to 14. */
|
||||
pxPerNode?: number
|
||||
@@ -22,28 +20,31 @@ export interface WaterfallExtraProps {
|
||||
|
||||
export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & WaterfallExtraProps) {
|
||||
const scale = pxPerNode ?? PX_PER_NODE
|
||||
const nodes = (useSession as SnapshotSelectorHook<ConversationSnapshot>)((s) => s.nodes)
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const spans = useMemo(() => deriveSpans(nodes), [nodes])
|
||||
if (spans.length === 0) return <div className={css.root}><p className={css.empty}>暂无瀑布数据</p></div>
|
||||
return (
|
||||
<div className={css.root}>
|
||||
{spans.map((span, i) => (
|
||||
<div key={span.turn} className={css.row} style={{ paddingLeft: i * 12 }}>
|
||||
<span className={css.turnTag}>turn {span.turn}</span>
|
||||
<span
|
||||
className={css.bar}
|
||||
style={{ width: Math.max(span.nodes * scale, MIN_BAR_PX) }}
|
||||
title={`${span.nodes} nodes`}
|
||||
/>
|
||||
{span.calls > 0 && (
|
||||
<>
|
||||
<TrajectoryStatsHeader useSession={useSession} />
|
||||
<div className={css.root}>
|
||||
{spans.map((span, i) => (
|
||||
<div key={span.turn} className={css.row} style={{ paddingLeft: i * 12 }}>
|
||||
<span className={css.turnTag}>turn {span.turn}</span>
|
||||
<span
|
||||
className={`${css.bar} ${css.barCalls}`}
|
||||
style={{ width: Math.max(span.calls * scale, MIN_BAR_PX) }}
|
||||
title={`${span.calls} tool calls`}
|
||||
className={css.bar}
|
||||
style={{ width: Math.max(span.nodes * scale, MIN_BAR_PX) }}
|
||||
title={`${span.nodes} nodes`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{span.calls > 0 && (
|
||||
<span
|
||||
className={`${css.bar} ${css.barCalls}`}
|
||||
style={{ width: Math.max(span.calls * scale, MIN_BAR_PX) }}
|
||||
title={`${span.calls} tool calls`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +1,30 @@
|
||||
/**
|
||||
* Trajectory/Waterfall plugin, browser half: merges ConversationViewMap and
|
||||
* registers the two placeholder views. Pure consumer — no ctx service, no
|
||||
* Context declaration merge; the minimal-plugin exemplar. Contract:
|
||||
* api-contracts v3 section 8.
|
||||
* Trajectory/Waterfall plugin, browser half: contributes the two placeholder
|
||||
* views into the conversation view ring (the 'conversation.view' list slot
|
||||
* declared by ui-conversation). Pure consumer — no ctx service, no Context
|
||||
* declaration merge; the minimal-plugin exemplar. Contract: api-contracts v3
|
||||
* section 8.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import { TrajectoryStatsHeader, type TrajectoryChromeProps } from './TrajectoryStatsHeader.tsx'
|
||||
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's
|
||||
// owning package) must be in the program for the register calls to type.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { TrajectoryView } from './TrajectoryView.tsx'
|
||||
import { WaterfallView, type WaterfallExtraProps } from './WaterfallView.tsx'
|
||||
|
||||
export type { TrajectoryChromeProps } from './TrajectoryStatsHeader.tsx'
|
||||
export type { WaterfallExtraProps } from './WaterfallView.tsx'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ConversationViewMap {
|
||||
// Per-view extension shapes merged through the map (view-ring design):
|
||||
// the stats header's chrome props ride both entries; the waterfall body
|
||||
// additionally takes its lane-density extra. P-III widens these.
|
||||
trajectory: { chromeProps: TrajectoryChromeProps }
|
||||
waterfall: { chromeProps: TrajectoryChromeProps; extraProps: WaterfallExtraProps }
|
||||
}
|
||||
}
|
||||
import { WaterfallView } from './WaterfallView.tsx'
|
||||
|
||||
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
|
||||
export const inject = ['conversation']
|
||||
export const inject = ['slots']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the trajectory and waterfall views. The
|
||||
* registrations are effects on this fiber (plugin unload removes both tabs).
|
||||
* Client plugin body: register the trajectory and waterfall view tabs. The
|
||||
* registrations ride the slot service's effect wrapper (plugin unload
|
||||
* removes both tabs); the span stats header renders inside each view body
|
||||
* (the chrome attachment mechanism retired with the view ring).
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
// chrome.header on both views: the second chrome-attachment consumer
|
||||
// (chat's footer StatsLine is the first) — proves both mount points live.
|
||||
ctx.conversation.registerView({
|
||||
id: 'trajectory', label: 'Trajectory', order: 10,
|
||||
component: TrajectoryView, chrome: { header: TrajectoryStatsHeader },
|
||||
})
|
||||
ctx.conversation.registerView({
|
||||
id: 'waterfall', label: 'Waterfall', order: 20,
|
||||
component: WaterfallView, chrome: { header: TrajectoryStatsHeader },
|
||||
})
|
||||
ctx.slots.register(
|
||||
{ name: 'conversation.view', id: 'trajectory', order: 10, label: 'Trajectory' }, TrajectoryView)
|
||||
ctx.slots.register(
|
||||
{ name: 'conversation.view', id: 'waterfall', order: 20, label: 'Waterfall' }, WaterfallView)
|
||||
}
|
||||
@@ -16,8 +16,8 @@ export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a pure-consumer plugin — it emits no cordis events
|
||||
* and owns no mutable cross-plugin state; both view registrations are plain
|
||||
* effects whose disposal the conversation registry's own specs and this
|
||||
* and owns no mutable cross-plugin state; both view-slot registrations are
|
||||
* plain effects whose disposal the slot ledger's own specs and this
|
||||
* package's behavior specs observe directly.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
* Real tsdown artifact shape: lib/client.js hands off through
|
||||
* window.DSHClientProxy.loadPlugin, resolves externals through the injected
|
||||
* require, returns the export surface (apply + inject), and a mounted apply
|
||||
* registers both views into a real ConversationService. Skips when dist/ is
|
||||
* registers both view tabs into a real SlotsService ring. Skips when dist/ is
|
||||
* not built (`pnpm --filter @deepseek-ai/dsh-client-ui-trajectory bundle`).
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory'
|
||||
|
||||
@@ -59,18 +59,23 @@ describe('tsdown client artifact', () => {
|
||||
const { handoff, surface } = await loadArtifact()
|
||||
expect(handoff.id).toBe(PLUGIN_ID)
|
||||
expect(surface.apply).toBeTypeOf('function')
|
||||
expect(surface.inject).toEqual(['conversation'])
|
||||
expect(surface.inject).toEqual(['slots'])
|
||||
})
|
||||
|
||||
it.skipIf(code === undefined)('mounted as an object plugin, apply registers both views on the real service', async () => {
|
||||
it.skipIf(code === undefined)('mounted as an object plugin, apply registers both view tabs on the real ring', async () => {
|
||||
const { surface } = await loadArtifact()
|
||||
const ctx = new Context()
|
||||
const svc = new ConversationService(ctx)
|
||||
const slots = new SlotsService(ctx)
|
||||
// The conversation entry's role: the ring must be declared before riders land.
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
|
||||
await fiber.await()
|
||||
expect(svc.views().map(v => v.id)).toEqual(['trajectory', 'waterfall'])
|
||||
expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory', 'waterfall'])
|
||||
await fiber.dispose()
|
||||
expect(svc.views()).toHaveLength(0)
|
||||
expect(slots.entries('conversation.view')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.skipIf(code === undefined)('injects plugin-tagged module CSS during factory execution', async () => {
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* View registration acceptance on the real framework stack: the plugin fiber
|
||||
* registers trajectory/waterfall into a real ConversationService, tabs switch
|
||||
* inside ConversationRoot (four-share props form; view rendering is
|
||||
* in-component now) without collapsing chat, chrome.header renders the span
|
||||
* stats bar, and fiber disposal removes both tabs. Span derivation edge cases
|
||||
* ride along.
|
||||
* registers trajectory/waterfall into a real SlotsService view ring, tabs
|
||||
* switch inside ConversationRoot (renderSlot share driven by the same tab
|
||||
* projection apply uses) without collapsing chat, the span stats header
|
||||
* renders inside both view bodies, and fiber disposal removes both tabs.
|
||||
* Span derivation edge cases ride along.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createElement, type FC } from 'react'
|
||||
import { bindSnapshotSelector } from '../../web-react/src/bind.ts'
|
||||
import { createElement, type FC, type ReactNode } from 'react'
|
||||
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 { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
import { ConversationRoot } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx'
|
||||
import { ConversationRoot, type ConversationRootProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationRoot.tsx'
|
||||
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
|
||||
import type { ConvViewProps, ViewId } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
||||
import { deriveSpans, deriveSpanStats } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/spans.ts'
|
||||
import { TrajectoryStatsHeader } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryStatsHeader.tsx'
|
||||
@@ -49,88 +49,116 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
||||
}
|
||||
|
||||
/** Empty sessions-list hook stub (breadcrumbs fall back to the raw id). */
|
||||
/** Empty sessions-list hook stub (breadcrumbs fall back to the raw id; 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)
|
||||
}
|
||||
|
||||
/** Chat-view stand-in props for standalone view mounts. */
|
||||
/** SessionProvider seat stub (render-prop pass-through; ConversationRoot never invokes it). */
|
||||
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
||||
|
||||
/** Standalone view props: the session-scope standard kit the outlet would bake. */
|
||||
function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
|
||||
const chat = createChatStore().create()
|
||||
return {
|
||||
sessionId: SID,
|
||||
useSession: fakeSession(nodes).useSession,
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: { openDetails: vi.fn(), loadOlder: vi.fn() },
|
||||
useSessions: emptySessions(),
|
||||
} as unknown as ConvViewProps
|
||||
}
|
||||
|
||||
/** Real-stack bench: root Context + real ConversationService + the plugin fiber. */
|
||||
/** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
const svc = new ConversationService(ctx)
|
||||
const slots = new SlotsService(ctx)
|
||||
// The conversation entry's role: declare the ring, then seed the chat entry.
|
||||
slots.register({
|
||||
name: 'root',
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
const chatBody = vi.fn(() => <div data-testid="chat-body" />)
|
||||
svc.registerView({ id: 'chat' as ViewId, label: 'Chat', order: 0, component: chatBody as unknown as FC<ConvViewProps> })
|
||||
slots.register(
|
||||
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, svc, fiber }
|
||||
return { ctx, slots, fiber }
|
||||
}
|
||||
|
||||
/** Mount ConversationRoot over the service's registry face (four-share form: chrome/view rendering is in-component). */
|
||||
function mount(svc: ConversationService, nodes: ConversationSnapshot['nodes'] = NODES) {
|
||||
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
|
||||
function tabsOf(slots: SlotsService): ViewTab[] {
|
||||
return slots.entries('conversation.view')
|
||||
.map(e => ({ id: e.options.id!, label: e.options.label ?? e.options.id! }))
|
||||
}
|
||||
|
||||
/** Mount ConversationRoot over the ring ledger with an outlet-faithful renderSlot. */
|
||||
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
|
||||
const sessionSnapshot = createSnapshotStore<{ running: boolean; removed: boolean; promptError: null; nodes: ConversationSnapshot['nodes'] }>({
|
||||
running: false, removed: false, promptError: null, nodes,
|
||||
})
|
||||
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
|
||||
const chat = createChatStore().create()
|
||||
// Minimal outlet twin: resolve the ring entry by the `only` filter and
|
||||
// render it with the session standard kit (what SlotOutlet does for a
|
||||
// list-kind session slot, minus machinery).
|
||||
const renderSlot = ((key: string, _owner: object, opts?: { only?: string }): ReactNode => {
|
||||
const entry = slots.entries('conversation.view').find(e => e.options.id === opts?.only)
|
||||
if (entry === undefined) return null
|
||||
const View = entry.component as FC<ConvViewProps>
|
||||
return (
|
||||
<View
|
||||
{...({ sessionId: SID, useSession, useSessions: emptySessions() } as unknown as ConvViewProps)}
|
||||
key={key}
|
||||
/>
|
||||
)
|
||||
}) as unknown as ConversationRootProps['renderSlot']
|
||||
return render(
|
||||
<ConversationRoot
|
||||
sessionId={SID}
|
||||
useSession={bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>}
|
||||
useSession={useSession}
|
||||
useSessions={emptySessions()}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot}
|
||||
SessionProvider={SessionProviderStub}
|
||||
views={{
|
||||
list: () => svc.views(),
|
||||
subscribe: (fn) => svc.subscribeViews(fn),
|
||||
version: () => svc.viewsVersion(),
|
||||
list: () => tabsOf(slots),
|
||||
subscribe: (fn) => slots.subscribe('conversation.view', fn),
|
||||
version: () => slots.getVersion('conversation.view'),
|
||||
}}
|
||||
send={vi.fn()}
|
||||
stop={vi.fn()}
|
||||
openDetails={vi.fn()}
|
||||
loadOlder={vi.fn()}
|
||||
open={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('plugin registration', () => {
|
||||
it('registers trajectory and waterfall after chat, both with header chrome', async () => {
|
||||
it('registers trajectory and waterfall after chat on the ring', async () => {
|
||||
const b = await bench()
|
||||
const views = b.svc.views()
|
||||
expect(views.map((v) => v.id)).toEqual(['chat', 'trajectory', 'waterfall'])
|
||||
expect(views[1]?.chrome?.header).toBeDefined()
|
||||
expect(views[2]?.chrome?.header).toBeDefined()
|
||||
expect(views[1]?.chrome?.footer).toBeUndefined()
|
||||
expect(tabsOf(b.slots)).toEqual([
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'trajectory', label: 'Trajectory' },
|
||||
{ id: 'waterfall', label: 'Waterfall' },
|
||||
])
|
||||
})
|
||||
|
||||
it('fiber disposal removes both tabs and leaves chat standing', async () => {
|
||||
const b = await bench()
|
||||
await b.fiber.dispose()
|
||||
expect(b.svc.views().map((v) => v.id)).toEqual(['chat'])
|
||||
expect(tabsOf(b.slots).map((v) => v.id)).toEqual(['chat'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab switching in ConversationRoot', () => {
|
||||
it('renders all three tabs, defaults to chat, and switches to trajectory with its header stats', async () => {
|
||||
const b = await bench()
|
||||
mount(b.svc)
|
||||
mount(b.slots)
|
||||
expect(screen.getByTestId('chat-body')).toBeTruthy()
|
||||
expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
// chrome.header stats over NODES: turns 0/1/2, 2 assistant steps, 1 tool call.
|
||||
// In-body header stats over NODES: turns 0/1/2, 2 assistant steps, 1 tool call.
|
||||
expect(screen.getByText('3 turns · 2 steps · 1 tool calls')).toBeTruthy()
|
||||
expect(screen.getByText('turn 0')).toBeTruthy()
|
||||
expect(screen.getByText('1 steps · 1 calls · 2 nodes')).toBeTruthy()
|
||||
@@ -139,7 +167,7 @@ describe('tab switching in ConversationRoot', () => {
|
||||
|
||||
it('waterfall renders bars and switching back to chat does not collapse it', async () => {
|
||||
const b = await bench()
|
||||
mount(b.svc)
|
||||
mount(b.slots)
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Waterfall' }))
|
||||
expect(screen.getByTitle('2 nodes')).toBeTruthy()
|
||||
expect(screen.getByTitle('1 tool calls')).toBeTruthy()
|
||||
@@ -148,9 +176,9 @@ describe('tab switching in ConversationRoot', () => {
|
||||
expect(screen.getByTestId('chat-body')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('empty window: placeholder copy in the body, header chrome renders nothing', async () => {
|
||||
it('empty window: placeholder copy in the body, the stats header renders nothing', async () => {
|
||||
const b = await bench()
|
||||
mount(b.svc, [] as unknown as ConversationSnapshot['nodes'])
|
||||
mount(b.slots, [] as unknown as ConversationSnapshot['nodes'])
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(screen.getByText('暂无轨迹数据')).toBeTruthy()
|
||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||
@@ -175,7 +203,7 @@ describe('span derivation', () => {
|
||||
it('empty inputs produce zero stats and standalone components render their empty forms', () => {
|
||||
expect(deriveSpanStats(deriveSpans([] as unknown as ConversationSnapshot['nodes']))).toEqual({ turns: 0, steps: 0, calls: 0 })
|
||||
const { useSession } = fakeSession([] as unknown as ConversationSnapshot['nodes'])
|
||||
const { container } = render(createElement(TrajectoryStatsHeader, { sessionId: SID, useSession }))
|
||||
const { container } = render(createElement(TrajectoryStatsHeader, { useSession: useSession as never }))
|
||||
expect(container.firstChild).toBeNull()
|
||||
render(createElement(TrajectoryView as FC<ConvViewProps>,
|
||||
standaloneProps([] as unknown as ConversationSnapshot['nodes'])))
|
||||
|
||||
Reference in New Issue
Block a user