mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
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,39 +1,32 @@
|
||||
/**
|
||||
* Client plugin body: provide the conversation service and toolview registry,
|
||||
* register the conversation/details slot occupants and the no-session empty
|
||||
* state, and mount the chat view with its samples. Assembly only — components
|
||||
* receive everything through props: the framework standard kit and store
|
||||
* faces arrive automatically from the declarations below; the inject
|
||||
* factories contribute the plain-data-and-callbacks business face (design §5).
|
||||
* Client plugin body: register the conversation/details slot occupants and
|
||||
* the no-session empty state, contribute the chat entry into the
|
||||
* 'conversation.view' ring that the conversation registration declares, then
|
||||
* mount the conversation service (class plugin) and the bash toolview sample.
|
||||
* Assembly only — components receive everything through props: the framework
|
||||
* standard kit and store faces arrive automatically from the declarations
|
||||
* below; the inject factories contribute the plain-data-and-callbacks
|
||||
* business face (design §5). Tool rows are ordinary keyed-slot registrations
|
||||
* into 'conversation.chat.toolview' — no dedicated registry exists.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId, SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { LayoutService } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { I18nService } from '@deepseek-ai/dsh-client-i18n/client'
|
||||
import type { SelectionTarget } from './contract/views.ts'
|
||||
import type { ConversationInjected, DetailsInjected, EmptyStateInjected } from './contract/slots.ts'
|
||||
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ViewTab } from './contract/views.ts'
|
||||
import type {
|
||||
ChatViewInjected, ConversationInjected, DetailsInjected, EmptyStateInjected,
|
||||
} from './contract/slots.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import { ToolViewRegistry } from './toolviews/registry.ts'
|
||||
import { childSessionScope, registerChat } from './chat/register.ts'
|
||||
import { registerBashSamples } from './toolviews/bash-sample.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
||||
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
import { EmptyState } from './skeleton/EmptyState.tsx'
|
||||
|
||||
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'i18n']
|
||||
|
||||
/** Resolve a service via ctx.get, failing loud. Property access is reserved
|
||||
* for contexts whose fiber declares the inject (scope fibers do not). */
|
||||
// T is the caller-named cast target; inlining `as T` per call site would scatter the budgeted cast.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
||||
function need<T>(ctx: Context, name: string): T {
|
||||
const value = ctx.get(name) as T | undefined
|
||||
if (value === undefined) throw new Error(`ui-conversation: ${name} service unavailable`)
|
||||
return value
|
||||
}
|
||||
export const inject = ['slots', 'layout', 'sessions']
|
||||
|
||||
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: SessionsService, id: SessionId): ConversationService {
|
||||
@@ -49,48 +42,46 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const sessions = need<SessionsService>(ctx, 'sessions')
|
||||
const layout = need<LayoutService>(ctx, 'layout')
|
||||
const i18n = need<I18nService>(ctx, 'i18n')
|
||||
const slots = need<SlotsService>(ctx, 'slots')
|
||||
|
||||
const conversation = new ConversationService(ctx)
|
||||
const toolviews = new ToolViewRegistry()
|
||||
ctx.provide('toolviews', toolviews)
|
||||
|
||||
const t = i18n.bind('conversation')
|
||||
// Chat view + StatsLine footer; bash samples assembled here (apply is the
|
||||
// only cross-domain point — chat consumes the resolver face, samples come
|
||||
// from the toolviews domain). registerView inside registerChat is already
|
||||
// effect-scoped; the raw sample registrations need the effect wrapper to
|
||||
// ride the fiber cascade.
|
||||
ctx.effect(
|
||||
() => registerChat({ conversation, toolviews, t }),
|
||||
'ui-conversation: chat view')
|
||||
ctx.effect(
|
||||
() => registerBashSamples(toolviews, childSessionScope(sessions.list)),
|
||||
'ui-conversation: bash toolview samples')
|
||||
const sessions = ctx.sessions
|
||||
const layout = ctx.layout
|
||||
const slots = ctx.slots
|
||||
|
||||
// Shared store handle, constructed here so its identity lives and dies with
|
||||
// this fiber (a module-level handle would be a de-facto singleton). Both
|
||||
// session-slot registrations declare it; same scope key = same instance, so
|
||||
// conversation writes and details reads meet in one store.
|
||||
const chat = createChatStore()
|
||||
// this fiber (a module-level handle would be a de-facto singleton). The
|
||||
// conversation, chat-view, and details registrations all declare it; same
|
||||
// scope key = same instance, so chat-view selection writes and details
|
||||
// reads meet in one store.
|
||||
const chatStore = createChatStore()
|
||||
|
||||
// Tab projection over the view ring's ledger (list entries carry id/order/
|
||||
// label as registration options; the ledger keeps them order-sorted).
|
||||
const viewTabs = (): ViewTab[] => {
|
||||
const tabs: ViewTab[] = []
|
||||
for (const entry of slots.entries('conversation.view')) {
|
||||
/* v8 ignore next -- unreachable: list registration validates id at load. */
|
||||
if (entry.options.id === undefined) continue
|
||||
tabs.push({ id: entry.options.id, label: entry.options.label ?? entry.options.id })
|
||||
}
|
||||
return tabs
|
||||
}
|
||||
|
||||
// Conversation occupant. Declaring the view ring here is claiming it:
|
||||
// ConversationRoot is the only component authorized to render the ring.
|
||||
slots.register({
|
||||
name: 'conversation',
|
||||
store: chat,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chat>): ConversationInjected => {
|
||||
const session = sessions.manager.get(sessionId)
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ConversationInjected => {
|
||||
// History pull is NOT triggered here: the runtime sessions service opens
|
||||
// the event window when the watch lands on the session (cell/binding
|
||||
// resolution) — an inject factory assembles callbacks, it has no side
|
||||
// effect on session state.
|
||||
const scoped = scopedConversation(sessions, sessionId)
|
||||
// Watch-driven history pull: assembling the surface IS the watch signal
|
||||
// (once per entry x session; open() is idempotent and self-recovers).
|
||||
void session.open()
|
||||
return {
|
||||
views: {
|
||||
list: () => conversation.views(),
|
||||
subscribe: fn => conversation.subscribeViews(fn),
|
||||
version: () => conversation.viewsVersion(),
|
||||
list: viewTabs,
|
||||
subscribe: fn => slots.subscribe('conversation.view', fn),
|
||||
version: () => slots.getVersion('conversation.view'),
|
||||
},
|
||||
send: (text, mode) => {
|
||||
const trimmed = text.trim()
|
||||
@@ -107,19 +98,46 @@ export function apply(ctx: Context): void {
|
||||
// Stop failure surfaces via snapshot.promptError; nothing to restore.
|
||||
})
|
||||
},
|
||||
openDetails: (target: SelectionTarget) => {
|
||||
actions.select(target)
|
||||
layout.openDetails()
|
||||
},
|
||||
loadOlder: () => { void session.loadOlder() },
|
||||
open: (target: SessionId) => { sessions.open(target) },
|
||||
}
|
||||
},
|
||||
}, ConversationRoot)
|
||||
|
||||
// The chat view: first entry of the ring this package just declared.
|
||||
// Declaring the keyed toolview hole here is claiming it: ChatView is the
|
||||
// only component authorized to render per-tool rows. Shares the chat
|
||||
// store, so its selection writes land in the same per-session instance the
|
||||
// details panel reads.
|
||||
slots.register({
|
||||
name: 'conversation.view',
|
||||
id: 'chat',
|
||||
order: 0,
|
||||
label: 'Chat',
|
||||
children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => ({
|
||||
openDetails: (target) => {
|
||||
actions.select(target)
|
||||
layout.openDetails()
|
||||
},
|
||||
loadOlder: () => { void sessions.manager.get(sessionId).loadOlder() },
|
||||
}),
|
||||
}, ChatView)
|
||||
|
||||
// Class-plugin mount (packages/AGENTS.md service form): the service
|
||||
// registers itself as `conversation` and lives on its own child fiber.
|
||||
// Mounted AFTER the chat entry register above — construction guarantee for
|
||||
// toolview registrants using `inject: ['conversation']` as their load-order
|
||||
// seam: the service being present implies the chat entry (and with it the
|
||||
// 'conversation.chat.toolview' declaration) is on the ledger.
|
||||
ctx.plugin(ConversationService)
|
||||
|
||||
// The bash sample rides that exact seam, in third-party posture.
|
||||
ctx.plugin(bashToolviewSample)
|
||||
|
||||
slots.register({
|
||||
name: 'details',
|
||||
store: chat,
|
||||
store: chatStore,
|
||||
inject: (): DetailsInjected => ({
|
||||
closeDetails: () => { layout.closeDetails() },
|
||||
}),
|
||||
@@ -128,7 +146,15 @@ export function apply(ctx: Context): void {
|
||||
slots.register({
|
||||
name: 'conversation.empty',
|
||||
inject: (): EmptyStateInjected => ({
|
||||
startSession: opts => conversation.startSession(opts),
|
||||
// ctx.get, not ctx.conversation: the service mounts on this plugin's
|
||||
// own child fiber, so it is not in the inject topology the property
|
||||
// proxy enforces; get reads the global store and stays loud on a torn
|
||||
// boot through the optional-chain throw below.
|
||||
startSession: (opts) => {
|
||||
const conversation = ctx.get('conversation')
|
||||
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
|
||||
return conversation.startSession(opts)
|
||||
},
|
||||
}),
|
||||
}, EmptyState)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// AssistantMarkdown: renders assistant blocks in order — markdown text body,
|
||||
// reasoning as the figma Think summary row (expand = indented gray text),
|
||||
// other-block JSON fallback. Tool-call heads are NOT rendered here: the chat
|
||||
// view groups them into tool rows via the toolview outlet (figma step-summary
|
||||
// flow). Shared by finalized nodes and the streaming partial (pulse marker).
|
||||
// view groups them into tool rows through its keyed toolview slot (figma
|
||||
// step-summary flow). Shared by finalized nodes and the streaming partial
|
||||
// (pulse marker).
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
@@ -1,53 +1,55 @@
|
||||
// ChatView: the default conversation view — message flow with user bubbles,
|
||||
// assistant narration, tool summary rows grouped into step runs, pending
|
||||
// cards, paging and bottom-follow. Created via factory so plugin deps
|
||||
// (toolviews registry, i18n) arrive by closure, never by import.
|
||||
// cards, paging, bottom-follow, and the session stats line under the flow
|
||||
// (chrome dissolved into the view: the footer is part of what a chat view
|
||||
// IS, not registration metadata). Pure component registered directly; its
|
||||
// registration declares the keyed 'conversation.chat.toolview' hole, so tool
|
||||
// rows render through the props renderSlot share (entryKey = tool name,
|
||||
// GenericToolCard as the render-site fallback).
|
||||
//
|
||||
// Render economics (architecture RFC performance model): the list parent
|
||||
// subscribes to snapshot segments that do NOT change per streaming chunk
|
||||
// (nodes/runningCalls/pending keep their references across chunk batches), so
|
||||
// during a token storm only StreamingTail re-renders; history rows hold via
|
||||
// memo on cache-stable node slices. Selection changes re-render the parent
|
||||
// map but only rows whose own selected bit flipped.
|
||||
// map but only rows whose own selected bit flipped. renderSlot is
|
||||
// entry-identity-stable (framework binding cache), so passing it through
|
||||
// memoized rows never churns them.
|
||||
|
||||
import {
|
||||
memo, useLayoutEffect, useMemo, useRef, useState, type FC, type ReactNode,
|
||||
memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
|
||||
} from 'react'
|
||||
import type {
|
||||
ConversationNode, ConversationSnapshot, RunningToolCall, SessionId, ToolResultNode,
|
||||
ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ConvViewProps, SelectionTarget, Translate } from '../contract/views.ts'
|
||||
import type { ToolViewProps } from '../contract/toolview.ts'
|
||||
import type { ToolViewResolver } from '../contract/toolview.ts'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { SelectionTarget } from '../contract/views.ts'
|
||||
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
import { MessageItem } from './MessageItem.tsx'
|
||||
import { PendingCard } from './PendingCard.tsx'
|
||||
import { ToolViewOutlet } from './ToolViewOutlet.tsx'
|
||||
import { StatsLine } from './StatsLine.tsx'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
/** Plugin-supplied closure deps (assembled in registerChat, apply world). */
|
||||
export interface ChatViewDeps {
|
||||
toolviews: ToolViewResolver
|
||||
t: Translate
|
||||
}
|
||||
|
||||
const FOLLOW_THRESHOLD = 24
|
||||
|
||||
type OpenDetails = (target: SelectionTarget) => void
|
||||
|
||||
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
|
||||
type RenderToolRow = ChatViewSlotProps['renderSlot']
|
||||
|
||||
/** ui-slots' UseSession is deliberately wide (dependency direction); the
|
||||
* chat view narrows once to the runtime snapshot the binding actually feeds. */
|
||||
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
|
||||
|
||||
/** One tool call row (result or running): builds the bound ToolViewProps. */
|
||||
const CallRow = memo(function CallRow({ registry, sessionId, useSession, t, callId, toolName, block, seq, onOpenDetails, selected }: {
|
||||
registry: ToolViewResolver
|
||||
sessionId: SessionId
|
||||
useSession: ConvViewProps['useSession']
|
||||
t: Translate
|
||||
/** One tool call row (result or running): dispatches through the keyed
|
||||
* toolview slot with the owner payload; unregistered tools fall back to
|
||||
* GenericToolCard at this render site. */
|
||||
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected }: {
|
||||
renderSlot: RenderToolRow
|
||||
callId: string
|
||||
toolName: string
|
||||
block: ToolResultNode | RunningToolCall
|
||||
@@ -56,24 +58,23 @@ const CallRow = memo(function CallRow({ registry, sessionId, useSession, t, call
|
||||
onOpenDetails: OpenDetails
|
||||
selected: boolean
|
||||
}) {
|
||||
const viewProps = useMemo<ToolViewProps>(() => ({
|
||||
callId, toolName, block, useSession,
|
||||
actions: { openDetails: () => onOpenDetails({ turnSeq: seq, callId, toolName }) },
|
||||
t,
|
||||
}), [callId, toolName, block, useSession, seq, onOpenDetails, t])
|
||||
const owner = useMemo(() => ({
|
||||
callId, toolName, block,
|
||||
openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) },
|
||||
}), [callId, toolName, block, seq, onOpenDetails])
|
||||
return (
|
||||
<div className={css.callRow} data-selected={selected || undefined}>
|
||||
<ToolViewOutlet registry={registry} sessionId={sessionId} toolName={toolName} viewProps={viewProps} />
|
||||
{renderSlot('conversation.chat.toolview', owner, {
|
||||
entryKey: toolName,
|
||||
fallback: <GenericToolCard {...owner} />,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */
|
||||
const ToolGroup = memo(function ToolGroup({ registry, sessionId, useSession, t, results, onOpenDetails, selectedCallId }: {
|
||||
registry: ToolViewResolver
|
||||
sessionId: SessionId
|
||||
useSession: ConvViewProps['useSession']
|
||||
t: Translate
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId }: {
|
||||
renderSlot: RenderToolRow
|
||||
results: readonly ToolResultNode[]
|
||||
onOpenDetails: OpenDetails
|
||||
/** Only set when the selected call lives in THIS group (memo economy). */
|
||||
@@ -84,10 +85,7 @@ const ToolGroup = memo(function ToolGroup({ registry, sessionId, useSession, t,
|
||||
{results.map((node) => (
|
||||
<CallRow
|
||||
key={node.callId}
|
||||
registry={registry}
|
||||
sessionId={sessionId}
|
||||
useSession={useSession}
|
||||
t={t}
|
||||
renderSlot={renderSlot}
|
||||
callId={node.callId}
|
||||
toolName={node.call?.name ?? ''}
|
||||
block={node}
|
||||
@@ -114,180 +112,166 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
return <AssistantMarkdown blocks={partial.blocks} streaming />
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the chat view component over plugin deps.
|
||||
* @param deps - toolview registry and bound translator.
|
||||
* @returns the ConvViewProps component registered as the chat view.
|
||||
*/
|
||||
export function createChatView(deps: ChatViewDeps): FC<ConvViewProps> {
|
||||
const { toolviews, t } = deps
|
||||
/** The chat view slot entry: pure component over the composed props (tool rows render through the declared keyed hole's renderSlot share). */
|
||||
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const runningCalls = useSession((s) => s.runningCalls)
|
||||
const pending = useSession((s) => s.pending)
|
||||
const openState = useSession((s) => s.openState)
|
||||
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||||
const hasMore = useSession((s) => s.hasMore)
|
||||
const loadingOlder = useSession((s) => s.loadingOlder)
|
||||
const selectedCallId = useStore((s) => s.selection?.callId)
|
||||
|
||||
return function ChatView({ sessionId, useSession: useSessionWide, useStore, actions }: ConvViewProps) {
|
||||
const useSession = useSessionWide as UseConversation
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const runningCalls = useSession((s) => s.runningCalls)
|
||||
const pending = useSession((s) => s.pending)
|
||||
const openState = useSession((s) => s.openState)
|
||||
const openErrorMessage = useSession((s) => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
|
||||
const hasMore = useSession((s) => s.hasMore)
|
||||
const loadingOlder = useSession((s) => s.loadingOlder)
|
||||
const selectedCallId = useStore((s) => s.selection?.callId)
|
||||
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
|
||||
|
||||
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
const atBottomRef = useRef(true)
|
||||
const [atBottom, setAtBottom] = useState(true)
|
||||
/** Paging anchor: height/position at click, compensated after the prepend lands. */
|
||||
const anchorRef = useRef<{ h: number; t: number } | null>(null)
|
||||
const firstSeqRef = useRef<number | null>(null)
|
||||
const openedRef = useRef(false)
|
||||
const lastKeyRef = useRef<string | null>(null)
|
||||
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
const atBottomRef = useRef(true)
|
||||
const [atBottom, setAtBottom] = useState(true)
|
||||
/** Paging anchor: height/position at click, compensated after the prepend lands. */
|
||||
const anchorRef = useRef<{ h: number; t: number } | null>(null)
|
||||
const firstSeqRef = useRef<number | null>(null)
|
||||
const openedRef = useRef(false)
|
||||
const lastKeyRef = useRef<string | null>(null)
|
||||
const firstSeq = nodes[0]?.seq ?? null
|
||||
const lastItem = items[items.length - 1]
|
||||
|
||||
const firstSeq = nodes[0]?.seq ?? null
|
||||
const lastItem = items[items.length - 1]
|
||||
|
||||
const toBottom = (el: HTMLDivElement): void => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
atBottomRef.current = true
|
||||
setAtBottom(true)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
|
||||
if (el === null) return
|
||||
// Open completed: jump to the bottom once.
|
||||
if (openState === 'open' && !openedRef.current) {
|
||||
openedRef.current = true
|
||||
toBottom(el)
|
||||
firstSeqRef.current = firstSeq
|
||||
lastKeyRef.current = lastItem?.key ?? null
|
||||
return
|
||||
}
|
||||
// Prepend (head seq decreased): compensate by the height delta.
|
||||
if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) {
|
||||
el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h)
|
||||
anchorRef.current = null
|
||||
firstSeqRef.current = firstSeq
|
||||
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
|
||||
lastKeyRef.current = lastItem?.key ?? null
|
||||
return
|
||||
}
|
||||
firstSeqRef.current = firstSeq
|
||||
// Own words must be visible: a new trailing user node force-scrolls
|
||||
// (send lives in the composer, so arrival is detected here, not armed there).
|
||||
const lastKey = lastItem?.key ?? null
|
||||
const appendedUser = lastKey !== lastKeyRef.current
|
||||
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
|
||||
lastKeyRef.current = lastKey
|
||||
if (appendedUser || atBottomRef.current) toBottom(el)
|
||||
})
|
||||
|
||||
const onScroll = (): void => {
|
||||
const el = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */
|
||||
if (el === null) return
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
||||
atBottomRef.current = isAtBottom
|
||||
setAtBottom(isAtBottom)
|
||||
}
|
||||
|
||||
// Follow streaming growth the parent never re-renders for (stable ref).
|
||||
// The ref starts null and is assigned every render, so the placeholder
|
||||
// initializer a function initial value would need never exists.
|
||||
const followRef = useRef<(() => void) | null>(null)
|
||||
followRef.current = () => {
|
||||
const el = listRef.current
|
||||
if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight
|
||||
}
|
||||
const onGrow = useRef(() => followRef.current?.()).current
|
||||
|
||||
const loadOlder = (): void => {
|
||||
const el = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
|
||||
if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
|
||||
actions.loadOlder()
|
||||
}
|
||||
|
||||
const renderItem = (item: ChatFlowItem): ReactNode => {
|
||||
if (item.kind === 'tool-group') {
|
||||
const inGroup = selectedCallId !== undefined
|
||||
&& item.results.some((r) => r.callId === selectedCallId)
|
||||
return (
|
||||
<ToolGroup
|
||||
key={item.key}
|
||||
registry={toolviews}
|
||||
sessionId={sessionId}
|
||||
useSession={useSession}
|
||||
t={t}
|
||||
results={item.results}
|
||||
onOpenDetails={actions.openDetails}
|
||||
selectedCallId={inGroup ? selectedCallId : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const node: ConversationNode = item.node
|
||||
if (node.kind === 'assistant') {
|
||||
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
|
||||
}
|
||||
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
|
||||
if (node.kind === 'tool-result') return null
|
||||
return <MessageItem key={item.key} node={node} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
|
||||
<div className={css.column}>
|
||||
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
|
||||
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
|
||||
{hasMore && (
|
||||
<div className={css.older}>
|
||||
<button type="button" disabled={loadingOlder} onClick={loadOlder}>
|
||||
{loadingOlder ? '加载中…' : '加载更早'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{items.map(renderItem)}
|
||||
<StreamingTail useSession={useSession} onGrow={onGrow} />
|
||||
{runningCalls.length > 0 && (
|
||||
<div className={css.toolGroup}>
|
||||
{runningCalls.map((call) => (
|
||||
<CallRow
|
||||
key={call.callId}
|
||||
registry={toolviews}
|
||||
sessionId={sessionId}
|
||||
useSession={useSession}
|
||||
t={t}
|
||||
callId={call.callId}
|
||||
toolName={call.name}
|
||||
block={call}
|
||||
seq={call.turn}
|
||||
onOpenDetails={actions.openDetails}
|
||||
selected={call.callId === selectedCallId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pending.map((item) => <PendingCard key={item.rpcId} item={item} />)}
|
||||
</div>
|
||||
</div>
|
||||
{!atBottom && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.toBottom}
|
||||
aria-label="回到底部"
|
||||
onClick={() => {
|
||||
const el = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
|
||||
if (el !== null) toBottom(el)
|
||||
}}
|
||||
>
|
||||
<IconChevronDownOutline14 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
const toBottom = (el: HTMLDivElement): void => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
atBottomRef.current = true
|
||||
setAtBottom(true)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
|
||||
if (el === null) return
|
||||
// Open completed: jump to the bottom once.
|
||||
if (openState === 'open' && !openedRef.current) {
|
||||
openedRef.current = true
|
||||
toBottom(el)
|
||||
firstSeqRef.current = firstSeq
|
||||
lastKeyRef.current = lastItem?.key ?? null
|
||||
return
|
||||
}
|
||||
// Prepend (head seq decreased): compensate by the height delta.
|
||||
if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) {
|
||||
el.scrollTop = anchorRef.current.t + (el.scrollHeight - anchorRef.current.h)
|
||||
anchorRef.current = null
|
||||
firstSeqRef.current = firstSeq
|
||||
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
|
||||
lastKeyRef.current = lastItem?.key ?? null
|
||||
return
|
||||
}
|
||||
firstSeqRef.current = firstSeq
|
||||
// Own words must be visible: a new trailing user node force-scrolls
|
||||
// (send lives in the composer, so arrival is detected here, not armed there).
|
||||
const lastKey = lastItem?.key ?? null
|
||||
const appendedUser = lastKey !== lastKeyRef.current
|
||||
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
|
||||
lastKeyRef.current = lastKey
|
||||
if (appendedUser || atBottomRef.current) toBottom(el)
|
||||
})
|
||||
|
||||
const onScroll = (): void => {
|
||||
const el = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the handler only fires on the mounted element. */
|
||||
if (el === null) return
|
||||
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
|
||||
atBottomRef.current = isAtBottom
|
||||
setAtBottom(isAtBottom)
|
||||
}
|
||||
|
||||
// Follow streaming growth the parent never re-renders for (stable ref).
|
||||
// The ref starts null and is assigned every render, so the placeholder
|
||||
// initializer a function initial value would need never exists.
|
||||
const followRef = useRef<(() => void) | null>(null)
|
||||
followRef.current = () => {
|
||||
const el = listRef.current
|
||||
if (el !== null && atBottomRef.current) el.scrollTop = el.scrollHeight
|
||||
}
|
||||
const onGrow = useRef(() => followRef.current?.()).current
|
||||
|
||||
const loadOlderAnchored = (): void => {
|
||||
const el = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
|
||||
if (el !== null) anchorRef.current = { h: el.scrollHeight, t: el.scrollTop }
|
||||
loadOlder()
|
||||
}
|
||||
|
||||
const renderItem = (item: ChatFlowItem): ReactNode => {
|
||||
if (item.kind === 'tool-group') {
|
||||
const inGroup = selectedCallId !== undefined
|
||||
&& item.results.some((r) => r.callId === selectedCallId)
|
||||
return (
|
||||
<ToolGroup
|
||||
key={item.key}
|
||||
renderSlot={renderSlot}
|
||||
results={item.results}
|
||||
onOpenDetails={openDetails}
|
||||
selectedCallId={inGroup ? selectedCallId : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const node: ConversationNode = item.node
|
||||
if (node.kind === 'assistant') {
|
||||
return <AssistantMarkdown key={item.key} blocks={node.blocks} streaming={false} interrupted={node.interrupted} />
|
||||
}
|
||||
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
|
||||
if (node.kind === 'tool-result') return null
|
||||
return <MessageItem key={item.key} node={node} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div ref={listRef} className={css.scroll} onScroll={onScroll}>
|
||||
<div className={css.column}>
|
||||
{openState === 'loading' && <div className={css.hint}>载入历史…</div>}
|
||||
{openState === 'error' && <div className={css.openError}>历史加载失败:{openErrorMessage}</div>}
|
||||
{hasMore && (
|
||||
<div className={css.older}>
|
||||
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
|
||||
{loadingOlder ? '加载中…' : '加载更早'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{items.map(renderItem)}
|
||||
<StreamingTail useSession={useSession} onGrow={onGrow} />
|
||||
{runningCalls.length > 0 && (
|
||||
<div className={css.toolGroup}>
|
||||
{runningCalls.map((call) => (
|
||||
<CallRow
|
||||
key={call.callId}
|
||||
renderSlot={renderSlot}
|
||||
callId={call.callId}
|
||||
toolName={call.name}
|
||||
block={call}
|
||||
seq={call.turn}
|
||||
onOpenDetails={openDetails}
|
||||
selected={call.callId === selectedCallId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pending.map((item) => <PendingCard key={item.rpcId} item={item} />)}
|
||||
</div>
|
||||
</div>
|
||||
<StatsLine useSession={useSession} />
|
||||
{!atBottom && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.toBottom}
|
||||
aria-label="回到底部"
|
||||
onClick={() => {
|
||||
const el = listRef.current
|
||||
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
|
||||
if (el !== null) toBottom(el)
|
||||
}}
|
||||
>
|
||||
<IconChevronDownOutline14 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// GenericToolCard: the registry-miss fallback toolview — classifies the tool
|
||||
// into one of the five figma row variants and renders the summary row. Also
|
||||
// the shared base the bash sample builds on: any ToolViewProps consumer.
|
||||
// GenericToolCard: the default tool row — classifies the tool into one of
|
||||
// the five figma row variants and renders the summary row. Supplied by the
|
||||
// chat view as the keyed toolview slot's render-site fallback (an
|
||||
// unregistered tool name lands here); registrants may also compose it as a
|
||||
// base, feeding the same owner payload through.
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconApiOutline14, IconBrowseOutline16, IconEditOutline16, IconSearchOutline16, IconThinkOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolViewProps } from '../contract/toolview.ts'
|
||||
import { toolRowModel, type ToolCallBlock, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import type { ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import { IconSparkle16 } from './IconSparkle16.tsx'
|
||||
|
||||
@@ -22,8 +24,8 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
others: <IconSparkle16 />,
|
||||
}
|
||||
|
||||
export function GenericToolCard({ toolName, block, actions }: ToolViewProps) {
|
||||
const model = toolRowModel(toolName, block as ToolCallBlock)
|
||||
export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
return (
|
||||
<ToolRow
|
||||
variant={model.variant}
|
||||
@@ -32,7 +34,7 @@ export function GenericToolCard({ toolName, block, actions }: ToolViewProps) {
|
||||
summary={model.summary}
|
||||
body={model.body}
|
||||
state={model.state}
|
||||
onOpenDetails={actions.openDetails}
|
||||
onOpenDetails={openDetails}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
// StatsLine: the session stats row (figma 122:11212 "cache hit 92% · 1,284
|
||||
// tokens · 45.2s · 5 turns · 32 steps"), mounted as the chat view's
|
||||
// chrome.footer — the first chrome-attachment consumer. Duration has no data
|
||||
// source in P-I (ledger). Subscribes to `nodes` only: chunk batches never swap
|
||||
// that reference, so the row renders zero times during streaming (the RFC
|
||||
// performance model's acceptance row).
|
||||
// tokens · 45.2s · 5 turns · 32 steps"), rendered by ChatView under the flow
|
||||
// (part of the chat view body — the chrome attachment mechanism retired with
|
||||
// the view ring). Duration has no data source in P-I (ledger). Subscribes to
|
||||
// `nodes` only: chunk batches never swap that reference, so the row renders
|
||||
// zero times during streaming (the RFC performance model's acceptance row).
|
||||
|
||||
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 '../contract/views.ts'
|
||||
import css from './StatsLine.module.css'
|
||||
|
||||
interface UsageTotals {
|
||||
@@ -55,8 +54,11 @@ export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
|
||||
}
|
||||
}
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession }: ChromeProps) {
|
||||
const nodes = (useSession as SnapshotSelectorHook<ConversationSnapshot>)((s) => s.nodes)
|
||||
/** Props: the conversation-snapshot selector hook (handed down by ChatView). */
|
||||
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const stats = useMemo(() => deriveStats(nodes), [nodes])
|
||||
if (stats.steps === 0) return null
|
||||
const parts: string[] = []
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
// ToolViewOutlet: resolves the toolview for one call through ctx.toolviews
|
||||
// (uSES over the registry version so unload falls back live) and renders it
|
||||
// behind a per-row error boundary. GenericToolCard is the render-side
|
||||
// fallback for both a registry miss and a crashed custom row. Pure props
|
||||
// machinery, zero React context: a registrant inject factory receives the
|
||||
// sessionId this outlet already holds, is called once per (registration x
|
||||
// session) and cached, mirroring the slot injection discipline.
|
||||
|
||||
import { Component, useSyncExternalStore, type ReactNode } from 'react'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolViewInject, ToolViewProps, ToolViewResolver } from '../contract/toolview.ts'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
|
||||
export interface ToolViewOutletProps {
|
||||
registry: ToolViewResolver
|
||||
sessionId: SessionId
|
||||
toolName: string
|
||||
viewProps: ToolViewProps
|
||||
}
|
||||
|
||||
/** Inject cache: per inject-factory (stable per registration) x session id.
|
||||
* The inner Map lives and dies with its factory (WeakMap entry), so entries
|
||||
* are bounded by the session count over the registration's lifetime. */
|
||||
const injectCache = new WeakMap<ToolViewInject<object>, Map<SessionId, object>>()
|
||||
|
||||
function cachedInject(inject: ToolViewInject<object>, sessionId: SessionId): object {
|
||||
let perSession = injectCache.get(inject)
|
||||
if (!perSession) {
|
||||
perSession = new Map()
|
||||
injectCache.set(inject, perSession)
|
||||
}
|
||||
let props = perSession.get(sessionId)
|
||||
if (!props) {
|
||||
props = inject(sessionId)
|
||||
perSession.set(sessionId, props)
|
||||
}
|
||||
return props
|
||||
}
|
||||
|
||||
class RowErrorBoundary extends Component<
|
||||
{ resetKey: unknown; fallback: ReactNode; children: ReactNode }, { failed: boolean }
|
||||
> {
|
||||
override state = { failed: false }
|
||||
// Fallback state MUST flip here (render phase): a boundary whose derived
|
||||
// state does not change re-renders the crashing children and React gives
|
||||
// up after the second throw, escalating past the boundary.
|
||||
static getDerivedStateFromError(): { failed: boolean } {
|
||||
return { failed: true }
|
||||
}
|
||||
override componentDidCatch(error: unknown): void {
|
||||
console.error('toolview row crashed:', error)
|
||||
}
|
||||
// A re-registration (resetKey bump) retries the custom row.
|
||||
override componentDidUpdate(prev: { resetKey: unknown }): void {
|
||||
if (this.state.failed && prev.resetKey !== this.props.resetKey) {
|
||||
this.setState({ failed: false })
|
||||
}
|
||||
}
|
||||
override render(): ReactNode {
|
||||
if (this.state.failed) return this.props.fallback
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export function ToolViewOutlet({ registry, sessionId, toolName, viewProps }: ToolViewOutletProps) {
|
||||
const version = useSyncExternalStore(
|
||||
(fn) => registry.subscribe(fn),
|
||||
() => registry.getVersion(),
|
||||
)
|
||||
const resolved = registry.resolve(toolName, sessionId)
|
||||
if (resolved === undefined) return <GenericToolCard {...viewProps} />
|
||||
const Row = resolved.component
|
||||
return (
|
||||
<RowErrorBoundary resetKey={version} fallback={<GenericToolCard {...viewProps} />}>
|
||||
{resolved.inject === undefined
|
||||
? <Row {...viewProps} />
|
||||
: <Row {...{ ...cachedInject(resolved.inject, sessionId), ...viewProps }} />}
|
||||
</RowErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* Chat-side registration entry, called from the plugin apply (the assembly
|
||||
* point): registers the chat view with the stats-line footer chrome. The
|
||||
* chat domain touches the tool ring only through the contract resolver face;
|
||||
* bash sample registration moved to apply (cross-domain assembly).
|
||||
*/
|
||||
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationService } from '../service.ts'
|
||||
import type { Translate } from '../contract/views.ts'
|
||||
import type { ToolViewResolver } from '../contract/toolview.ts'
|
||||
import { createChatView } from './ChatView.tsx'
|
||||
import { StatsLine } from './StatsLine.tsx'
|
||||
|
||||
/** Read face of the sessions list store (subscription not needed: the filter
|
||||
* reads the latest snapshot at each resolve). */
|
||||
export interface SessionListReader { getSnapshot(): SessionListState }
|
||||
|
||||
/**
|
||||
* Default scoped-sample filter: the sub-session family. Sub-agent rows
|
||||
* rendering differently is the registry's canonical product scenario, and
|
||||
* forking gives W5 acceptance a real entry point to observe the differential.
|
||||
* @param list - injected sessions list read face.
|
||||
* @returns filter matching sessions with a parent.
|
||||
*/
|
||||
export function childSessionScope(list: SessionListReader): (sessionId: SessionId) => boolean {
|
||||
return sessionId => list.getSnapshot().byId[sessionId]?.parentId !== undefined
|
||||
}
|
||||
|
||||
/** Assembly inputs for {@link registerChat} (resolved by apply, not here). */
|
||||
export interface RegisterChatDeps {
|
||||
conversation: ConversationService
|
||||
/** Toolview read face consumed by the chat rows' outlet. */
|
||||
toolviews: ToolViewResolver
|
||||
/** Translator bound to the conversation namespace. */
|
||||
t: Translate
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the chat view (footer chrome included).
|
||||
* @param deps - assembled service instances.
|
||||
* @returns disposer removing the registration.
|
||||
*/
|
||||
export function registerChat(deps: RegisterChatDeps): () => void {
|
||||
const { conversation, toolviews, t } = deps
|
||||
return conversation.registerView({
|
||||
id: 'chat',
|
||||
label: 'Chat',
|
||||
order: 0,
|
||||
component: createChatView({ toolviews, t }),
|
||||
chrome: { footer: StatsLine },
|
||||
})
|
||||
}
|
||||
@@ -1,31 +1,101 @@
|
||||
/**
|
||||
* Slot-ring contract for the conversation package: the composed props shapes
|
||||
* its registrants mount into the layout-owned slots (conversation / details /
|
||||
* conversation.empty). Terminal slot design (§3): full component props are the
|
||||
* automatic shares — PropsRuntime<K> (framework standard kit) & PropsStore<H>
|
||||
* Slot-ring contract for the conversation package: the 'conversation.view'
|
||||
* slot this package declares (the view ring — one list entry per conversation
|
||||
* view tab), the chat view's per-tool row hole ('conversation.chat.toolview',
|
||||
* keyed on the wire tool name), and the composed props shapes its registrants
|
||||
* mount into the layout-owned slots (conversation / details /
|
||||
* conversation.empty) plus its own slots. Terminal slot design (§3): full
|
||||
* component props are the automatic shares — PropsRuntime<K> (framework
|
||||
* standard kit) & PropsRenderSlots<S> (declared children) & PropsStore<H>
|
||||
* (declared store's read/write faces) & the injected business face declared
|
||||
* here. No renderSlot share: none of the three registrations declares
|
||||
* children, so the zero-renderSlot inference applies.
|
||||
* here.
|
||||
*/
|
||||
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { SelectionTarget, ViewEntry } from './views.ts'
|
||||
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
|
||||
|
||||
/** The shared chat store handle type (apply constructs one; conversation and details both declare it). */
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/**
|
||||
* The conversation view ring: one list entry per view tab (chat here;
|
||||
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
|
||||
* ConversationRoot via `only: <active id>`. Declared by this package's
|
||||
* 'conversation' entry (declaring is claiming). Session scope: views read
|
||||
* the conversation snapshot through the standard kit.
|
||||
*/
|
||||
'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
|
||||
/**
|
||||
* The chat view's per-tool row hole: keyed dispatch on the wire tool name
|
||||
* (the key space is runtime-open — SlotMap declares slots, never keys).
|
||||
* Declared by the chat view entry (declaring is claiming); the render
|
||||
* site dispatches via `entryKey: toolName` with GenericToolCard as the
|
||||
* `fallback` for unregistered tools.
|
||||
*/
|
||||
'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* View-slot owner share: deliberately empty — ConversationRoot supplies
|
||||
* nothing at its renderSlot site (sessionId and the snapshot hook arrive as
|
||||
* framework-standard props; tool rows go through each view's own declared
|
||||
* toolview hole). Kept as the named owner seat so a future cross-view
|
||||
* payload has a home.
|
||||
*/
|
||||
export interface ConvViewOwnerProps {}
|
||||
|
||||
/**
|
||||
* Owner share of a per-view toolview slot: the call material the rendering
|
||||
* view supplies per row. Uniform across views — the trajectory/waterfall
|
||||
* toolview slots (same kind/scope/owner, names fixed by the slot-naming
|
||||
* discipline) land with their own row render sites; today only the chat slot
|
||||
* is declared (RendersCheck rejects a declaration nobody renders).
|
||||
*/
|
||||
export interface ToolRowOwnerProps {
|
||||
/** Tool call identity (details linkage; stable across running → settled). */
|
||||
callId: CallId
|
||||
/** Wire tool name (also the keyed dispatch key at the render site). */
|
||||
toolName: string
|
||||
/** Frozen call slice: the running call or the settled result node. */
|
||||
block: ToolCallBlock
|
||||
/** Open the details panel for this call (session-level facility, supplied by the view). */
|
||||
openDetails(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Full props of a registered tool-row component: the slot's runtime share
|
||||
* (owner payload + session standard kit + global seat). Registrants type
|
||||
* their component `FC<ToolRowProps & I>` with `I` inferred from their inject
|
||||
* factory. Declared against the chat slot; the three per-view toolview slots
|
||||
* share one declaration shape, so this alias serves them all.
|
||||
*/
|
||||
export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
|
||||
|
||||
/**
|
||||
* Base props of a conversation view entry: the framework standard kit for the
|
||||
* session-scope 'conversation.view' slot (useSession narrowed to the
|
||||
* conversation snapshot by the runtime merge, sessionId, useSessions).
|
||||
* Entries declaring the shared store or an inject face compose their shares
|
||||
* on top (the chat entry's {@link ChatViewSlotProps}); store-less pure
|
||||
* readers (ui-trajectory) take this base alone.
|
||||
*/
|
||||
export type ConvViewProps = PropsRuntime<'conversation.view'>
|
||||
|
||||
/** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */
|
||||
export type ChatStore = ReturnType<typeof createChatStore>
|
||||
|
||||
/**
|
||||
* Injected share of the conversation slot: plain data and callbacks only
|
||||
* (design §5 — hooks are framework-made). The store lines that used to ride
|
||||
* here live in the declared {@link ChatStore} now; ancestry derives from the
|
||||
* standard useSessions hook in-component; view rendering moved into the
|
||||
* component, which holds every share a view needs.
|
||||
* here live in the declared {@link ChatStore}; ancestry derives from the
|
||||
* standard useSessions hook in-component; views render through the declared
|
||||
* 'conversation.view' child slot, with this face projecting the tab strip.
|
||||
*/
|
||||
export interface ConversationInjected {
|
||||
/** View registry read face (uSES triple from the conversation service). */
|
||||
/** View tab read face (uSES triple over the 'conversation.view' slot ledger). */
|
||||
views: {
|
||||
list(): readonly ViewEntry[]
|
||||
list(): readonly ViewTab[]
|
||||
subscribe(fn: () => void): () => void
|
||||
version(): number
|
||||
}
|
||||
@@ -33,17 +103,29 @@ export interface ConversationInjected {
|
||||
send(text: string, mode: 'queue' | 'steer'): void
|
||||
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
|
||||
stop(): void
|
||||
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
|
||||
openDetails(target: SelectionTarget): void
|
||||
/** Pull one older history page. */
|
||||
loadOlder(): void
|
||||
/** Navigate to another session (breadcrumb ancestors). */
|
||||
open(id: SessionId): void
|
||||
}
|
||||
|
||||
/** Full conversation-slot component props: runtime share & store share & injected share. */
|
||||
/** Full conversation-slot component props: runtime share & view-slot render share & store share & injected share. */
|
||||
export type ConversationSlotProps =
|
||||
PropsRuntime<'conversation'> & PropsStore<ChatStore> & ConversationInjected
|
||||
PropsRuntime<'conversation'> & PropsRenderSlots<'conversation.view'> & PropsStore<ChatStore> & ConversationInjected
|
||||
|
||||
/**
|
||||
* Injected share of the chat view entry: the two callbacks whose targets live
|
||||
* outside the view (layout orchestration; the session object layer).
|
||||
*/
|
||||
export interface ChatViewInjected {
|
||||
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
|
||||
openDetails(target: SelectionTarget): void
|
||||
/** Pull one older history page. */
|
||||
loadOlder(): void
|
||||
}
|
||||
|
||||
/** Full chat-view component props: runtime share & the declared toolview hole's render share & store share & injected share. */
|
||||
export type ChatViewSlotProps =
|
||||
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview'>
|
||||
& PropsStore<ChatStore> & ChatViewInjected
|
||||
|
||||
/**
|
||||
* Injected share of the details slot: the panel is otherwise a pure reader of
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
* one-line summary and expanded-body text from the frozen call slice. No
|
||||
* inline output ever — full results live in the details panel.
|
||||
*/
|
||||
import type { ToolCallBlock } from './toolview.ts'
|
||||
// The block union's defining home is runtime (fold-product types); this
|
||||
// contract only forwards it (type-definition authority stays with the layer
|
||||
// that produces the values).
|
||||
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
export type { ToolCallBlock } from './toolview.ts'
|
||||
export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** The frozen slice the chat view hands to toolview components as `block`
|
||||
* (both members are cache-stable references off ConversationSnapshot). */
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* Tool-ring contract: the props surface handed to toolview components, the
|
||||
* registry's resolve/registration shapes, and the tool-call block union.
|
||||
* Shared face between the chat domain (ToolViewOutlet consumes resolve) and
|
||||
* the toolviews domain (registry implementation + sample rows); domain
|
||||
* implementation files import this, never each other.
|
||||
*/
|
||||
import type { FC } from 'react'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CallId, Translate } from './views.ts'
|
||||
|
||||
// The block union's defining home is runtime (fold-product types); the
|
||||
// contract only forwards it (type-definition authority stays with the layer
|
||||
// that produces the values).
|
||||
export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Props handed to registered toolview components. */
|
||||
export interface ToolViewProps {
|
||||
callId: CallId
|
||||
toolName: string
|
||||
block: ToolCallBlock
|
||||
useSession: UseSession
|
||||
actions: { openDetails(): void }
|
||||
t: Translate
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolview inject factory: produces the registrant's private injected share
|
||||
* `I`, called once per (registration x session) and cached by the render
|
||||
* outlet. Mirrors the slot inject shape (parameters derive from the
|
||||
* declaration): toolviews are session-domain by nature, so the factory
|
||||
* receives the session id only — service access goes through the
|
||||
* registrant's own apply-closure ctx (design §5; binding objects retired).
|
||||
*/
|
||||
export type ToolViewInject<I extends object> = (sessionId: SessionId) => I
|
||||
|
||||
/** Options accepted by the toolview registry's register; `I` is inferred from the inject factory. */
|
||||
export interface ToolViewOptions<I extends object = object> {
|
||||
/** Session filter; absent = global registration. */
|
||||
scope?: (sessionId: SessionId) => boolean
|
||||
/** Private inject factory merged into the row's props by the render outlet. */
|
||||
inject?: ToolViewInject<I>
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolved toolview registration. `I` is erased to `object` on the resolve
|
||||
* read face (storage erases the per-registration parameter; the outlet merges
|
||||
* injected props untyped — the register site already proved component ⊇ I).
|
||||
*/
|
||||
export interface ResolvedToolView<I extends object = object> {
|
||||
component: FC<ToolViewProps & I>
|
||||
inject?: ToolViewInject<I>
|
||||
}
|
||||
|
||||
/** The registry's read face consumed by render outlets (implementation lives in the toolviews domain). */
|
||||
export interface ToolViewResolver {
|
||||
/**
|
||||
* Resolve the renderer for a tool in a session. Order: scope match (later
|
||||
* registration wins) > global > undefined (caller falls back to the
|
||||
* generic card).
|
||||
* @param tool - tool name.
|
||||
* @param sessionId - session the row renders in.
|
||||
* @returns resolved view, or undefined when nothing matches.
|
||||
*/
|
||||
resolve(tool: string, sessionId: SessionId): ResolvedToolView | undefined
|
||||
/**
|
||||
* Subscribe to registration changes (synchronous).
|
||||
* @param fn - change callback.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void
|
||||
/**
|
||||
* Monotonic version for uSES pairing.
|
||||
* @returns current version.
|
||||
*/
|
||||
getVersion(): number
|
||||
}
|
||||
@@ -1,89 +1,39 @@
|
||||
/**
|
||||
* View-ring contract: the typed conversation view table, the chat store state
|
||||
* shared through it, and the props surfaces handed to registered views.
|
||||
* Shared face between the skeleton domain (ConversationRoot renders views)
|
||||
* and the chat domain (registers the chat view); domain implementation files
|
||||
* import this, never each other.
|
||||
* Shared conversation contract primitives: the view tab projection (slot
|
||||
* entries in 'conversation.view' surface as tabs), the chat store state
|
||||
* shared through the declared store, and the selection primitives every
|
||||
* domain consumes. Shared face between the skeleton domain (tab strip +
|
||||
* view outlet) and the chat domain; domain implementation files import this,
|
||||
* never each other. The view ring itself IS the 'conversation.view' slot
|
||||
* (contract in slots.ts) — the package-local view registry is retired, and
|
||||
* so is the hand-threaded translate channel (framework-level per-slot i18n
|
||||
* injection is the planned replacement).
|
||||
*/
|
||||
import type { FC } from 'react'
|
||||
import type { SnapshotSelectorHook, UseSession } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/**
|
||||
* One ConversationViewMap entry: per-view props extension shapes (design
|
||||
* ledger, view ring). `chromeProps` extends {@link ChromeProps} for the
|
||||
* view's chrome attachments; `extraProps` extends {@link ConvViewProps} for
|
||||
* the view component itself. Both optional — the common bases stay the floor.
|
||||
*/
|
||||
export interface ViewEntryDef { chromeProps?: object; extraProps?: object }
|
||||
|
||||
/**
|
||||
* Typed conversation view table; ui-trajectory merges {trajectory, waterfall}.
|
||||
* The chat entry is declared inline here (self-merge from a sibling module
|
||||
* trips TS6305 under tsc -b).
|
||||
*/
|
||||
export interface ConversationViewMap { chat: ViewEntryDef }
|
||||
|
||||
/** View id constrained to registered ConversationViewMap keys (all string literals; chat is declared inline). */
|
||||
export type ViewId = keyof ConversationViewMap
|
||||
|
||||
/** Per-view chrome props: the common base plus the entry's declared extension. */
|
||||
export type ChromePropsOf<Id extends ViewId> =
|
||||
ChromeProps & (ConversationViewMap[Id] extends { chromeProps: infer C extends object } ? C : object)
|
||||
|
||||
/** Per-view component props: the common base plus the entry's declared extension. */
|
||||
export type ConvViewPropsOf<Id extends ViewId> =
|
||||
ConvViewProps & (ConversationViewMap[Id] extends { extraProps: infer E extends object } ? E : object)
|
||||
|
||||
/** Tool call identity as carried on the wire (branded upstream in connection). */
|
||||
export type CallId = string
|
||||
|
||||
/** Translate function bound to a namespace via i18n. */
|
||||
export type Translate = (key: string, params?: Record<string, unknown>) => string
|
||||
|
||||
/** One registered conversation view (props positions keyed by the entry's declared shapes). */
|
||||
export interface ViewEntry<Id extends ViewId = ViewId> {
|
||||
id: Id
|
||||
label: string
|
||||
order?: number
|
||||
component: FC<ConvViewPropsOf<Id>>
|
||||
/** Per-view chrome attachments (chat mounts the stats line as footer). */
|
||||
chrome?: { header?: FC<ChromePropsOf<Id>>; footer?: FC<ChromePropsOf<Id>> }
|
||||
}
|
||||
|
||||
/** Props for view chrome attachments. */
|
||||
export interface ChromeProps { sessionId: SessionId; useSession: UseSession }
|
||||
|
||||
/** Selection target for the details linkage channel (toolcall is the step special case). */
|
||||
export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: CallId; toolName?: string }
|
||||
|
||||
/**
|
||||
* One conversation view tab, projected from a 'conversation.view' slot
|
||||
* entry's registration options (label falls back to the entry id).
|
||||
*/
|
||||
export interface ViewTab { id: string; label: string }
|
||||
|
||||
/**
|
||||
* Chat store state (slot terminal design §4): the per-session store shared by
|
||||
* the conversation and details registrations. `createChatStore` implements
|
||||
* this shape; views read it through {@link ConvViewProps}'s pass-through hook.
|
||||
* `view` may carry a stale persisted id after a view plugin unloads — the
|
||||
* registry is the runtime validator (unknown ids fall back to the first view).
|
||||
* the conversation, chat-view, and details registrations. `createChatStore`
|
||||
* implements this shape. `view` may carry a stale persisted id after a view
|
||||
* plugin unloads — the slot ledger is the runtime validator (unknown ids fall
|
||||
* back to the first registered view).
|
||||
*/
|
||||
export interface ChatStoreState {
|
||||
/** Details-linkage channel (conversation writes, details reads). */
|
||||
selection: SelectionTarget | null
|
||||
/** Composer draft (persisted; survives session switches and reloads). */
|
||||
draft: string
|
||||
/** Active conversation view id; null falls back to the first registered view. */
|
||||
view: ViewId | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Props handed to registered conversation views. `useSession` and `useStore`
|
||||
* are the framework hooks ConversationRoot received as a slot registrant,
|
||||
* passed through unchanged (hook transfer is plain props passing; no
|
||||
* business-made subscription exists on this path). No renderSlot share: the
|
||||
* view ring delegates no sub-slots.
|
||||
*/
|
||||
export interface ConvViewProps {
|
||||
sessionId: SessionId
|
||||
useSession: UseSession
|
||||
/** Chat store read face (selection is the only slice views consume today). */
|
||||
useStore: SnapshotSelectorHook<ChatStoreState>
|
||||
actions: { openDetails(t: SelectionTarget): void; loadOlder(): void }
|
||||
/** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */
|
||||
view: string | null
|
||||
}
|
||||
|
||||
@@ -1,34 +1,31 @@
|
||||
/**
|
||||
* Conversation domain plugin, browser half: skeleton (header/tabs/composer),
|
||||
* typed view registry, scope-addressed ConversationService, named toolview
|
||||
* registry, minimal details panel. Contract: api-contracts v3 section 7.
|
||||
* Thin shell: type surfaces live in contract/, assembly in apply.ts; the
|
||||
* three implementation domains (skeleton/chat/toolviews) never import each
|
||||
* other — contract/ is their only shared face.
|
||||
* the 'conversation.view' slot ring (chat entry here; other plugins
|
||||
* contribute view tabs through ctx.slots), the chat view's keyed
|
||||
* 'conversation.chat.toolview' row hole, scope-addressed ConversationService,
|
||||
* minimal details panel. Contract: api-contracts v3 section 7. Thin shell:
|
||||
* type surfaces live in contract/, assembly in apply.ts; the implementation
|
||||
* domains (skeleton/chat) never import each other — contract/ is their only
|
||||
* shared face.
|
||||
*/
|
||||
import type { ConversationService } from './service.ts'
|
||||
import type { ToolViewRegistry } from './toolviews/registry.ts'
|
||||
|
||||
export { apply, inject } from './apply.ts'
|
||||
export { ConversationService } from './service.ts'
|
||||
export { ToolViewRegistry } from './toolviews/registry.ts'
|
||||
|
||||
export type {
|
||||
CallId, ChatStoreState, ChromeProps, ChromePropsOf, ConversationViewMap, ConvViewProps,
|
||||
ConvViewPropsOf, SelectionTarget, Translate, ViewEntry, ViewEntryDef, ViewId,
|
||||
CallId, ChatStoreState, SelectionTarget, ViewTab,
|
||||
} from './contract/views.ts'
|
||||
export type { ToolCallBlock } from './contract/tool-call-model.ts'
|
||||
export type {
|
||||
ResolvedToolView, ToolCallBlock, ToolViewOptions, ToolViewProps, ToolViewResolver,
|
||||
} from './contract/toolview.ts'
|
||||
export type {
|
||||
ChatStore, ConversationInjected, ConversationSlotProps, DetailsInjected, DetailsSlotProps,
|
||||
EmptyStateInjected, EmptyStateSlotProps,
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, ConversationInjected, ConversationSlotProps,
|
||||
ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
|
||||
EmptyStateInjected, EmptyStateSlotProps, ToolRowOwnerProps, ToolRowProps,
|
||||
} from './contract/slots.ts'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
conversation: ConversationService
|
||||
toolviews: ToolViewRegistry
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* ConversationService implementation: scope-addressed send/cancel, view
|
||||
* registry with a uSES read face, and the empty-state startSession chain.
|
||||
* Contract: api-contracts v3 section 7. Selection/draft state moved to the
|
||||
* declared chat store (slot terminal design §4) — the per-scope store maps,
|
||||
* lazy construction, and prune bookkeeping this service used to carry are
|
||||
* retired; what remains is the send/stop orchestration face.
|
||||
* ConversationService implementation: scope-addressed send/cancel and the
|
||||
* empty-state startSession chain. Contract: api-contracts v3 section 7.
|
||||
* Selection/draft state moved to the declared chat store (slot terminal
|
||||
* design §4); the view registry moved to the 'conversation.view' slot (slot
|
||||
* ledger owns registration, ordering, and disposal) — what remains is the
|
||||
* send/stop orchestration face.
|
||||
*
|
||||
* Scope addressing rides the cordis Service tracker: property access through
|
||||
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
|
||||
@@ -23,23 +23,9 @@ import type { Context } from 'cordis'
|
||||
// in the browser while unit tests (single-instance path resolution) stay green.
|
||||
import { scopeOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { Session, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ViewEntry, ViewId } from './index.ts'
|
||||
|
||||
/** Mutable view-registry cell (plain object: mutation never crosses the tracker proxy). */
|
||||
interface ViewsState {
|
||||
entries: Map<string, ViewEntry>
|
||||
/** Sorted projection cache; null = rebuild on next read. */
|
||||
cache: readonly ViewEntry[] | null
|
||||
tick: number
|
||||
listeners: Set<() => void>
|
||||
}
|
||||
|
||||
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
|
||||
export class ConversationService extends Service {
|
||||
private readonly viewsState: ViewsState = {
|
||||
entries: new Map(), cache: null, tick: 0, listeners: new Set(),
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ctx - owning root context (the plugin apply context; the service
|
||||
* registers itself and follows that fiber's lifetime).
|
||||
@@ -68,60 +54,6 @@ export class ConversationService extends Service {
|
||||
if (!result.ok) throw new Error(`conversation.cancel failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a conversation view. Duplicate ids throw; the registration is an
|
||||
* effect on the caller's fiber (plugin unload collects it).
|
||||
* @param entry - the view entry.
|
||||
* @returns disposer removing the view.
|
||||
*/
|
||||
registerView<Id extends ViewId>(entry: ViewEntry<Id>): () => void {
|
||||
const views = this.viewsState
|
||||
const dispose = this.ctx.effect(() => {
|
||||
if (views.entries.has(entry.id)) {
|
||||
throw new Error(`conversation view "${entry.id}" is already registered`)
|
||||
}
|
||||
views.entries.set(entry.id, entry)
|
||||
bumpViews(views)
|
||||
return () => {
|
||||
views.entries.delete(entry.id)
|
||||
bumpViews(views)
|
||||
}
|
||||
}, 'conversation.registerView()')
|
||||
// The effect disposer settles asynchronously; the registry face stays a
|
||||
// synchronous fire-and-forget disposer.
|
||||
return () => { void dispose() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered views ordered by `order` (ties keep registration sequence).
|
||||
* Stable array reference between mutations (uSES getSnapshot source).
|
||||
* @returns the view entries.
|
||||
*/
|
||||
views(): readonly ViewEntry[] {
|
||||
const state = this.viewsState
|
||||
state.cache ??= [...state.entries.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
return state.cache
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to view registry changes (synchronous, like the toolview registry).
|
||||
* @param fn - change callback.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribeViews(fn: () => void): () => void {
|
||||
const { listeners } = this.viewsState
|
||||
listeners.add(fn)
|
||||
return () => { listeners.delete(fn) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotonic view registry version for uSES pairing.
|
||||
* @returns current version.
|
||||
*/
|
||||
viewsVersion(): number {
|
||||
return this.viewsState.tick
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty-state first-send chain (root-context method; does not read scope):
|
||||
* create the session, navigate to it, then send through the new scope.
|
||||
@@ -167,9 +99,3 @@ export class ConversationService extends Service {
|
||||
return sessions
|
||||
}
|
||||
}
|
||||
|
||||
function bumpViews(state: ViewsState): void {
|
||||
state.cache = null
|
||||
state.tick += 1
|
||||
for (const fn of [...state.listeners]) fn()
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
// ConversationRoot: the conversation slot's skeleton (figma Header 39:27730 +
|
||||
// Tab_Group + view area + composer). Pure component — everything arrives via
|
||||
// props: the framework standard kit (useSession/sessionId/useSessions), the
|
||||
// declared chat store's useStore/actions, and the injected business face.
|
||||
// declared chat store's useStore/actions, the injected business face, and the
|
||||
// renderSlot share for the declared 'conversation.view' child slot (views are
|
||||
// slot entries; the active one renders via the list `only` filter).
|
||||
// Breadcrumbs derive from useSessions with a pure parentId walk; the active
|
||||
// view id lives in the chat store's `view` field (per-session by store scope).
|
||||
|
||||
import { useMemo, useSyncExternalStore, type ReactNode } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSlotProps } from '../contract/slots.ts'
|
||||
import type { ConvViewProps, ViewEntry } from '../contract/views.ts'
|
||||
import { InputBar } from './InputBar.tsx'
|
||||
import type { InputBarError } from './InputBar.tsx'
|
||||
import css from './ConversationRoot.module.css'
|
||||
@@ -35,15 +36,15 @@ function deriveAncestry(list: SessionListState, id: SessionId): readonly Session
|
||||
}
|
||||
|
||||
export function ConversationRoot({
|
||||
sessionId, useSession, useSessions, useStore, actions,
|
||||
views, send, stop, openDetails, loadOlder, open,
|
||||
sessionId, useSession, useSessions, useStore, actions, renderSlot,
|
||||
views, send, stop, open,
|
||||
}: ConversationRootProps) {
|
||||
useSyncExternalStore(views.subscribe, views.version)
|
||||
const list = views.list()
|
||||
const tabs = views.list()
|
||||
// The store's persisted view id may be stale (view plugin unloaded); the
|
||||
// registry is the runtime validator — unknown ids fall to the first view.
|
||||
// slot ledger is the runtime validator — unknown ids fall to the first view.
|
||||
const activeId = useStore(s => s.view) ?? 'chat'
|
||||
const active = list.find(v => v.id === activeId) ?? list[0]
|
||||
const active = tabs.find(v => v.id === activeId) ?? tabs[0]
|
||||
|
||||
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
|
||||
const draft = useStore(s => s.draft)
|
||||
@@ -56,27 +57,6 @@ export function ConversationRoot({
|
||||
? null
|
||||
: { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` }
|
||||
|
||||
// Views receive the shares this component already holds (hook transfer is
|
||||
// plain props passing); the callback slice is referentially stable per
|
||||
// injected identity so memoized view rows hold.
|
||||
const viewProps = useMemo<ConvViewProps>(() => ({
|
||||
sessionId, useSession, useStore,
|
||||
actions: { openDetails, loadOlder },
|
||||
}), [sessionId, useSession, useStore, openDetails, loadOlder])
|
||||
|
||||
const renderView = (entry: ViewEntry): ReactNode => {
|
||||
const Header = entry.chrome?.header
|
||||
const Footer = entry.chrome?.footer
|
||||
const View = entry.component
|
||||
return (
|
||||
<>
|
||||
{Header !== undefined && <Header sessionId={sessionId} useSession={useSession} />}
|
||||
<View {...viewProps} />
|
||||
{Footer !== undefined && <Footer sessionId={sessionId} useSession={useSession} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<header className={css.header}>
|
||||
@@ -104,9 +84,9 @@ export function ConversationRoot({
|
||||
{/* Header button row (Fork / Session log / I/O Details): a P-I visual
|
||||
placeholder registry slot is deferred — buttons land with their features. */}
|
||||
</div>
|
||||
{list.length > 1 && (
|
||||
{tabs.length > 1 && (
|
||||
<div className={css.tabs} role="tablist">
|
||||
{list.map(v => (
|
||||
{tabs.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
type="button"
|
||||
@@ -123,7 +103,7 @@ export function ConversationRoot({
|
||||
</header>
|
||||
|
||||
<div className={css.viewArea}>
|
||||
{active !== undefined && renderView(active)}
|
||||
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
|
||||
</div>
|
||||
|
||||
<InputBar
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* in the module cache (a de-facto singleton surviving plugin reloads).
|
||||
*/
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatStoreState, SelectionTarget, ViewId } from './contract/views.ts'
|
||||
import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
|
||||
|
||||
/**
|
||||
* Annotation twin of the actions literal below (the export needs a declared
|
||||
@@ -21,22 +21,22 @@ type ChatActions = {
|
||||
setDraft: (draft: ChatStoreState, text: string) => void
|
||||
clearDraft: (draft: ChatStoreState) => void
|
||||
restoreDraft: (draft: ChatStoreState, text: string) => void
|
||||
setView: (draft: ChatStoreState, view: ViewId) => void
|
||||
setView: (draft: ChatStoreState, view: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the per-session chat store. `selection` is the details-linkage
|
||||
* channel (conversation writes, details reads); `draft` is the composer text
|
||||
* (persisted so it survives session switches and reloads); `view` is the
|
||||
* active conversation view id (previously layout.viewFor — store seat is the
|
||||
* cross-remount survival channel, null falls back to the first registered view).
|
||||
* active conversation view id (a 'conversation.view' entry id — store seat is
|
||||
* the cross-remount survival channel, null falls back to the first view).
|
||||
* @returns the store handle (spec + identity + factory in one value).
|
||||
*/
|
||||
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
|
||||
return defineStore({
|
||||
// Anchored to the contract shape: views consume the store through
|
||||
// ConvViewProps' SnapshotSelectorHook<ChatStoreState>, so init and the
|
||||
// contract cannot drift.
|
||||
// Anchored to the contract shape: consumers read the store through
|
||||
// PropsStore<ChatStore>'s SnapshotSelectorHook<ChatStoreState>, so init
|
||||
// and the contract cannot drift.
|
||||
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
|
||||
persist: 'dsh.conversation.chat',
|
||||
actions: {
|
||||
@@ -46,7 +46,7 @@ export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions
|
||||
// Optimistic-send failure restore: only when the user typed nothing new
|
||||
// since the clear (send choreography lives in the inject factory).
|
||||
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
|
||||
setView: (d, view: ViewId) => { d.view = view },
|
||||
setView: (d, view: string) => { d.view = view },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
// Bash toolview sample, written in third-party posture: everything below uses
|
||||
// only the public registration surface (ctx.toolviews.register + ToolViewProps)
|
||||
// — the differential-rendering acceptance proof for the registry chain.
|
||||
// Two registrations: a global bash row, and a scope-filtered variant that
|
||||
// takes over for matching sessions only (later registration wins its tier).
|
||||
// only the public slot surface (ctx.slots.register into the keyed
|
||||
// 'conversation.chat.toolview' hole + ToolRowProps) — the acceptance proof
|
||||
// that a plain plugin can take over a tool row with zero dedicated machinery.
|
||||
// Session-dimension differentiation happens INSIDE the component (the
|
||||
// canonical sub-agent scenario): rows in child sessions render the scoped
|
||||
// variant, derived from the standard useSessions kit — no registry predicates.
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ToolViewProps } from '../contract/toolview.ts'
|
||||
import type { ToolViewRegistry } from './registry.ts'
|
||||
import { toolRowModel, type ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import css from './bash-sample.module.css'
|
||||
|
||||
/** Global bash row: command-first monospace summary (replaces the generic row). */
|
||||
export function BashRow({ toolName, block, actions }: ToolViewProps) {
|
||||
const model = toolRowModel(toolName, block as ToolCallBlock)
|
||||
/** Bash row: command-first monospace summary replacing the generic card.
|
||||
* Sub-session rows (parentId present) swap the prompt for a scoped badge —
|
||||
* the differential stays observable per session from one registration. */
|
||||
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
||||
if (isChild) {
|
||||
return (
|
||||
<div className={css.row} data-sample="bash-scoped" onClick={openDetails}>
|
||||
<span className={css.scopeBadge}>scoped</span>
|
||||
<span className={css.command}>{model.summary}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={css.row} data-sample="bash-global" onClick={actions.openDetails}>
|
||||
<div className={css.row} data-sample="bash-global" onClick={openDetails}>
|
||||
<span className={css.prompt} aria-hidden>$</span>
|
||||
<span className={css.command}>{model.summary}</span>
|
||||
{model.state === 'error' && <span className={css.err}>failed</span>}
|
||||
@@ -22,31 +34,20 @@ export function BashRow({ toolName, block, actions }: ToolViewProps) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Scoped variant: visually distinct so the differential hit is observable. */
|
||||
export function ScopedBashRow({ toolName, block, actions }: ToolViewProps) {
|
||||
const model = toolRowModel(toolName, block as ToolCallBlock)
|
||||
return (
|
||||
<div className={css.row} data-sample="bash-scoped" onClick={actions.openDetails}>
|
||||
<span className={css.scopeBadge}>scoped</span>
|
||||
<span className={css.command}>{model.summary}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register both sample rows.
|
||||
* @param toolviews - the conversation plugin's registry service.
|
||||
* @param scope - session filter for the scoped variant.
|
||||
* @returns disposer removing both registrations.
|
||||
* The sample as a plain registrant plugin. `inject` carries the load-order
|
||||
* seam: requiring the conversation service guarantees the chat entry (and
|
||||
* with it the 'conversation.chat.toolview' declaration) is registered —
|
||||
* ui-conversation's apply mounts the service after the chat entry.
|
||||
*/
|
||||
export function registerBashSamples(
|
||||
toolviews: ToolViewRegistry,
|
||||
scope: (sessionId: SessionId) => boolean,
|
||||
): () => void {
|
||||
const offGlobal = toolviews.register('bash', BashRow)
|
||||
const offScoped = toolviews.register('bash', ScopedBashRow, { scope })
|
||||
return () => {
|
||||
offGlobal()
|
||||
offScoped()
|
||||
}
|
||||
export const bashToolviewSample = {
|
||||
name: 'bash-toolview-sample',
|
||||
inject: ['slots', 'conversation'],
|
||||
/**
|
||||
* Register the bash row into the chat view's keyed toolview hole.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash' }, BashRow)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* ToolViewRegistry: named per-tool component registry, session-scope aware
|
||||
* (api-contracts v3 section 7). Consumed by chat now, trajectory/waterfall
|
||||
* later — deliberately a named service, not a SlotMap key. The tool key set
|
||||
* is deliberately open (model-side tools arrive at runtime): the strong
|
||||
* typing lives inside the Entry — `I` is inferred from the inject factory at
|
||||
* the register site and proves component props ⊇ ToolViewProps & I.
|
||||
*/
|
||||
import type { FC } from 'react'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ResolvedToolView, ToolViewOptions, ToolViewProps } from '../contract/toolview.ts'
|
||||
|
||||
/** Stored registration: the per-registration inject parameter is erased
|
||||
* (storage-erase/read-restore is the typed-Map boundary, one cast budgeted). */
|
||||
interface Registration extends ToolViewOptions {
|
||||
component: FC<ToolViewProps & object>
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tool renderer registry. Resolution order: scope match (later
|
||||
* registration wins) > global (same tie-break) > undefined, where the caller
|
||||
* falls back to GenericToolCard.
|
||||
*/
|
||||
export class ToolViewRegistry {
|
||||
private byTool = new Map<string, Registration[]>()
|
||||
private version = 0
|
||||
private listeners = new Set<() => void>()
|
||||
|
||||
/**
|
||||
* Register a tool row renderer. The component must accept the shared
|
||||
* ToolViewProps plus its own injected share `I` — mismatches (missing keys,
|
||||
* wrong types, an inject factory that does not produce what the component
|
||||
* declares) are register-site compile errors.
|
||||
* @param tool - tool name the renderer takes over.
|
||||
* @param component - row component over ToolViewProps & I.
|
||||
* @param opts - optional session-scope filter and private inject factory.
|
||||
* @returns disposer removing this registration.
|
||||
*/
|
||||
register<I extends object = object>(
|
||||
tool: string, component: FC<ToolViewProps & I>, opts?: ToolViewOptions<I>): () => void {
|
||||
const list = this.byTool.get(tool) ?? []
|
||||
if (list.length === 0) this.byTool.set(tool, list)
|
||||
// Storage erases I (heterogeneous registrations share one list); resolve
|
||||
// restores the erased shape on the read face.
|
||||
const entry: Registration = { component: component as FC<ToolViewProps & object>, ...opts }
|
||||
list.push(entry)
|
||||
this.bump()
|
||||
let disposed = false
|
||||
return () => {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
const at = list.indexOf(entry)
|
||||
/* v8 ignore next -- negative arm: an entry lives in one list and only its
|
||||
own once-guarded disposer removes it, so a live disposer always finds it. */
|
||||
if (at >= 0) list.splice(at, 1)
|
||||
if (list.length === 0) this.byTool.delete(tool)
|
||||
this.bump()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the renderer for a tool in a session.
|
||||
* @param tool - tool name.
|
||||
* @param sessionId - session the row renders in (fed to scope filters).
|
||||
* @returns resolved view, or undefined when nothing matches.
|
||||
*/
|
||||
resolve(tool: string, sessionId: SessionId): ResolvedToolView | undefined {
|
||||
const list = this.byTool.get(tool)
|
||||
if (list === undefined) return undefined
|
||||
let global: Registration | undefined
|
||||
let scoped: Registration | undefined
|
||||
for (const entry of list) {
|
||||
if (entry.scope === undefined) global = entry
|
||||
else if (entry.scope(sessionId)) scoped = entry
|
||||
}
|
||||
const hit = scoped ?? global
|
||||
if (hit === undefined) return undefined
|
||||
return hit.inject === undefined ? { component: hit.component } : { component: hit.component, inject: hit.inject }
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to registration changes (render outlets re-resolve on notify).
|
||||
* @param fn - change listener.
|
||||
* @returns disposer.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.listeners.add(fn)
|
||||
return () => this.listeners.delete(fn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotonic registration version for uSES getSnapshot.
|
||||
* @returns current version.
|
||||
*/
|
||||
getVersion(): number {
|
||||
return this.version
|
||||
}
|
||||
|
||||
private bump(): void {
|
||||
this.version += 1
|
||||
for (const fn of this.listeners) fn()
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,10 @@ export const name = 'client-ui-conversation-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the conversation service emits no cordis events — its
|
||||
* view and toolview registries notify through package-local subscribe faces
|
||||
* whose ordering (synchronous version bump before notification) is exercised
|
||||
* directly by the behavior specs, and the per-scope store accounts are owned
|
||||
* mutable state with no cross-plugin observer to contradict.
|
||||
* No runtime invariant: the conversation service emits no cordis events, and
|
||||
* both rings this package owns (the 'conversation.view' tab ring and the
|
||||
* 'conversation.chat.toolview' row hole) ride the slot system, whose ledger
|
||||
* invariants live with the runtime slots package.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user