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:
imccyu
2026-07-23 17:09:32 +08:00
parent fcd9af2033
commit bbde18caff
56 changed files with 1569 additions and 1847 deletions

View File

@@ -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>
})

View File

@@ -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>
</>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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)
}

View File

@@ -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 = () => {}