Merge remote-tracking branch 'origin/master' into worktree/web-theme-settings-integration-fde706
# Conflicts: # .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml # .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md # .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml # .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md # .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml # .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md # .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml # apps/web/tests/scaffold.ts # docs/module-graph.md # packages/client/runtime/README.i18n.yaml # packages/client/runtime/package.json # packages/client/test-runtime/README.i18n.yaml # packages/client/test-runtime/README.zh.md # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/package.json # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-theme/README.i18n.yaml # packages/client/ui-theme/README.md # packages/client/ui-theme/README.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.zh.md # pnpm-lock.yaml
This commit is contained in:
@@ -9,8 +9,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ViewTab } from './contract/views.ts'
|
||||
import type {
|
||||
ApprovalWait, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
|
||||
ConversationSessionHeaderInjected, ConversationSessionInjected, DetailsInjected,
|
||||
ApprovalWait, ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, ComposerBarInjected,
|
||||
ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected,
|
||||
DetailsInjected,
|
||||
} from './contract/slots.ts'
|
||||
import type { InputNotice } from './input/contract.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
@@ -32,6 +33,8 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
|
||||
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
import { en, NS, zh, type ConversationKey } from './locales.ts'
|
||||
import { registerConversationNodes } from './conversation-nodes/register.ts'
|
||||
import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
|
||||
import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
@@ -42,7 +45,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection']
|
||||
export const inject = [
|
||||
'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection',
|
||||
'conversationEvents', 'conversationViews',
|
||||
]
|
||||
|
||||
// Static no-session sources for the composer-bar hooks compartment: module
|
||||
// constants so the render side's per-source hook cache (observableHook) keeps
|
||||
@@ -66,6 +72,19 @@ const ABSENT_MENU_LAUNCHER = {
|
||||
subscribe: () => () => {},
|
||||
}
|
||||
|
||||
const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
|
||||
hooks: {
|
||||
turnData: ({ useSession }, nodeKey) => function useTurnData(key) {
|
||||
return useSession((snapshot) => {
|
||||
const location = snapshot.chat.nodes.get(nodeKey)?.location
|
||||
return location?.kind === 'turn' || location?.kind === 'step'
|
||||
? location.turn.data.get(key)
|
||||
: undefined
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
|
||||
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
|
||||
const scoped = sessions.scope(id)
|
||||
@@ -89,6 +108,9 @@ export function apply(ctx: Context): void {
|
||||
const layout = ctx.layout
|
||||
const slots = ctx.slots
|
||||
|
||||
registerConversationNodes(ctx)
|
||||
registerChatNodeRenderers(ctx)
|
||||
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries')
|
||||
|
||||
// Registration-time text (the view tab label) reads through the bound
|
||||
@@ -143,7 +165,7 @@ export function apply(ctx: Context): void {
|
||||
// way: this package must not import the plugins that would know.
|
||||
const composerBlocks = new ComposerBlockRegistry()
|
||||
|
||||
// Decision 19/20: the input machine feeds every session-scope slot
|
||||
// The input machine feeds every session-scope slot
|
||||
// component through the standard provide channel — the 'input' hook plus
|
||||
// the two public actions. Materialization is the shell creation trigger
|
||||
// (per-session lazy; scope disposer tears down).
|
||||
@@ -175,6 +197,7 @@ export function apply(ctx: Context): void {
|
||||
'conversation.input.left': { kind: 'list', scope: 'session' },
|
||||
'conversation.input.right': { kind: 'list', scope: 'session' },
|
||||
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
|
||||
'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
|
||||
hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId) },
|
||||
@@ -224,7 +247,7 @@ export function apply(ctx: Context): void {
|
||||
}, ConversationSessionHeader)
|
||||
|
||||
// The default composer body: its own single slot inside the composer
|
||||
// chain's fallback (decision 20). Public machine surface arrives via the
|
||||
// chain's fallback. Public machine surface arrives via the
|
||||
// provide channel above; the keyboard command face and the stop/retry
|
||||
// verbs ride this inject (package-internal — hub and bar are one plugin).
|
||||
// Session-maybe: with no current session the machine faces are absent and
|
||||
@@ -235,7 +258,7 @@ export function apply(ctx: Context): void {
|
||||
locale: NS,
|
||||
// The two named control seats in the bar's tool row (plan beside the
|
||||
// access control, model right); empty until their owning plugins
|
||||
// register (B ruling).
|
||||
// register.
|
||||
children: {
|
||||
'conversation.input.plan': { kind: 'single', scope: 'session' },
|
||||
'conversation.input.model': { kind: 'single', scope: 'session' },
|
||||
@@ -301,8 +324,8 @@ export function apply(ctx: Context): void {
|
||||
slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel)
|
||||
|
||||
// The chat view: first entry of the ring this package just declared.
|
||||
// ChatView owns ordered Tool placement but delegates each whole root call
|
||||
// to ui-tool, which owns root/subcall composition and atomic dispatch.
|
||||
// ChatView owns only the stable ordered Node list. Business renderers are
|
||||
// independently keyed behind its one Node seat.
|
||||
slots.register({
|
||||
name: 'conversation.view',
|
||||
id: 'chat',
|
||||
@@ -310,9 +333,7 @@ export function apply(ctx: Context): void {
|
||||
label: () => t('view.chat'),
|
||||
locale: NS,
|
||||
children: {
|
||||
'conversation.chat.tool': { kind: 'single', scope: 'session' },
|
||||
'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
|
||||
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
|
||||
'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT },
|
||||
},
|
||||
store: chatStore,
|
||||
inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
|
||||
@@ -322,6 +343,7 @@ export function apply(ctx: Context): void {
|
||||
actions.select(target)
|
||||
layout.openDetails()
|
||||
},
|
||||
fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner),
|
||||
openFile: (path) => {
|
||||
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
|
||||
void workspaces.openPath(resolveWorkspacePath(cwd, path)).catch(() => {
|
||||
@@ -366,8 +388,8 @@ export function apply(ctx: Context): void {
|
||||
// The plan strip rides the input dock above the queue rows (same posture).
|
||||
ctx.plugin(todoDockEntry)
|
||||
|
||||
// The read-only queue dock entry (T9 file territory) rides the same
|
||||
// registration seam into the input dock declared above.
|
||||
// The read-only queue dock entry rides the same
|
||||
// registration path into the input dock declared above.
|
||||
ctx.plugin(queueDockEntry)
|
||||
|
||||
slots.register({
|
||||
|
||||
@@ -11,11 +11,9 @@
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts'
|
||||
import { hasContentText } from './chat-flow.ts'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { ReasoningRow } from './ReasoningRow.tsx'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
|
||||
@@ -24,41 +22,15 @@ export interface AssistantMarkdownProps {
|
||||
streaming: boolean
|
||||
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
|
||||
interrupted?: boolean | undefined
|
||||
/** Unix epoch ms for the IconActions clock; omitted while streaming or when
|
||||
* the parent withholds chrome (mid-turn content assistants and every node
|
||||
* of a turn that has not ended). */
|
||||
time?: number | undefined
|
||||
/** Turn wall time in ms for the IconActions run-time label; omitted when the
|
||||
* turn's triggering input is outside the loaded window. */
|
||||
runMs?: number | undefined
|
||||
/** Turn first-step TTFT in ms for the IconActions label; omitted when unrecorded. */
|
||||
ttftMs?: number | undefined
|
||||
/** Turn decode throughput for the IconActions label; omitted when unrecorded. */
|
||||
tokensPerSecond?: number | undefined
|
||||
/** Event sequence used as the fork boundary; omitted while streaming. */
|
||||
seq?: number | undefined
|
||||
/** Fork the session through this finalized message's completed turn when eligible. */
|
||||
onFork?: ((seq: number) => void) | undefined
|
||||
/** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */
|
||||
turnTail?: (Pick<PropsRenderSlots<'conversation.chat.turnTail'>, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined
|
||||
/** The message is not the transcript tail of a completed turn. */
|
||||
forkUnavailable?: boolean | undefined
|
||||
/** Resolved prose file mentions for this Assistant's closing turn. */
|
||||
mentions?: MarkdownFileMentions | undefined
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
/** Joined text blocks for the copy action (reasoning / tool heads stay out). */
|
||||
function copyText(blocks: readonly AssistantBlock[]): string {
|
||||
const parts: string[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.kind === 'text') parts.push(block.text)
|
||||
}
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t,
|
||||
blocks, streaming, interrupted, mentions, t,
|
||||
}: AssistantMarkdownProps) {
|
||||
// Stable per locale revision (t identity changes on switch): a fresh object
|
||||
// per render would rebuild MarkdownText's component table every chunk.
|
||||
@@ -71,15 +43,19 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
|| interrupted === true
|
||||
|| blocks.some(block => block.kind !== 'tool-call')
|
||||
if (!hasVisible) return null
|
||||
// Footer only under settled content text; Think-only / streaming omit it.
|
||||
const showActions = !streaming && time !== undefined && hasContentText(blocks)
|
||||
return (
|
||||
<div className={css.root} data-streaming={streaming || undefined} data-time-hover-root>
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
<div className={css.body}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
case 'text': return (
|
||||
<MarkdownText key={i} text={block.text} streaming={streaming} codeLabels={codeLabels} />
|
||||
<MarkdownText
|
||||
key={i}
|
||||
text={block.text}
|
||||
streaming={streaming}
|
||||
codeLabels={codeLabels}
|
||||
fileMentions={mentions}
|
||||
/>
|
||||
)
|
||||
case 'reasoning': return <ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />
|
||||
// Grouped into tool rows by ChatView; hasVisible above skips an empty shell.
|
||||
@@ -96,21 +72,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
})}
|
||||
{interrupted && <span className={css.stopped}>{t('message.stopped')}</span>}
|
||||
</div>
|
||||
{showActions && turnTail?.renderSlotChain('conversation.chat.turnTail', turnTail.owner)}
|
||||
{showActions && (
|
||||
<MessageIconActions
|
||||
text={copyText(blocks)}
|
||||
time={time}
|
||||
runMs={runMs}
|
||||
ttftMs={ttftMs}
|
||||
tokensPerSecond={tokensPerSecond}
|
||||
clock="end"
|
||||
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
|
||||
branchUnavailable={forkUnavailable}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
|
||||
/** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */
|
||||
export const AssistantNodeView = memo(function AssistantNodeView({
|
||||
node, useTurnData, openFile, fileMentions, t,
|
||||
}: ChatNodeViewProps<'assistant-step'>) {
|
||||
const data = node.data
|
||||
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
||||
? node.location.turn
|
||||
: undefined
|
||||
const tail = useTurnData('turn-tail')
|
||||
const owner = useMemo<TurnTailOwnerProps | undefined>(() => {
|
||||
if (turn?.status !== 'closed' || data.finalNode === undefined) return undefined
|
||||
if (tail?.closing?.finalNode.seq !== data.finalNode.seq) return undefined
|
||||
return { turn, seq: data.finalNode.seq, openFile }
|
||||
}, [data.finalNode, openFile, tail, turn])
|
||||
const mentions = useMemo(
|
||||
() => owner === undefined ? undefined : fileMentions(owner),
|
||||
[fileMentions, owner],
|
||||
)
|
||||
return (
|
||||
<AssistantMarkdown
|
||||
blocks={data.blocks}
|
||||
streaming={data.status === 'running'}
|
||||
interrupted={data.status === 'interrupted'}
|
||||
mentions={mentions}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
interface ChatNodeSeatProps extends ChatNodeOwnerProps {
|
||||
readonly nodeKey: string
|
||||
readonly useSession: ChatViewSlotProps['useSession']
|
||||
readonly renderSlot: ChatViewSlotProps['renderSlot']
|
||||
readonly t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
type RoutedChatNodeOwner = {
|
||||
[Kind in ChatNode['kind']]: ChatNodeOwnerProps & { readonly node: ChatNode<Kind> }
|
||||
}[ChatNode['kind']]
|
||||
|
||||
/** Subscribe and dispatch one stable Context key without observing sibling Nodes. */
|
||||
export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt,
|
||||
fileMentions, useSession, renderSlot, t,
|
||||
}: ChatNodeSeatProps) {
|
||||
const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey))
|
||||
const routedNode = node as ChatNode | undefined
|
||||
const owner = useMemo<ChatNodeOwnerProps | null>(() => node === undefined
|
||||
? null
|
||||
: {
|
||||
selectedCallId,
|
||||
cwd,
|
||||
openFile,
|
||||
inspectCall,
|
||||
forkAt,
|
||||
fileMentions,
|
||||
}, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, fileMentions])
|
||||
if (routedNode === undefined || owner === null) return null
|
||||
// Runtime dispatch owns the correlation: every Node's discriminant is the
|
||||
// keyed-slot entry passed alongside that same Node. TypeScript does not
|
||||
// distribute an object containing a union into a union of objects itself.
|
||||
const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner
|
||||
return (
|
||||
<div
|
||||
className={css.flowItem}
|
||||
data-chat-anchor-key={routedNode.key}
|
||||
data-chat-flow-key={routedNode.key}
|
||||
data-chat-flow-kind={routedNode.kind}
|
||||
>
|
||||
{renderSlot('conversation.chat.node', routedOwner, {
|
||||
entryKey: routedNode.kind,
|
||||
hookContext: nodeKey,
|
||||
fallback: (
|
||||
<JsonBlock
|
||||
label={t('message.unknownSurface', { type: routedNode.kind })}
|
||||
payload={routedNode.data}
|
||||
truncatedLabel={total => t('json.truncated', { total })}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,5 @@
|
||||
/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool
|
||||
runs) via the column gap and between consecutive tool rows via the group
|
||||
gap. Input padding cap rides the skeleton. Under
|
||||
/* Chat flow: one 16px rhythm everywhere through the column gap. Input
|
||||
padding cap rides the skeleton. Under
|
||||
`[data-conversation-scroll]` the column host owns overflow and this view
|
||||
is ordinary flow (see ConversationRoot active-phase rules). */
|
||||
|
||||
@@ -51,10 +50,11 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
/* A keyed renderer may intentionally decline its row after dispatch (the
|
||||
completed-turn tail does this when it owns neither actions nor extensions).
|
||||
An empty flex item must not consume the column gap. */
|
||||
.flowItem:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.callRow {
|
||||
|
||||
@@ -1,41 +1,24 @@
|
||||
// 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. Session stats live on
|
||||
// 'conversation.composer.dock' (sticky with the composer). Pure component
|
||||
// registered directly; its registration declares the whole-Tool
|
||||
// 'conversation.chat.tool' seat. ui-tool owns root/subcall composition and
|
||||
// keyed per-tool dispatch behind that boundary.
|
||||
// ChatView: the default conversation view — one stable keyed parent list over
|
||||
// final business Nodes, plus paging, pending steering and bottom-follow.
|
||||
// Each row dispatches through 'conversation.chat.node'; ui-tool owns the
|
||||
// tool-call renderer and its recursive root/subcall composition.
|
||||
//
|
||||
// Scroll: when nested under `[data-conversation-scroll]` (active conversation
|
||||
// column), that host is the scrollport and this view is flow content; when
|
||||
// mounted alone (unit tests), `.scroll` owns overflow. Bottom-follow and
|
||||
// prepend anchoring always target the resolved scrollport.
|
||||
//
|
||||
// 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. renderSlot is
|
||||
// entry-identity-stable (framework binding cache), so passing it through
|
||||
// memoized rows never churns them.
|
||||
// Render economics: order changes only when rows enter, leave or move. Each
|
||||
// ChatNodeSeat subscribes to one Node key, so Assistant deltas and Tool
|
||||
// lifecycle updates replace only their own row without remounting it.
|
||||
|
||||
import {
|
||||
memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
|
||||
} from 'react'
|
||||
import type {
|
||||
CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { CompactionCommandCard } from './CompactionCommandCard.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
|
||||
import { PendingSteeringBubble } from './MessageItem.tsx'
|
||||
import { ChatNodeSeat } from './ChatNodeSeat.tsx'
|
||||
import { formatRunDuration } from './message-chrome.ts'
|
||||
import { deriveTurnMetrics } from './turn-metrics.ts'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
const FOLLOW_THRESHOLD = 24
|
||||
@@ -98,35 +81,8 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement |
|
||||
return visibleRows[0] ?? rows[0] ?? null
|
||||
}
|
||||
|
||||
type OpenFile = (path: string) => void
|
||||
|
||||
type InspectCall = (callId: string) => void
|
||||
|
||||
/** Declared child-slot render share (stable framework binding). */
|
||||
type RenderChatSlot = ChatViewSlotProps['renderSlot']
|
||||
|
||||
type ChatScrollPosition = NonNullable<ReturnType<ChatViewSlotProps['chatScroll']['read']>>
|
||||
|
||||
/** 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>
|
||||
|
||||
function treeContainsCall(block: ToolCallBlock, callId: string | undefined): boolean {
|
||||
return callId !== undefined
|
||||
&& (block.callId === callId || block.subCalls.some(child => treeContainsCall(child, callId)))
|
||||
}
|
||||
|
||||
function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null {
|
||||
if (!running) return null
|
||||
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
||||
const node = nodes[index]
|
||||
if (node === undefined) continue
|
||||
if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq
|
||||
if (node.kind === 'assistant' || node.kind === 'user') return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Capture a reflow-resistant reader position from the current rendered window. */
|
||||
function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollPosition | null {
|
||||
const row = pagingAnchor(list, scrollport)
|
||||
@@ -139,77 +95,13 @@ function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollP
|
||||
}
|
||||
}
|
||||
|
||||
/** One ordered root Tool call handed intact to the Tool presentation plugin. */
|
||||
const ToolSeat = memo(function ToolSeat({
|
||||
renderSlot, callId, toolName, block, openFile, selectedCallId, cwd, inspectCall,
|
||||
}: {
|
||||
renderSlot: RenderChatSlot
|
||||
callId: string
|
||||
toolName: string
|
||||
block: ToolResultNode | RunningToolCall
|
||||
openFile: OpenFile
|
||||
selectedCallId?: string | undefined
|
||||
cwd: string | undefined
|
||||
inspectCall: InspectCall
|
||||
}) {
|
||||
const owner = useMemo(() => ({
|
||||
callId, toolName, block, selectedCallId, cwd, openFile, inspectCall,
|
||||
}), [callId, toolName, block, selectedCallId, cwd, openFile, inspectCall])
|
||||
return renderSlot('conversation.chat.tool', owner)
|
||||
})
|
||||
|
||||
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, cwd, inspectCall }: {
|
||||
renderSlot: RenderChatSlot
|
||||
results: readonly ToolResultNode[]
|
||||
openFile: OpenFile
|
||||
/** Tool ownership resolves whether the selection is this root or one of its children. */
|
||||
selectedCallId: string | undefined
|
||||
/** Session workspace root for path-relative summaries. */
|
||||
cwd: string | undefined
|
||||
inspectCall: InspectCall
|
||||
}) {
|
||||
return (
|
||||
<div className={css.toolGroup}>
|
||||
{results.map(node => (
|
||||
<ToolSeat
|
||||
key={node.callId}
|
||||
renderSlot={renderSlot}
|
||||
callId={node.callId}
|
||||
toolName={node.call?.name ?? ''}
|
||||
block={node}
|
||||
openFile={openFile}
|
||||
selectedCallId={treeContainsCall(node, selectedCallId) ? selectedCallId : undefined}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** One command lifecycle row: keyed dispatch on the command name with the
|
||||
* generic card as the render-site fallback (zero registration required). A
|
||||
* run-less cross-window node has no name and always lands on the fallback. */
|
||||
const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: {
|
||||
renderSlot: RenderChatSlot
|
||||
node: CommandNode
|
||||
compaction?: Extract<ConversationNode, { kind: 'compaction' }>
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node])
|
||||
const fallback = node.name === 'compact'
|
||||
? <CompactionCommandCard {...owner} t={t} />
|
||||
: <GenericCommandCard {...owner} t={t} />
|
||||
return (
|
||||
<div className={css.callRow}>
|
||||
{renderSlot('conversation.chat.commandview', owner, {
|
||||
entryKey: node.name ?? '',
|
||||
fallback,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null {
|
||||
let latest: number | null = null
|
||||
for (const turn of timeline.turns.values()) {
|
||||
if (turn.status === 'open' && turn.start !== undefined) latest = turn.start.time
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
/** Turn-level model activity label retained across first-token, tool, and streaming phases. */
|
||||
function TurnStatus({ startTime, t }: {
|
||||
@@ -247,51 +139,32 @@ function TurnStatus({ startTime, t }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** The streaming partial, isolated so chunk batches re-render only this tail;
|
||||
* the column ResizeObserver owns bottom-follow when its box grows. */
|
||||
function StreamingTail({ useSession, t }: {
|
||||
useSession: UseConversation
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const partial = useSession(s => s.partial)
|
||||
if (partial === null) return null
|
||||
return <AssistantMarkdown blocks={partial.blocks} streaming t={t} />
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat view slot entry: pure component over the composed props; each
|
||||
* ordered root Tool call crosses the declared whole-Tool render seat.
|
||||
* ordered business Node crosses the keyed renderer seat.
|
||||
*/
|
||||
export function ChatView({
|
||||
useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
|
||||
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt,
|
||||
fileMentions, t,
|
||||
}: ChatViewSlotProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const turnTimings = useSession(s => s.turnTimings)
|
||||
const turnEnds = useSession(s => s.turnEnds)
|
||||
const order = useSession(s => s.chat.order)
|
||||
const nodeStore = useSession(s => s.chat.nodes)
|
||||
const timeline = useSession(s => s.chat.timeline)
|
||||
const inbox = useSession(s => s.queue)
|
||||
// Workspace root off the session list row: path summaries display relative to it.
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
const running = useSession(s => s.running)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const openState = useSession(s => s.openState)
|
||||
const openError = useSession(s => s.openError)
|
||||
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 pendingSteering = useMemo(
|
||||
() => inbox.filter(item => item.placement === 'steering'),
|
||||
[inbox],
|
||||
)
|
||||
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
|
||||
// Only the last content assistant of each completed turn owns IconActions;
|
||||
// mid-turn text and every node of a running turn omit `time`, so
|
||||
// AssistantMarkdown stays chrome-free until the answer settles.
|
||||
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes, turnEnds), [nodes, turnEnds])
|
||||
const branchSeqs = useMemo(() => assistantBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
|
||||
const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings])
|
||||
const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes])
|
||||
const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline])
|
||||
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
const columnRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -307,15 +180,16 @@ export function ChatView({
|
||||
const lastKeyRef = useRef<string | null>(null)
|
||||
const lastSteeringIdRef = useRef<string | null>(null)
|
||||
/** Flow tip signature — follow-scroll only when this moves, never on a
|
||||
* scroll-driven at-bottom chrome re-render (that was snapping inertial
|
||||
* scroll-driven at-bottom chrome re-render (which would snap inertial
|
||||
* scrolls the rest of the way to the floor). */
|
||||
const followSigRef = useRef<string | null>(null)
|
||||
|
||||
const firstSeq = nodes[0]?.seq ?? null
|
||||
const lastItem = items[items.length - 1]
|
||||
const lastKey = lastItem?.key ?? null
|
||||
const firstKey = order[0]
|
||||
const firstSeq = firstKey === undefined ? null : nodeStore.get(firstKey)?.anchorSeq ?? null
|
||||
const lastKey = order.at(-1) ?? null
|
||||
const lastNode = lastKey === null ? undefined : nodeStore.get(lastKey)
|
||||
const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null
|
||||
const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}`
|
||||
const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}`
|
||||
|
||||
const toBottom = (el: HTMLElement): void => {
|
||||
anchorRef.current = null
|
||||
@@ -376,8 +250,7 @@ export function ChatView({
|
||||
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 appendedUser = lastKey !== lastKeyRef.current
|
||||
&& lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user'
|
||||
const appendedUser = lastKey !== lastKeyRef.current && lastNode?.kind === 'user'
|
||||
const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current
|
||||
const tipMoved = followSigRef.current !== followSig
|
||||
lastKeyRef.current = lastKey
|
||||
@@ -489,70 +362,6 @@ export function ChatView({
|
||||
loadOlder()
|
||||
}
|
||||
|
||||
const renderItem = (item: ChatFlowItem): ReactNode => {
|
||||
if (item.kind === 'tool-group') {
|
||||
return (
|
||||
<ToolGroup
|
||||
renderSlot={renderSlot}
|
||||
results={item.results}
|
||||
openFile={openFile}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (item.kind === 'command-compaction') {
|
||||
return (
|
||||
<CommandRow
|
||||
renderSlot={renderSlot}
|
||||
node={item.command}
|
||||
compaction={item.compaction}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const node: ConversationNode = item.node
|
||||
if (node.kind === 'assistant') {
|
||||
const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined
|
||||
// Metrics gate on the settled in-window timing: turn/start loaded means
|
||||
// every step of the turn is loaded, so first-step TTFT is genuine.
|
||||
const metrics = timing?.endTime === undefined ? undefined : turnMetrics.get(node.turn)
|
||||
return (
|
||||
<AssistantMarkdown
|
||||
blocks={node.blocks}
|
||||
streaming={false}
|
||||
interrupted={node.interrupted}
|
||||
time={actionSeqs.has(node.seq) ? node.time : undefined}
|
||||
runMs={timing?.endTime === undefined
|
||||
? undefined
|
||||
: Math.max(0, timing.endTime - timing.startTime)}
|
||||
ttftMs={metrics?.ttftMs}
|
||||
tokensPerSecond={metrics?.tokensPerSecond}
|
||||
seq={node.seq}
|
||||
onFork={forkAt}
|
||||
forkUnavailable={!branchSeqs.has(node.seq)}
|
||||
turnTail={actionSeqs.has(node.seq)
|
||||
? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } }
|
||||
: undefined}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.kind === 'command') {
|
||||
return <CommandRow renderSlot={renderSlot} node={node} t={t} />
|
||||
}
|
||||
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
|
||||
if (node.kind === 'tool-result') return null
|
||||
return (
|
||||
<MessageItem
|
||||
node={node}
|
||||
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div ref={listRef} className={css.scroll}>
|
||||
@@ -570,43 +379,21 @@ export function ChatView({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{items.map(item => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={css.flowItem}
|
||||
data-chat-anchor-key={item.kind === 'node'
|
||||
? `node:${String(item.node.seq)}`
|
||||
: item.kind === 'command-compaction'
|
||||
? `node:${String(item.compaction.seq)}`
|
||||
: undefined}
|
||||
data-chat-flow-key={item.key}
|
||||
data-chat-flow-kind={item.kind === 'node'
|
||||
? item.node.kind
|
||||
: item.kind === 'command-compaction'
|
||||
? item.kind
|
||||
: 'tool-group'}
|
||||
>
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
{order.map(nodeKey => (
|
||||
<ChatNodeSeat
|
||||
key={nodeKey}
|
||||
nodeKey={nodeKey}
|
||||
useSession={useSession}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
openFile={openFile}
|
||||
inspectCall={inspectCall}
|
||||
forkAt={forkAt}
|
||||
fileMentions={fileMentions}
|
||||
renderSlot={renderSlot}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<StreamingTail useSession={useSession} t={t} />
|
||||
{runningCalls.length > 0 && (
|
||||
<div className={css.toolGroup}>
|
||||
{runningCalls.map(call => (
|
||||
<ToolSeat
|
||||
key={call.callId}
|
||||
renderSlot={renderSlot}
|
||||
callId={call.callId}
|
||||
toolName={call.name}
|
||||
block={call}
|
||||
openFile={openFile}
|
||||
selectedCallId={treeContainsCall(call, selectedCallId) ? selectedCallId : undefined}
|
||||
cwd={cwd}
|
||||
inspectCall={inspectCall}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* No pending placeholders: questions (ui-question) and approvals
|
||||
(ApprovalPanel) both take over the composer, so a flow card would
|
||||
double-render the same wait. */}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
ChatNodeViewProps, CommandRowOwnerProps,
|
||||
} from '../contract/slots.ts'
|
||||
import { CompactionCommandCard } from './CompactionCommandCard.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
type CommandNodeViewProps = ChatNodeViewProps<'command'> & PropsRenderSlots<'conversation.chat.commandview'>
|
||||
|
||||
/** Ordinary command lifecycle renderer with command-name keyed specialization. */
|
||||
export const CommandNodeView = memo(function CommandNodeView({ node, renderSlot, t }: CommandNodeViewProps) {
|
||||
const command = node.data
|
||||
const owner = useMemo<CommandRowOwnerProps>(() => ({ node: command }), [command])
|
||||
return (
|
||||
<div className={css.callRow}>
|
||||
{renderSlot('conversation.chat.commandview', owner, {
|
||||
entryKey: command.name ?? '',
|
||||
fallback: <GenericCommandCard {...owner} t={t} />,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** One integrated `/compact` command and compaction transaction renderer. */
|
||||
export const ManualCompactionNodeView = memo(function ManualCompactionNodeView({
|
||||
node, t,
|
||||
}: ChatNodeViewProps<'manual-compaction'>) {
|
||||
const data = node.data
|
||||
return (
|
||||
<div className={css.callRow}>
|
||||
<CompactionCommandCard
|
||||
node={data.command}
|
||||
{...data.compaction === null ? {} : { compaction: data.compaction }}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
// marker reports where the model stopped seeing that history — it never
|
||||
// replaces it. The framed checkpoint payload is written for the model and is
|
||||
// not rendered; the disclosure shows the summary from the checkpoint's own
|
||||
// provenance, and a window cut that left that provenance outside makes the row
|
||||
// cited `compact/summary` event, and a window cut that left that event outside makes the row
|
||||
// non-expandable rather than empty.
|
||||
|
||||
import { memo, useState } from 'react'
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Provenance beneath the text: dimmer than the content it describes. */
|
||||
/* Source fields beneath the text: dimmer than the content they describe. */
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -66,7 +66,7 @@ function boundedText(text: string, t: Translate): string {
|
||||
|
||||
/**
|
||||
* One source field rendered as a value row; nested shapes stay compact JSON.
|
||||
* Bounded on its own, because provenance is as unbounded as the text: an unknown
|
||||
* Bounded on its own, because source fields are as unbounded as the text: an unknown
|
||||
* producer may record an arbitrarily large string or array.
|
||||
*/
|
||||
function fieldValue(value: unknown, t: Translate): string {
|
||||
@@ -77,7 +77,7 @@ function fieldValue(value: unknown, t: Translate): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Provenance fields as a key/value list. `kind` is always omitted because the
|
||||
* Source fields as a key/value list. `kind` is always omitted because the
|
||||
* row header already names the producer. `form` is omitted only when a
|
||||
* dedicated body rendered for it — then the presentation the reader is looking
|
||||
* at IS that value. On the opaque fallback the declaration is kept, because
|
||||
@@ -159,7 +159,7 @@ function ModelFacingContent({ content, t }: {
|
||||
|
||||
/**
|
||||
* Default presentation: the model-facing text as text, with its real line
|
||||
* breaks, and the remaining provenance beneath it. This is what every form
|
||||
* breaks, and the remaining source fields beneath it. This is what every form
|
||||
* this UI version does not recognize renders as.
|
||||
* @param props - Durable content, its source, and the locale seat.
|
||||
* @returns The opaque context body.
|
||||
@@ -428,7 +428,7 @@ export function NoticeBody({ content, t }: {
|
||||
/**
|
||||
* `relay` form: which agent sent this, then what it said.
|
||||
*
|
||||
* The sender is an opaque session id; it is shown as provenance rather than a
|
||||
* The sender is an opaque session id; it is shown as a field rather than a
|
||||
* label, because this client cannot resolve it to a title.
|
||||
* @param props - Durable content, its source, and the locale seat.
|
||||
* @returns The relay context body.
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface ContextInjectionRowProps {
|
||||
* from a workspace instruction file or a recalled session without expanding.
|
||||
* The expanded body follows the producer-declared form; an absent or unknown
|
||||
* form renders the opaque body.
|
||||
* @param props - Durable content, its projected provenance and form, and the locale seat.
|
||||
* @param props - Durable content, its projected producer role/name and form, and the locale seat.
|
||||
* @returns A collapsed context row with a bounded, form-specific body.
|
||||
*/
|
||||
export function ContextInjectionRow({ content, source, provenance, form, t }: ContextInjectionRowProps) {
|
||||
|
||||
@@ -7,30 +7,15 @@
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode,
|
||||
TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
|
||||
ModelRetryNode, TurnErrorNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { CompactionItem } from './CompactionItem.tsx'
|
||||
import { ContextInjectionRow } from './ContextInjectionRow.tsx'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import css from './MessageItem.module.css'
|
||||
|
||||
export interface MessageItemProps {
|
||||
node:
|
||||
| UserMessageNode
|
||||
| SteeringMessageNode
|
||||
| ContextMessageNode
|
||||
| CompactionSummaryNode
|
||||
| ModelRetryNode
|
||||
| TurnErrorNode
|
||||
| UnknownSurfaceNode
|
||||
retryActive?: boolean
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
|
||||
const texts: string[] = []
|
||||
const rest: unknown[] = []
|
||||
@@ -138,8 +123,8 @@ function TurnErrorItem({ node, t }: {
|
||||
* Display projection of reference forms in a user bubble (free geometry — no
|
||||
* textarea alignment constraint here); everything else stays plain text. The
|
||||
* logged model text remains the single truth; this is presentation only.
|
||||
* Plain-text `/name` / `@name` word-boundary tokens decorate (decision 21:
|
||||
* the sent text IS the reference — the bubble uses the same plainest token
|
||||
* Plain-text `/name` / `@name` word-boundary tokens decorate (the sent text
|
||||
* IS the reference — the bubble uses the same plainest token
|
||||
* scan as the composer, minus the lexicon: sent tokens were validated at
|
||||
* compose time, so shape alone decorates).
|
||||
*/
|
||||
@@ -219,50 +204,69 @@ export function PendingSteeringBubble({ content, t }: {
|
||||
)
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({
|
||||
node, retryActive = false, t,
|
||||
}: MessageItemProps) {
|
||||
const truncated = (total: number): string => t('json.truncated', { total })
|
||||
switch (node.kind) {
|
||||
case 'user':
|
||||
case 'steering':
|
||||
return (
|
||||
<UserStyleBubble
|
||||
content={node.content}
|
||||
steering={node.kind === 'steering'}
|
||||
t={t}
|
||||
actions={text => (
|
||||
<MessageIconActions
|
||||
text={text}
|
||||
time={node.time}
|
||||
clock="start"
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
case 'context':
|
||||
return (
|
||||
<ContextInjectionRow
|
||||
content={node.content}
|
||||
source={node.source}
|
||||
provenance={node.provenance}
|
||||
form={node.form}
|
||||
/** User and admitted-steering keyed Chat renderer. */
|
||||
export const UserMessageNodeView = memo(function UserMessageNodeView({
|
||||
node, t,
|
||||
}: ChatNodeViewProps<'user' | 'steering'>) {
|
||||
const data = node.data
|
||||
return (
|
||||
<UserStyleBubble
|
||||
content={data.content}
|
||||
steering={data.kind === 'steering'}
|
||||
t={t}
|
||||
actions={text => (
|
||||
<MessageIconActions
|
||||
text={text}
|
||||
time={data.time}
|
||||
clock="start"
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
case 'compaction':
|
||||
return <CompactionItem node={node} t={t} />
|
||||
case 'model-retry':
|
||||
return <ModelRetryItem node={node} active={retryActive} t={t} />
|
||||
case 'turn-error':
|
||||
return <TurnErrorItem node={node} t={t} />
|
||||
default:
|
||||
return (
|
||||
<div className={css.contextRow}>
|
||||
<JsonBlock label={t('message.unknownSurface', { type: node.type })} payload={node.data} truncatedLabel={truncated} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
/** Injected-context keyed Chat renderer. */
|
||||
export const ContextMessageNodeView = memo(function ContextMessageNodeView({ node, t }: ChatNodeViewProps<'context'>) {
|
||||
const data = node.data
|
||||
return (
|
||||
<ContextInjectionRow
|
||||
content={data.content}
|
||||
source={data.source}
|
||||
provenance={data.provenance}
|
||||
form={data.form}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
/** Automatic compaction keyed Chat renderer. */
|
||||
export const CompactionNodeView = memo(function CompactionNodeView({ node, t }: ChatNodeViewProps<'compaction'>) {
|
||||
return <CompactionItem node={node.data} t={t} />
|
||||
})
|
||||
|
||||
/** Correlated retry-chain keyed Chat renderer. */
|
||||
export const RetryNodeView = memo(function RetryNodeView({ node, t }: ChatNodeViewProps<'model-retry'>) {
|
||||
const data = node.data
|
||||
return <ModelRetryItem node={data.current} active={data.current.retryState === 'scheduled'} t={t} />
|
||||
})
|
||||
|
||||
/** Terminal turn-error keyed Chat renderer. */
|
||||
export const TurnErrorNodeView = memo(function TurnErrorNodeView({ node, t }: ChatNodeViewProps<'turn-error'>) {
|
||||
return <TurnErrorItem node={node.data} t={t} />
|
||||
})
|
||||
|
||||
/** Explicit unknown-surface keyed Chat renderer. */
|
||||
export const UnknownNodeView = memo(function UnknownNodeView({ node, t }: ChatNodeViewProps<'unknown'>) {
|
||||
const data = node.data
|
||||
return (
|
||||
<div className={css.contextRow}>
|
||||
<JsonBlock
|
||||
label={t('message.unknownSurface', { type: data.type })}
|
||||
payload={data.data}
|
||||
truncatedLabel={total => t('json.truncated', { total })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -157,9 +157,9 @@ export interface StatsLineProps {
|
||||
}
|
||||
|
||||
export const StatsLine = memo(function StatsLine({ useSession, useProjection, t }: StatsLineProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const settledNodes = useSession(s => s.chat.legacy.nodes)
|
||||
const stats = useMemo(() => deriveStats(settledNodes), [settledNodes])
|
||||
const usage = useProjection('tokenUsage')
|
||||
const stats = useMemo(() => deriveStats(nodes), [nodes])
|
||||
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
|
||||
const groups: string[] = []
|
||||
if (stats.steps > 0) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-left: -6px;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { memo } from 'react'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { assistantText } from './turn-assistant.ts'
|
||||
import css from './TurnTailNodeView.module.css'
|
||||
|
||||
type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'> & PropsRenderSlots<'conversation.chat.turnTail'>
|
||||
|
||||
/** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */
|
||||
export const TurnTailNodeView = memo(function TurnTailNodeView({
|
||||
node, openFile, forkAt, renderSlotChain, t, useSession,
|
||||
}: TurnTailNodeViewProps) {
|
||||
const data = node.data
|
||||
const hasLaterChatNode = useSession(snapshot =>
|
||||
snapshot.chat.locations.getTurn(data.turn).at(-1) !== node.key)
|
||||
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
||||
? node.location.turn
|
||||
: undefined
|
||||
if (turn === undefined) return null
|
||||
const closing = data.closing
|
||||
const owner: TurnTailOwnerProps = { turn, seq: closing?.finalNode.seq ?? data.seq, openFile }
|
||||
const tail = renderSlotChain('conversation.chat.turnTail', owner)
|
||||
if (closing === null) return tail === null ? null : <div className={css.root}>{tail}</div>
|
||||
const runMs = turn.start === undefined || turn.end === undefined
|
||||
? undefined
|
||||
: Math.max(0, turn.end.time - turn.start.time)
|
||||
return (
|
||||
<div className={css.root} data-turn-tail={data.turn} data-time-hover-root>
|
||||
{tail}
|
||||
<MessageIconActions
|
||||
text={assistantText(closing.blocks)}
|
||||
time={closing.time}
|
||||
runMs={runMs}
|
||||
ttftMs={data.ttftMs}
|
||||
tokensPerSecond={data.tokensPerSecond}
|
||||
clock="end"
|
||||
onBranch={() => { forkAt(closing.finalNode.seq) }}
|
||||
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,214 +0,0 @@
|
||||
/**
|
||||
* Chat flow derivation: ConversationSnapshot nodes -> render items. Tool
|
||||
* results group into consecutive-run tool groups (figma step-summary flow,
|
||||
* VERTICAL gap10) alternating with narration. Consecutive retry notices
|
||||
* reuse the first notice's row while projecting the latest retry turn.
|
||||
* Item identity keys are stable across snapshots so the list parent can
|
||||
* subscribe to keys only while rows subscribe to content. IconActions ownership
|
||||
* and completed-turn branch points are derived here too so ChatView and the
|
||||
* flow share their gates.
|
||||
*/
|
||||
import type {
|
||||
AssistantBlock, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One renderable flow item; key is the React key and the parent's identity unit. */
|
||||
export type ChatFlowItem =
|
||||
| { kind: 'node'; key: string; node: ConversationNode }
|
||||
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
|
||||
| {
|
||||
kind: 'command-compaction'
|
||||
key: string
|
||||
command: CommandNode
|
||||
compaction: CompactionSummaryNode
|
||||
}
|
||||
|
||||
/** Match explicit command outcome references to exactly one compaction checkpoint. */
|
||||
function commandCompactionPairs(nodes: readonly ConversationNode[]): {
|
||||
readonly byCommandId: ReadonlyMap<string, CompactionSummaryNode>
|
||||
readonly byCompactionSeq: ReadonlyMap<number, CommandNode>
|
||||
} {
|
||||
const commandsBySource = new Map<number, CommandNode | null>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'command' || node.name !== 'compact' || node.outcome?.kind !== 'success') continue
|
||||
const source = node.outcome.sourceEventSeq
|
||||
if (source === undefined) continue
|
||||
commandsBySource.set(source, commandsBySource.has(source) ? null : node)
|
||||
}
|
||||
const compactionsBySummary = new Map<number, CompactionSummaryNode | null>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'compaction' || node.summaryEventSeq === null) continue
|
||||
const summary = node.summaryEventSeq
|
||||
compactionsBySummary.set(summary, compactionsBySummary.has(summary) ? null : node)
|
||||
}
|
||||
const byCommandId = new Map<string, CompactionSummaryNode>()
|
||||
const byCompactionSeq = new Map<number, CommandNode>()
|
||||
for (const [source, command] of commandsBySource) {
|
||||
const compaction = compactionsBySummary.get(source)
|
||||
if (command === null || compaction === undefined || compaction === null) continue
|
||||
byCommandId.set(command.commandId, compaction)
|
||||
byCompactionSeq.set(compaction.seq, command)
|
||||
}
|
||||
return { byCommandId, byCompactionSeq }
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the node has model-visible text content worth IconActions chrome.
|
||||
* Shared with {@link AssistantMarkdown}'s mount gate so ownership and mounting
|
||||
* cannot diverge.
|
||||
* @param blocks - assistant blocks of one finalized node.
|
||||
* @returns Whether any text block carries non-blank content.
|
||||
*/
|
||||
export function hasContentText(blocks: readonly AssistantBlock[]): boolean {
|
||||
return blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
|
||||
}
|
||||
|
||||
/** An assistant node that renders nothing: only tool-call heads (rows render
|
||||
* via the grouping pass) and blank text/reasoning. Skipped by the flow so it
|
||||
* neither costs column gaps nor splits a tool-row run. Interrupted nodes
|
||||
* always render (the 已停止 marker). */
|
||||
function rendersNothing(node: ConversationNode): boolean {
|
||||
return node.kind === 'assistant' && node.interrupted !== true
|
||||
&& node.blocks.every(b => b.kind === 'tool-call'
|
||||
|| ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* Seq set of assistants that own IconActions: the last content-text assistant
|
||||
* of each *completed* turn. A turn without a `turn/end` in the window is still
|
||||
* producing steps, so its latest narration is not the settled answer and owns
|
||||
* nothing; mid-turn narration of a completed turn stays chrome-free too.
|
||||
* @param nodes - snapshot nodes (surface order).
|
||||
* @param turnEnds - completed turn boundaries retained from the event window.
|
||||
* @returns Seq values ChatView may pass as `time` into AssistantMarkdown.
|
||||
*/
|
||||
export function assistantActionsSeqs(
|
||||
nodes: readonly ConversationNode[],
|
||||
turnEnds: ReadonlyMap<number, number>,
|
||||
): ReadonlySet<number> {
|
||||
const lastByTurn = new Map<number, number>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'assistant' || !turnEnds.has(node.turn) || !hasContentText(node.blocks)) continue
|
||||
lastByTurn.set(node.turn, node.seq)
|
||||
}
|
||||
return new Set(lastByTurn.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact start time of the latest in-window turn without a matching end time.
|
||||
* @param turnTimings - In-window turn timings in event order.
|
||||
* @returns Unix epoch ms, or null when the running turn started outside the window.
|
||||
*/
|
||||
export function runningTurnStartTime(
|
||||
turnTimings: ConversationSnapshot['turnTimings'],
|
||||
): number | null {
|
||||
let latest: number | null = null
|
||||
for (const timing of turnTimings.values()) {
|
||||
if (timing.endTime === undefined) latest = timing.startTime
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
/**
|
||||
* Seq set of assistant answers that may fork: the completed turn's transcript
|
||||
* tail, when that tail is the turn's own content-text assistant. A later tool,
|
||||
* reasoning, error, or other transcript node leaves the answer's branch action
|
||||
* unavailable because the Host would include the whole turn. User and steering
|
||||
* bubbles carry no branch action at all: a fork at their seq cuts at the same
|
||||
* `turn/end` as the answer's, so the affordance lives only under the settled
|
||||
* answer.
|
||||
* @param nodes - snapshot nodes in event order.
|
||||
* @param turnEnds - completed turn boundaries retained from the event window.
|
||||
* @returns Assistant seq values whose visible position matches the fork boundary.
|
||||
*/
|
||||
export function assistantBranchSeqs(
|
||||
nodes: readonly ConversationNode[],
|
||||
turnEnds: ReadonlyMap<number, number>,
|
||||
): ReadonlySet<number> {
|
||||
const result = new Set<number>()
|
||||
const boundaries = [...turnEnds].sort((a, b) => a[1] - b[1])
|
||||
let nodeIndex = 0
|
||||
for (const [turn, endSeq] of boundaries) {
|
||||
let tail: ConversationNode | undefined
|
||||
while (nodeIndex < nodes.length) {
|
||||
const candidate = nodes[nodeIndex]
|
||||
if (candidate === undefined || candidate.seq > endSeq) break
|
||||
tail = candidate
|
||||
nodeIndex++
|
||||
}
|
||||
if (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks)) {
|
||||
result.add(tail.seq)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Group finalized nodes into the step-summary flow.
|
||||
* @param nodes - snapshot nodes in human-transcript and durable-notice order.
|
||||
* @returns flow items; consecutive tool results group and retry notices reuse their first key.
|
||||
*/
|
||||
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
|
||||
const items: ChatFlowItem[] = []
|
||||
const pairs = commandCompactionPairs(nodes)
|
||||
let group: ToolResultNode[] | null = null
|
||||
for (const node of nodes) {
|
||||
if (rendersNothing(node)) continue
|
||||
if (node.kind === 'command' && pairs.byCommandId.has(node.commandId)) {
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'compaction') {
|
||||
group = null
|
||||
const command = pairs.byCompactionSeq.get(node.seq)
|
||||
if (command !== undefined) {
|
||||
items.push({
|
||||
kind: 'command-compaction',
|
||||
key: `c${command.commandId}`,
|
||||
command,
|
||||
compaction: node,
|
||||
})
|
||||
} else {
|
||||
items.push({ kind: 'node', key: `n${node.seq}`, node })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'tool-result') {
|
||||
if (group === null) {
|
||||
group = [node]
|
||||
items.push({ kind: 'tool-group', key: `g${node.seq}`, results: group })
|
||||
} else {
|
||||
group.push(node)
|
||||
}
|
||||
} else if (node.kind === 'model-retry') {
|
||||
group = null
|
||||
const previous = items[items.length - 1]
|
||||
if (
|
||||
previous?.kind === 'node'
|
||||
&& previous.node.kind === 'model-retry'
|
||||
) {
|
||||
items[items.length - 1] = { ...previous, node }
|
||||
} else {
|
||||
items.push({ kind: 'node', key: `n${node.seq}`, node })
|
||||
}
|
||||
} else {
|
||||
group = null
|
||||
items.push({
|
||||
kind: 'node',
|
||||
key: node.kind === 'command' && node.name === 'compact'
|
||||
? `c${node.commandId}`
|
||||
: `n${node.seq}`,
|
||||
node,
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Key projection for the list parent's selector (content-blind identity).
|
||||
* @param items - derived flow items.
|
||||
* @returns joined key string usable with Object.is short-circuiting.
|
||||
*/
|
||||
export function flowKeys(items: readonly ChatFlowItem[]): string {
|
||||
return items.map(i => i.key).join('|')
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { NS } from '../locales.ts'
|
||||
import { AssistantNodeView } from './AssistantNodeView.tsx'
|
||||
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'
|
||||
import {
|
||||
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
|
||||
UnknownNodeView, UserMessageNodeView,
|
||||
} from './MessageItem.tsx'
|
||||
import { TurnTailNodeView } from './TurnTailNodeView.tsx'
|
||||
|
||||
/**
|
||||
* Register this package's business renderers behind the keyed Chat Node seat.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerChatNodeRenderers(ctx: Context): void {
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'user', locale: NS }, UserMessageNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'steering', locale: NS }, UserMessageNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'context', locale: NS }, ContextMessageNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'assistant-step', locale: NS }, AssistantNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'command',
|
||||
locale: NS,
|
||||
children: { 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' } },
|
||||
}, CommandNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'manual-compaction', locale: NS }, ManualCompactionNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'compaction', locale: NS }, CompactionNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'model-retry', locale: NS }, RetryNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'turn-tail',
|
||||
locale: NS,
|
||||
children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
|
||||
}, TurnTailNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'unknown', locale: NS }, UnknownNodeView))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
ConversationSnapshot, ToolCallBlock,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { conversationContextKey } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
|
||||
function toolNode(node: ReturnType<ConversationSnapshot['chat']['nodes']['get']>): ChatNode<'tool-call'> | undefined {
|
||||
return node?.kind === 'tool-call' ? node as ChatNode<'tool-call'> : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one root Tool lifecycle through the internal Chat Node index.
|
||||
* @param snapshot - current Conversation snapshot.
|
||||
* @param rootCallId - root call identity and Tool Context identity.
|
||||
* @returns root lifecycle when it is materialized in the current window.
|
||||
*/
|
||||
export function rootToolCall(
|
||||
snapshot: ConversationSnapshot,
|
||||
rootCallId: string,
|
||||
): ToolCallBlock | undefined {
|
||||
return toolNode(snapshot.chat.nodes.get(conversationContextKey('tool-call', rootCallId)))?.data.root
|
||||
}
|
||||
|
||||
/**
|
||||
* Find any root or nested Tool lifecycle through the internal Node store.
|
||||
* @param snapshot - current Conversation snapshot.
|
||||
* @param callId - root or nested call identity.
|
||||
* @returns current Tool lifecycle when materialized in the loaded window.
|
||||
*/
|
||||
export function findToolCall(snapshot: ConversationSnapshot, callId: string): ToolCallBlock | undefined {
|
||||
const visit = (block: ToolCallBlock): ToolCallBlock | undefined => {
|
||||
if (block.callId === callId) return block
|
||||
for (const child of block.subCalls) {
|
||||
const found = visit(child)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
for (const node of snapshot.chat.nodes.values()) {
|
||||
const root = toolNode(node)?.data.root
|
||||
if (root === undefined) continue
|
||||
const found = visit(root)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/**
|
||||
* Collect visible prose from one Assistant lifecycle.
|
||||
* @param blocks - Assistant content blocks.
|
||||
* @returns concatenated text blocks.
|
||||
*/
|
||||
export function assistantText(blocks: readonly AssistantBlock[]): string {
|
||||
return blocks.flatMap(block => block.kind === 'text' ? [block.text] : []).join('')
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Latency/throughput folds shared by the settled turn footer and StatsLine.
|
||||
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { AssistantMessageNode, ConversationNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Latency and decode-throughput readings for one turn's footer. */
|
||||
export interface TurnMetrics {
|
||||
@@ -24,7 +24,7 @@ interface UsageLike {
|
||||
outputTokens?: number
|
||||
}
|
||||
|
||||
type AssistantNode = Extract<ConversationSnapshot['nodes'][number], { kind: 'assistant' }>
|
||||
type AssistantNode = AssistantMessageNode
|
||||
|
||||
function usageOutputTokens(usage: unknown): number | null {
|
||||
if (typeof usage !== 'object' || usage === null) return null
|
||||
@@ -67,7 +67,7 @@ interface TurnFold {
|
||||
* @param nodes - Snapshot nodes of the loaded window.
|
||||
* @returns Turn number → available metrics; turns with none are absent.
|
||||
*/
|
||||
export function deriveTurnMetrics(nodes: ConversationSnapshot['nodes']): Map<number, TurnMetrics> {
|
||||
export function deriveTurnMetrics(nodes: readonly ConversationNode[]): Map<number, TurnMetrics> {
|
||||
const folds = new Map<number, TurnFold>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'assistant') continue
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
AssistantBlock, AssistantMessageNode, ChatConversationViewNode, CommandNode,
|
||||
CompactionSummaryNode, ModelRetryNode, RunningToolCall, ToolCallBlock,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Merge-extensible payload registry keyed by final Chat renderer kind. */
|
||||
export interface ChatNodeDataMap {}
|
||||
|
||||
/** Renderer kinds contributed by the currently installed Chat business modules. */
|
||||
export type ChatNodeKind = Extract<keyof ChatNodeDataMap, string>
|
||||
|
||||
/** Final Chat Node narrowed to one registered renderer kind and payload. */
|
||||
export type ChatNode<Kind extends ChatNodeKind = ChatNodeKind> = {
|
||||
[RegisteredKind in Kind]: ChatConversationViewNode & {
|
||||
readonly kind: RegisteredKind
|
||||
readonly data: ChatNodeDataMap[RegisteredKind]
|
||||
}
|
||||
}[Kind]
|
||||
|
||||
/** Final Assistant row payload shared by streaming and settled states. */
|
||||
export interface AssistantChatData {
|
||||
readonly status: 'running' | 'settled' | 'interrupted'
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
readonly blocks: readonly AssistantBlock[]
|
||||
readonly time: number
|
||||
readonly usage?: unknown
|
||||
readonly finalNode?: AssistantMessageNode
|
||||
}
|
||||
|
||||
/** Settled or interrupted Assistant payload with its durable presentation node. */
|
||||
export type FinalAssistantChatData = AssistantChatData & {
|
||||
readonly finalNode: AssistantMessageNode
|
||||
}
|
||||
|
||||
/** Root Tool row payload; the root lifecycle owns all recursive subcalls. */
|
||||
export interface ToolChatData {
|
||||
readonly root: ToolCallBlock
|
||||
}
|
||||
|
||||
/** One manual command and its correlated compaction transaction. */
|
||||
export interface ManualCompactionChatData {
|
||||
readonly command: CommandNode
|
||||
readonly compaction: CompactionSummaryNode | null
|
||||
}
|
||||
|
||||
/** One durable retry chain rendered as a single row. */
|
||||
export interface RetryChatData {
|
||||
readonly attempts: readonly ModelRetryNode[]
|
||||
readonly current: ModelRetryNode
|
||||
}
|
||||
|
||||
/** Turn-local footer row that owns actions and optional feature contributions. */
|
||||
export interface TurnTailChatData {
|
||||
readonly turn: number
|
||||
readonly seq: number
|
||||
readonly time: number
|
||||
/** Last finalized content-bearing Assistant in this Turn. */
|
||||
readonly closing: FinalAssistantChatData | null
|
||||
/** Whether non-rendered later evidence makes the closing seq non-tail. */
|
||||
readonly branchUnavailable: boolean
|
||||
readonly ttftMs?: number
|
||||
readonly tokensPerSecond?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a Tool root has settled.
|
||||
* @param block - Tool root lifecycle value.
|
||||
* @returns whether the root carries its final result.
|
||||
*/
|
||||
export function isSettledTool(block: ToolCallBlock): block is Extract<ToolCallBlock, { kind: 'tool-result' }> {
|
||||
return 'kind' in block
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a Tool root is still running.
|
||||
* @param block - Tool root lifecycle value.
|
||||
* @returns whether the root lacks a final result.
|
||||
*/
|
||||
export function isRunningTool(block: ToolCallBlock): block is RunningToolCall {
|
||||
return !isSettledTool(block)
|
||||
}
|
||||
@@ -1,14 +1,21 @@
|
||||
/** Conversation slot declarations and their composed component props. */
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import type {
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
|
||||
SlotHookFactory, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
CommandNode, CompactionSummaryNode, ConversationSnapshot, ConversationTurnDataMap,
|
||||
ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock,
|
||||
TurnLocation, WorkspaceId,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerBlock } from '../input/blocks.ts'
|
||||
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts'
|
||||
import type { ChatNode, ChatNodeKind } from './chat-nodes.ts'
|
||||
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
@@ -30,13 +37,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* conversation snapshot through the standard kit.
|
||||
*/
|
||||
'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
|
||||
/**
|
||||
* One root Tool call at its ordered ChatFlow position. The chat view owns
|
||||
* placement; ui-tool owns root/subcall composition and keyed dispatch.
|
||||
* The filler preserves the call-anchor DOM contract documented by
|
||||
* {@link ToolTreeOwnerProps} for every root and child wrapper.
|
||||
*/
|
||||
'conversation.chat.tool': { kind: 'single'; scope: 'session'; owner: ToolTreeOwnerProps }
|
||||
/** Final business node renderer, dispatched by `ChatConversationViewNode.kind`. */
|
||||
'conversation.chat.node': {
|
||||
kind: 'keyed'
|
||||
scope: 'session'
|
||||
owner: ChatNodeOwnerProps
|
||||
keyProps: { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }
|
||||
hookContext: string
|
||||
inject: ChatNodeTurnDataInjected
|
||||
}
|
||||
/**
|
||||
* The chat view's per-command row hole: keyed dispatch on the command
|
||||
* name (`command/run.name`; a run-less cross-window node has none and
|
||||
@@ -47,11 +56,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
*/
|
||||
'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
|
||||
/**
|
||||
* The chat view's turn-tail chain: rendered between a closing assistant
|
||||
* message's body and its IconActions footer, once per turn (the render
|
||||
* site elects the closing seq). Entries derive a match from the owner
|
||||
* currency before mounting, so presentation components never mount only
|
||||
* to return null; an all-declined chain renders nothing.
|
||||
* The completed Turn Node's extension chain, rendered before that Node's
|
||||
* IconActions. Entries derive a match from the engine-owned Turn and
|
||||
* closing seq before mounting, so presentation components never mount
|
||||
* only to return null; an all-declined chain renders nothing.
|
||||
*/
|
||||
'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps }
|
||||
/** Selected Tool call output inside the details panel. */
|
||||
@@ -71,14 +79,20 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* reads the global workspace list.
|
||||
*/
|
||||
'conversation.hero.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps }
|
||||
// 'conversation.input.overlay' merges in ui-slash (dedup ruling: the
|
||||
// dependency direction is the hard constraint — ui-slash cannot import
|
||||
/**
|
||||
* The agent-preset chip beside the workspace picker on the new-session
|
||||
* screen. Root scope: no session exists yet, so the choice is staged for
|
||||
* the next one rather than applied to a current one.
|
||||
*/
|
||||
'conversation.hero.agentPreset': { kind: 'single'; scope: 'root'; owner: HeroAgentPresetOwnerProps }
|
||||
// 'conversation.input.overlay' merges in ui-slash (the dependency
|
||||
// direction is the hard constraint — ui-slash cannot import
|
||||
// this package, while this package's input contract already imports
|
||||
// ui-slash, so the type arrives transitively). The runtime declaration
|
||||
// (children table in apply.ts) stays here with the other input slots.
|
||||
/**
|
||||
* Stacked strip above the input (queue rows / GoalBar / attachments;
|
||||
* design §6 MIX evidence: entries coexist in fixed order).
|
||||
* entries coexist in fixed order).
|
||||
*/
|
||||
'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
|
||||
/** The band under the composer card (stats line family), rendered inside the bar's width column via the `footer` owner prop. */
|
||||
@@ -89,7 +103,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
'conversation.input.right': { kind: 'list'; scope: 'session'; owner: InputZone }
|
||||
/**
|
||||
* The default composer body: a single slot rendered as the composer
|
||||
* chain's fallback (decision 20 — a real entry, not a chain rider, so a
|
||||
* chain's fallback (a real entry, not a chain rider, so a
|
||||
* takeover election hides rather than unmounts it and the textarea DOM
|
||||
* survives). Session-maybe: the bar stays mounted across the
|
||||
* no-session/session transition — the no-workspace hero renders the SAME
|
||||
@@ -103,7 +117,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
/**
|
||||
* The Plan-mode status seat in the composer tool row (left group,
|
||||
* right of the access-mode control). Declared by the composer-bar
|
||||
* entry; empty until a plan plugin registers (B ruling: no placeholder
|
||||
* entry; empty until a plan plugin registers (no placeholder
|
||||
* fallback).
|
||||
*/
|
||||
'conversation.input.plan': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
|
||||
@@ -116,7 +130,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
||||
/**
|
||||
* ui-conversation's members of the session standard kit, provided through
|
||||
* `sessions.provide` (decision 19/20): every session-scope slot component
|
||||
* `sessions.provide`: every session-scope slot component
|
||||
* receives the input machine's state hook and the two public actions.
|
||||
*/
|
||||
interface SessionStandardProps {
|
||||
@@ -133,11 +147,33 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner share of the hero agent-preset chip: the shell supplies nothing. */
|
||||
export interface HeroAgentPresetOwnerProps {
|
||||
/** Marker field: the chip owns its own roster, staging, and menu state. */
|
||||
children?: never
|
||||
}
|
||||
|
||||
/** Owner share of the strict session content seat. */
|
||||
export interface ConversationSessionOwnerProps {
|
||||
/**
|
||||
* Wrap the view ring in the transcript scrollport that also hosts the
|
||||
* sticky composer seat (whole `'conversation.composer'` chain output).
|
||||
* Supplied for every real session (hero/settling/active) so the composer
|
||||
* keeps one tree seat across the blank → active flip; the header stays
|
||||
* outside that wrapper as ordinary column chrome (`flex: none`), while
|
||||
* active CSS sticks the seat to the bottom of the same scrollport so wheel
|
||||
* over the footer scrolls the flow.
|
||||
* @param view - the session view-ring content (null while blank chrome is hidden).
|
||||
* @returns the scrollport containing `view` and the sticky composer seat.
|
||||
*/
|
||||
wrapActiveBody?: (view: ReactNode) => ReactNode
|
||||
}
|
||||
|
||||
/** Header actions derive their state from the standard session/global kit. */
|
||||
export interface ConversationHeaderActionOwnerProps {}
|
||||
|
||||
/**
|
||||
* The input-region slot currency (plan §1.4): dock/left/right entries read
|
||||
* The input-region slot currency: dock/left/right entries read
|
||||
* the conversation snapshot and the live input state as owner props (both
|
||||
* are point-in-time snapshots — the dispatching skeleton re-renders on
|
||||
* either store's change, so entries stay current without subscribing).
|
||||
@@ -161,14 +197,37 @@ export interface ConvViewOwnerProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner currency of the chat view's turn-tail hole: the finalized snapshot
|
||||
* and the closing assistant's anchor. Registrants derive their own facts
|
||||
* from the nodes (the owner never pre-chews a feature's vocabulary), and
|
||||
* open files through the same opener the tool rows use.
|
||||
* Optional prose file-mention provider, consumed via `ctx.get('chatFileMentions')`
|
||||
* (optional-service convention): the chat view asks it for a closing message's
|
||||
* inline-code vocabulary and threads the result into MarkdownText. Absent
|
||||
* service — the providing plugin composed out of cordis.yml — turns the
|
||||
* surface off; the prose renders inert code.
|
||||
*/
|
||||
export interface ChatFileMentions {
|
||||
/**
|
||||
* Mention vocabulary for the closing message the owner currency names.
|
||||
* @param owner - Turn-tail owner currency (Turn data, closing seq, opener).
|
||||
* @returns The resolver MarkdownText consumes, or undefined when the turn
|
||||
* produced nothing worth linking.
|
||||
*/
|
||||
forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */
|
||||
chatFileMentions: ChatFileMentions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner currency of the chat view's turn-tail hole: the engine-owned Turn and
|
||||
* the closing assistant's anchor. Registrants read their own typed Turn data
|
||||
* and open files through the same opener the tool rows use.
|
||||
*/
|
||||
export interface TurnTailOwnerProps {
|
||||
/** Finalized snapshot nodes in surface order. */
|
||||
nodes: readonly ConversationNode[]
|
||||
/** Engine-owned closing Turn boundary. */
|
||||
turn: TurnLocation
|
||||
/** The closing assistant's seq — the anchor the tail renders under. */
|
||||
seq: number
|
||||
/**
|
||||
@@ -178,34 +237,34 @@ export interface TurnTailOwnerProps {
|
||||
openFile: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner currency of the chat view's whole-Tool rendering seat. The filler
|
||||
* wraps every rendered root and child with `data-chat-anchor-key="call:<id>"`
|
||||
* and `data-chat-call-id="<id>"`, plus `data-selected="true"` for the selected
|
||||
* call. ChatView consumes those anchors to restore prepend/paging position.
|
||||
*/
|
||||
export interface ToolTreeOwnerProps {
|
||||
/** Root Tool call identity, stable across running → settled. */
|
||||
callId: CallId
|
||||
/** Root wire Tool name. */
|
||||
toolName: string
|
||||
/** Frozen root call slice: running call or settled result node. */
|
||||
block: ToolCallBlock
|
||||
/** Selected call id; the Tool owner resolves whether it is root or child. */
|
||||
selectedCallId?: CallId | undefined
|
||||
/** Session workspace root; path summaries display relative to it. */
|
||||
cwd?: string | undefined
|
||||
/**
|
||||
* Open a tool-arg filesystem path with the host OS default application.
|
||||
* The conversation owner resolves relative paths against the session cwd.
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
/**
|
||||
* Jump to any call in this tree in the trajectory view.
|
||||
*/
|
||||
inspectCall: (callId: CallId) => void
|
||||
/** Hook constrained to business data published on the current Chat Node's Turn. */
|
||||
export type UseChatNodeTurnData = <Key extends Extract<keyof ConversationTurnDataMap, string>>(
|
||||
key: Key,
|
||||
) => Readonly<ConversationTurnDataMap[Key]> | undefined
|
||||
|
||||
/** Slot-level Hook factory used by renderers reading their Node's Turn data. */
|
||||
export interface ChatNodeTurnDataInjected {
|
||||
hooks: {
|
||||
turnData: SlotHookFactory<'conversation.chat.node', UseChatNodeTurnData>
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable owner currency delivered to one keyed Chat business renderer. */
|
||||
export interface ChatNodeOwnerProps {
|
||||
/** Selected Tool call, when the shared details store names one. */
|
||||
selectedCallId?: CallId | undefined
|
||||
/** Session workspace root; Tool summaries display paths relative to it. */
|
||||
cwd?: string | undefined
|
||||
openFile: (path: string) => void
|
||||
inspectCall: (callId: CallId) => void
|
||||
forkAt: (seq: number) => void
|
||||
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
|
||||
}
|
||||
|
||||
/** Full props of one registered keyed Chat business renderer. */
|
||||
export type ChatNodeViewProps<Kind extends ChatNodeKind = ChatNodeKind> =
|
||||
PropsRuntime<'conversation.chat.node', Kind> & PropsLocale<'conversation'>
|
||||
|
||||
/** Owner currency of the details panel's Tool output renderer. */
|
||||
export interface DetailsToolOwnerProps {
|
||||
/** Frozen selected call slice. */
|
||||
@@ -322,7 +381,7 @@ export interface ComposerBarOwnerProps {
|
||||
|
||||
/** Injected share of the composer-bar entry (package-internal faces). */
|
||||
export interface ComposerBarInjected {
|
||||
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
|
||||
/** The InputBar-exclusive keyboard/DOM command face (private plane); absent with the session. */
|
||||
keyboard: ComposerKeyboard | undefined
|
||||
/** Resolve one keyboard submission gesture against the current running state and persisted preference. */
|
||||
resolveSubmitMode: (
|
||||
@@ -349,7 +408,8 @@ export interface ComposerBarInjected {
|
||||
hooks: {
|
||||
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
|
||||
notices: ObservableSnapshot<InputNotice | null>
|
||||
/** Hot plain-text reference lexicon for the decoration scan (decision 21). */
|
||||
/** Hot plain-text reference lexicon for the decoration scan (plain-text-reference decision;
|
||||
* see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md). */
|
||||
lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
|
||||
/** Source name opened by the programmatic menu launcher, or null. */
|
||||
menuLauncher: ObservableSnapshot<string | null>
|
||||
@@ -398,6 +458,7 @@ export type ConversationSlotProps =
|
||||
| 'conversation.input.dock' | 'conversation.composer.dock'
|
||||
| 'conversation.input.left' | 'conversation.input.right'
|
||||
| 'conversation.hero.workspace'
|
||||
| 'conversation.hero.agentPreset'
|
||||
>
|
||||
& InjectFace<ConversationInjected>
|
||||
& PropsLocale<'conversation'>
|
||||
@@ -519,11 +580,18 @@ export interface ChatViewInjected {
|
||||
}
|
||||
/** Fork through the completed turn ending at the eligible message `seq`, then open the child. */
|
||||
forkAt: (seq: number) => void
|
||||
/**
|
||||
* Prose file-mention vocabulary for one closing message, from the optional
|
||||
* {@link ChatFileMentions} service (resolved lazily per call, so composing
|
||||
* the provider in or out takes effect live). Undefined when the service is
|
||||
* absent or the turn produced nothing worth linking.
|
||||
*/
|
||||
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
|
||||
}
|
||||
|
||||
/** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */
|
||||
export type ChatViewSlotProps =
|
||||
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.tool' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'>
|
||||
PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.node'>
|
||||
& PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch,
|
||||
ConversationNodeContext, ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
emptyAssistantBlock, isAppendSurfaceEvent, isTokenDelta, toAssistantBlock, toAssistantBlocks,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { AssistantChatData } from '../contract/chat-nodes.ts'
|
||||
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Streaming, settled, or interrupted Assistant step. */
|
||||
'assistant-step': AssistantChatData
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-runtime/client' {
|
||||
interface ConversationStepDataMap {
|
||||
/** Streaming, settled, or interrupted Assistant material for this Step. */
|
||||
'assistant-step': AssistantChatData
|
||||
}
|
||||
}
|
||||
|
||||
interface AssistantState {
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
readonly blocks: readonly (AssistantBlock | undefined)[]
|
||||
readonly firstVisibleSeq: number | undefined
|
||||
readonly firstVisibleTime: number | undefined
|
||||
readonly firstTokenTime: number | undefined
|
||||
readonly hidden: boolean
|
||||
readonly final: ConversationMatch | undefined
|
||||
readonly usage: unknown
|
||||
}
|
||||
|
||||
function initialState(turn: number, step: number): AssistantState {
|
||||
return {
|
||||
turn,
|
||||
step,
|
||||
blocks: [],
|
||||
firstVisibleSeq: undefined,
|
||||
firstVisibleTime: undefined,
|
||||
firstTokenTime: undefined,
|
||||
hidden: false,
|
||||
final: undefined,
|
||||
usage: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function compactBlocks(blocks: readonly (AssistantBlock | undefined)[]): AssistantBlock[] {
|
||||
return blocks.filter((block): block is AssistantBlock => block !== undefined)
|
||||
}
|
||||
|
||||
function hasVisibleContent(blocks: readonly AssistantBlock[]): boolean {
|
||||
return blocks.some((block) => {
|
||||
if (block.kind === 'tool-call') return false
|
||||
if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function hasInterruptionEvidence(blocks: readonly AssistantBlock[]): boolean {
|
||||
return blocks.some((block) => {
|
||||
if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function resetForRetry(state: AssistantState): AssistantState {
|
||||
return {
|
||||
...initialState(state.turn, state.step),
|
||||
firstTokenTime: state.firstTokenTime,
|
||||
hidden: true,
|
||||
}
|
||||
}
|
||||
|
||||
function updateChunk(state: AssistantState, match: ConversationMatch): AssistantState {
|
||||
if (match.event.type !== 'assistant/chunk') return state
|
||||
const chunk = match.event.data.chunk
|
||||
const blocks = [...state.blocks]
|
||||
switch (chunk.type) {
|
||||
case 'block-start':
|
||||
blocks[chunk.index] = emptyAssistantBlock(chunk.blockType)
|
||||
break
|
||||
case 'text-delta': {
|
||||
const previous = blocks[chunk.index]
|
||||
blocks[chunk.index] = { kind: 'text', text: (previous?.kind === 'text' ? previous.text : '') + chunk.text }
|
||||
break
|
||||
}
|
||||
case 'reasoning-delta': {
|
||||
const previous = blocks[chunk.index]
|
||||
blocks[chunk.index] = { kind: 'reasoning', text: (previous?.kind === 'reasoning' ? previous.text : '') + chunk.text }
|
||||
break
|
||||
}
|
||||
case 'tool-call-delta': {
|
||||
const previous = blocks[chunk.index]
|
||||
const base = previous?.kind === 'tool-call'
|
||||
? previous
|
||||
: { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' }
|
||||
blocks[chunk.index] = {
|
||||
kind: 'tool-call',
|
||||
callId: base.callId || String(chunk.id),
|
||||
name: chunk.name ?? base.name,
|
||||
argsRaw: base.argsRaw + chunk.argumentsDelta,
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'block-end':
|
||||
blocks[chunk.index] = toAssistantBlock(chunk.block)
|
||||
break
|
||||
case 'usage':
|
||||
return { ...state, usage: chunk.usage }
|
||||
default:
|
||||
return state
|
||||
}
|
||||
const visible = hasVisibleContent(compactBlocks(blocks))
|
||||
const firstToken = isTokenDelta(chunk)
|
||||
return {
|
||||
...state,
|
||||
blocks,
|
||||
hidden: visible ? false : state.hidden,
|
||||
...visible && state.firstVisibleSeq === undefined
|
||||
? { firstVisibleSeq: match.event.seq, firstVisibleTime: match.event.time }
|
||||
: {},
|
||||
...firstToken && state.firstTokenTime === undefined
|
||||
? { firstTokenTime: match.event.time }
|
||||
: {},
|
||||
}
|
||||
}
|
||||
|
||||
function closedBoundary(location: ConversationLocation): { seq: number; time: number } | undefined {
|
||||
if (location.kind === 'step' && location.step.status === 'closed' && location.step.end !== undefined) {
|
||||
return location.step.end
|
||||
}
|
||||
if ((location.kind === 'step' || location.kind === 'turn')
|
||||
&& location.turn.status === 'closed' && location.turn.end !== undefined) {
|
||||
return location.turn.end
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function finalNode(
|
||||
state: AssistantState,
|
||||
context: ConversationNodeContext<AssistantState>,
|
||||
): AssistantMessageNode | undefined {
|
||||
const final = state.final
|
||||
if (final?.event.type === 'assistant/message') {
|
||||
const event = final.event
|
||||
return {
|
||||
kind: 'assistant',
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
turn: state.turn,
|
||||
step: state.step,
|
||||
blocks: toAssistantBlocks(event.data.message.content),
|
||||
usage: event.data.usage,
|
||||
timing: {
|
||||
stepStartTime: context.start?.event.time ?? null,
|
||||
firstTokenTime: state.firstTokenTime ?? null,
|
||||
completedTime: event.time,
|
||||
},
|
||||
}
|
||||
}
|
||||
const location = context.start?.location ?? context.matches.at(-1)?.location
|
||||
const boundary = location === undefined ? undefined : closedBoundary(location)
|
||||
const blocks = compactBlocks(state.blocks)
|
||||
if (boundary === undefined || !hasInterruptionEvidence(blocks)) return undefined
|
||||
return {
|
||||
kind: 'assistant',
|
||||
seq: boundary.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedAssistant,
|
||||
time: boundary.time,
|
||||
turn: state.turn,
|
||||
step: state.step,
|
||||
blocks,
|
||||
interrupted: true,
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackState(context: ConversationNodeContext<AssistantState>): AssistantState | undefined {
|
||||
let state: AssistantState | undefined
|
||||
for (const match of context.matches) {
|
||||
if (match.event.type === 'assistant/chunk') {
|
||||
state ??= initialState(match.event.data.turn, match.event.data.step)
|
||||
state = updateChunk(state, match)
|
||||
continue
|
||||
}
|
||||
if (match.event.type === 'assistant/message') {
|
||||
state ??= initialState(match.event.data.turn, match.event.data.step)
|
||||
state = {
|
||||
...state,
|
||||
blocks: toAssistantBlocks(match.event.data.message.content),
|
||||
hidden: false,
|
||||
final: match,
|
||||
usage: match.event.data.usage,
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (match.event.type === 'llm/retry' && state !== undefined) {
|
||||
state = resetForRetry(state)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
interface AssistantProjection {
|
||||
readonly data: AssistantChatData
|
||||
readonly anchorSeq: number
|
||||
readonly visible: boolean
|
||||
readonly settled: AssistantMessageNode | undefined
|
||||
}
|
||||
|
||||
function projectAssistant(context: ConversationNodeContext<AssistantState>): AssistantProjection | undefined {
|
||||
const state = context.state ?? fallbackState(context)
|
||||
if (state === undefined) return undefined
|
||||
const settled = finalNode(state, context)
|
||||
const blocks = settled?.blocks ?? compactBlocks(state.blocks)
|
||||
const visible = hasVisibleContent(blocks)
|
||||
const status = settled?.interrupted === true
|
||||
? 'interrupted'
|
||||
: settled === undefined ? 'running' : 'settled'
|
||||
const anchorSeq = settled?.seq ?? state.firstVisibleSeq ?? context.matches[0]?.event.seq ?? 0
|
||||
const time = settled?.time ?? state.firstVisibleTime ?? context.matches[0]?.event.time ?? 0
|
||||
return {
|
||||
anchorSeq,
|
||||
visible,
|
||||
settled,
|
||||
data: {
|
||||
status,
|
||||
turn: state.turn,
|
||||
step: state.step,
|
||||
blocks,
|
||||
time,
|
||||
...state.usage === undefined ? {} : { usage: state.usage },
|
||||
...settled === undefined ? {} : { finalNode: settled },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-step Assistant streaming/final/interruption Definition. */
|
||||
export const assistantDefinition: ConversationNodeDefinition<AssistantState> = {
|
||||
kind: 'assistant-step',
|
||||
match: (event) => {
|
||||
if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' }
|
||||
if (event.type === 'assistant/chunk'
|
||||
|| (event.type === 'assistant/message' && isAppendSurfaceEvent(event))) {
|
||||
return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
|
||||
}
|
||||
if (event.type === 'llm/retry') {
|
||||
return { id: `${event.data.turn}:${event.data.step}`, role: 'update' }
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'step/start') throw new Error('assistant-step start requires step/start')
|
||||
return initialState(match.event.data.turn, match.event.data.step)
|
||||
},
|
||||
update: (context, match) => {
|
||||
if (match.event.type === 'assistant/chunk') return updateChunk(context.state, match)
|
||||
if (match.event.type === 'assistant/message') {
|
||||
return {
|
||||
...context.state,
|
||||
blocks: toAssistantBlocks(match.event.data.message.content),
|
||||
hidden: false,
|
||||
final: match,
|
||||
usage: match.event.data.usage,
|
||||
}
|
||||
}
|
||||
if (match.event.type === 'llm/retry') {
|
||||
return resetForRetry(context.state)
|
||||
}
|
||||
return context.state
|
||||
},
|
||||
publication: (match) => {
|
||||
if (match.event.type === 'step/start') return 'none'
|
||||
if (match.event.type !== 'assistant/chunk') return 'immediate'
|
||||
const type = match.event.data.chunk.type
|
||||
return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'
|
||||
},
|
||||
buildLocationData: (context, scope) => {
|
||||
if (scope !== 'step') return null
|
||||
const projected = projectAssistant(context)
|
||||
if (projected === undefined) return null
|
||||
return {
|
||||
kind: 'step',
|
||||
turn: projected.data.turn,
|
||||
step: projected.data.step,
|
||||
key: 'assistant-step',
|
||||
value: projected.data,
|
||||
}
|
||||
},
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat') return null
|
||||
const projected = projectAssistant(context)
|
||||
if (projected === undefined) return null
|
||||
if (projected.settled === undefined && !projected.visible) {
|
||||
const state = context.state ?? fallbackState(context)
|
||||
if (state === undefined) return null
|
||||
const current = context.current.get('chat')
|
||||
if (!state.hidden || current === undefined || current === null) return null
|
||||
}
|
||||
return chatNode(context, 'assistant-step', projected.anchorSeq, projected.data, {
|
||||
visibility: projected.settled?.interrupted === true || projected.visible ? 'visible' : 'hidden',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Assistant lifecycle business contribution.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerAssistantConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(assistantDefinition)
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot,
|
||||
ConversationLocation, ConversationNode, ConversationTimelineSnapshot,
|
||||
ConversationViewBuilder, ConversationViewDefinition, LegacyConversationSlice,
|
||||
PartialAssistant, RunningToolCall,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
import { isRunningTool } from '../contract/chat-nodes.ts'
|
||||
|
||||
const EMPTY_KEYS: readonly string[] = []
|
||||
const EMPTY_TURNS: readonly number[] = []
|
||||
const EMPTY_LIST: readonly never[] = []
|
||||
|
||||
function sameReferences<T>(left: readonly T[], right: readonly T[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
class MutableChatNodeStore implements ChatNodeStore {
|
||||
private readonly byKey = new Map<string, ChatConversationViewNode>()
|
||||
private valuesCache: readonly ChatConversationViewNode[] = EMPTY_LIST
|
||||
private valuesDirty = false
|
||||
|
||||
get(key: string): ChatConversationViewNode | undefined {
|
||||
return this.byKey.get(key)
|
||||
}
|
||||
|
||||
values(): readonly ChatConversationViewNode[] {
|
||||
if (this.valuesDirty) {
|
||||
this.valuesCache = [...this.byKey.values()]
|
||||
this.valuesDirty = false
|
||||
}
|
||||
return this.valuesCache
|
||||
}
|
||||
|
||||
replace(nodes: readonly ChatConversationViewNode[]): void {
|
||||
this.byKey.clear()
|
||||
for (const node of nodes) this.byKey.set(node.key, node)
|
||||
this.valuesCache = [...this.byKey.values()]
|
||||
this.valuesDirty = false
|
||||
}
|
||||
|
||||
upsert(nodes: readonly ChatConversationViewNode[]): void {
|
||||
let changed = false
|
||||
for (const node of nodes) {
|
||||
if (this.byKey.get(node.key) === node) continue
|
||||
this.byKey.set(node.key, node)
|
||||
changed = true
|
||||
}
|
||||
if (changed) this.valuesDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
class MutableChatLocationIndex implements ChatLocationNodeIndex {
|
||||
private turns = new Map<number, readonly string[]>()
|
||||
private steps = new Map<string, readonly string[]>()
|
||||
|
||||
getTurn(turn: number): readonly string[] {
|
||||
return this.turns.get(turn) ?? EMPTY_KEYS
|
||||
}
|
||||
|
||||
getStep(turn: number, step: number): readonly string[] {
|
||||
return this.steps.get(stepKey(turn, step)) ?? EMPTY_KEYS
|
||||
}
|
||||
|
||||
rebuild(order: readonly string[], store: ChatNodeStore): void {
|
||||
const turns = new Map<number, string[]>()
|
||||
const steps = new Map<string, string[]>()
|
||||
for (const key of order) {
|
||||
const location = store.get(key)?.location
|
||||
if (location === undefined) continue
|
||||
const coordinates = locationCoordinates(location)
|
||||
if (coordinates.turn === undefined) continue
|
||||
const turnKeys = turns.get(coordinates.turn) ?? []
|
||||
turnKeys.push(key)
|
||||
turns.set(coordinates.turn, turnKeys)
|
||||
if (coordinates.step === undefined) continue
|
||||
const step = stepKey(coordinates.turn, coordinates.step)
|
||||
const stepKeys = steps.get(step) ?? []
|
||||
stepKeys.push(key)
|
||||
steps.set(step, stepKeys)
|
||||
}
|
||||
this.turns = updateIndex(this.turns, turns)
|
||||
this.steps = updateIndex(this.steps, steps)
|
||||
}
|
||||
|
||||
/** Invalidate aggregate readers when member data changes without moving. */
|
||||
touch(nodes: readonly ChatConversationViewNode[]): void {
|
||||
const turns = new Set<number>()
|
||||
const steps = new Set<string>()
|
||||
for (const node of nodes) {
|
||||
const coordinates = locationCoordinates(node.location)
|
||||
if (coordinates.turn === undefined || !this.turns.get(coordinates.turn)?.includes(node.key)) continue
|
||||
turns.add(coordinates.turn)
|
||||
if (coordinates.step !== undefined) steps.add(stepKey(coordinates.turn, coordinates.step))
|
||||
}
|
||||
for (const turn of turns) {
|
||||
const keys = this.turns.get(turn)
|
||||
if (keys === undefined) continue
|
||||
this.turns.set(turn, [...keys])
|
||||
}
|
||||
for (const step of steps) {
|
||||
const keys = this.steps.get(step)
|
||||
if (keys === undefined) continue
|
||||
this.steps.set(step, [...keys])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateIndex<Key>(
|
||||
previous: ReadonlyMap<Key, readonly string[]>,
|
||||
nextMutable: ReadonlyMap<Key, string[]>,
|
||||
): Map<Key, readonly string[]> {
|
||||
const next = new Map<Key, readonly string[]>()
|
||||
const keys = new Set([...previous.keys(), ...nextMutable.keys()])
|
||||
for (const key of keys) {
|
||||
const before = previous.get(key) ?? EMPTY_KEYS
|
||||
const candidate = nextMutable.get(key) ?? EMPTY_KEYS
|
||||
const value = sameReferences(before, candidate) ? before : candidate
|
||||
if (candidate.length > 0) next.set(key, value)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function stepKey(turn: number, step: number): string {
|
||||
return `${turn}:${step}`
|
||||
}
|
||||
|
||||
function locationCoordinates(location: ConversationLocation): { turn?: number; step?: number } {
|
||||
if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step }
|
||||
if (location.kind === 'turn') return { turn: location.turn.turn }
|
||||
return {}
|
||||
}
|
||||
|
||||
function orderedVisible(nodes: readonly ChatConversationViewNode[]): ChatConversationViewNode[] {
|
||||
return nodes
|
||||
.filter(node => node.visibility === 'visible')
|
||||
.sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key))
|
||||
}
|
||||
|
||||
interface LegacyContribution {
|
||||
readonly anchorSeq: number
|
||||
readonly nodes: readonly ConversationNode[]
|
||||
readonly partial: PartialAssistant | null
|
||||
readonly running: RunningToolCall | null
|
||||
}
|
||||
|
||||
const EMPTY_CONTRIBUTION: LegacyContribution = {
|
||||
anchorSeq: 0,
|
||||
nodes: EMPTY_LIST,
|
||||
partial: null,
|
||||
running: null,
|
||||
}
|
||||
|
||||
function legacyContribution(raw: ChatConversationViewNode): LegacyContribution {
|
||||
const node = raw as ChatNode
|
||||
// Content-free settled Assistants remain in the finalized compatibility
|
||||
// stream so StatsLine preserves its pre-assembly step counts; hidden running
|
||||
// attempts have no final Node to contribute.
|
||||
if (raw.visibility !== 'visible' && node.kind !== 'assistant-step') return EMPTY_CONTRIBUTION
|
||||
switch (node.kind) {
|
||||
case 'user':
|
||||
case 'steering':
|
||||
case 'context':
|
||||
case 'command':
|
||||
case 'compaction':
|
||||
case 'turn-error':
|
||||
case 'unknown':
|
||||
return { anchorSeq: node.anchorSeq, nodes: [node.data], partial: null, running: null }
|
||||
case 'assistant-step': {
|
||||
const data = node.data
|
||||
if (data.status === 'running') {
|
||||
if (raw.visibility !== 'visible') return EMPTY_CONTRIBUTION
|
||||
return {
|
||||
anchorSeq: node.anchorSeq,
|
||||
nodes: EMPTY_LIST,
|
||||
partial: { turn: data.turn, step: data.step, blocks: data.blocks },
|
||||
running: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
anchorSeq: node.anchorSeq,
|
||||
nodes: data.finalNode === undefined ? EMPTY_LIST : [data.finalNode],
|
||||
partial: null,
|
||||
running: null,
|
||||
}
|
||||
}
|
||||
case 'tool-call': {
|
||||
const root = node.data.root
|
||||
return isRunningTool(root)
|
||||
? { anchorSeq: node.anchorSeq, nodes: EMPTY_LIST, partial: null, running: root }
|
||||
: { anchorSeq: node.anchorSeq, nodes: [root], partial: null, running: null }
|
||||
}
|
||||
case 'manual-compaction': {
|
||||
const data = node.data
|
||||
return {
|
||||
anchorSeq: node.anchorSeq,
|
||||
nodes: data.compaction === null ? [data.command] : [data.command, data.compaction],
|
||||
partial: null,
|
||||
running: null,
|
||||
}
|
||||
}
|
||||
case 'model-retry':
|
||||
return {
|
||||
anchorSeq: node.anchorSeq,
|
||||
nodes: node.data.attempts,
|
||||
partial: null,
|
||||
running: null,
|
||||
}
|
||||
case 'turn-tail':
|
||||
return EMPTY_CONTRIBUTION
|
||||
default:
|
||||
return EMPTY_CONTRIBUTION
|
||||
}
|
||||
}
|
||||
|
||||
function sameContribution(left: LegacyContribution | undefined, right: LegacyContribution): boolean {
|
||||
return left !== undefined
|
||||
&& left.anchorSeq === right.anchorSeq
|
||||
&& left.partial?.blocks === right.partial?.blocks
|
||||
&& left.partial?.turn === right.partial?.turn
|
||||
&& left.partial?.step === right.partial?.step
|
||||
&& left.running === right.running
|
||||
&& sameReferences(left.nodes, right.nodes)
|
||||
}
|
||||
|
||||
/** Incremental compatibility projection for StatsLine and legacy top-level snapshot fields. */
|
||||
class LegacySliceBuilder {
|
||||
private readonly contributions = new Map<string, LegacyContribution>()
|
||||
private readonly finalizedContributions = new Map<string, LegacyContribution>()
|
||||
private readonly runningContributions = new Map<string, LegacyContribution>()
|
||||
private readonly partialContributions = new Map<string, LegacyContribution>()
|
||||
private finalized: readonly ConversationNode[] = EMPTY_LIST
|
||||
private runningCalls: readonly RunningToolCall[] = EMPTY_LIST
|
||||
private partial: PartialAssistant | null = null
|
||||
private timeline: ConversationTimelineSnapshot | undefined
|
||||
private turnTimings: LegacyConversationSlice['turnTimings'] = new Map()
|
||||
private turnEnds: LegacyConversationSlice['turnEnds'] = new Map()
|
||||
|
||||
replace(
|
||||
nodes: readonly ChatConversationViewNode[],
|
||||
timeline: ConversationTimelineSnapshot,
|
||||
): LegacyConversationSlice {
|
||||
this.contributions.clear()
|
||||
this.finalizedContributions.clear()
|
||||
this.runningContributions.clear()
|
||||
this.partialContributions.clear()
|
||||
for (const node of nodes) {
|
||||
const contribution = legacyContribution(node)
|
||||
this.contributions.set(node.key, contribution)
|
||||
this.indexContribution(node.key, contribution)
|
||||
}
|
||||
this.rebuildFinalized()
|
||||
this.rebuildRunning()
|
||||
this.rebuildPartial()
|
||||
this.updateTimeline(timeline)
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
apply(
|
||||
upserts: readonly ChatConversationViewNode[],
|
||||
timeline: ConversationTimelineSnapshot,
|
||||
): LegacyConversationSlice {
|
||||
let finalizedChanged = false
|
||||
let runningChanged = false
|
||||
let partialChanged = false
|
||||
for (const node of upserts) {
|
||||
const contribution = legacyContribution(node)
|
||||
const previous = this.contributions.get(node.key)
|
||||
if (sameContribution(previous, contribution)) continue
|
||||
finalizedChanged ||= finalizedContributionChanged(previous, contribution)
|
||||
runningChanged ||= runningContributionChanged(previous, contribution)
|
||||
partialChanged ||= partialContributionChanged(previous, contribution)
|
||||
this.contributions.set(node.key, contribution)
|
||||
this.indexContribution(node.key, contribution)
|
||||
}
|
||||
if (finalizedChanged) this.rebuildFinalized()
|
||||
if (runningChanged) this.rebuildRunning()
|
||||
if (partialChanged) this.rebuildPartial()
|
||||
this.updateTimeline(timeline)
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
private indexContribution(key: string, contribution: LegacyContribution): void {
|
||||
updateContributionIndex(this.finalizedContributions, key, contribution, contribution.nodes.length > 0)
|
||||
updateContributionIndex(this.runningContributions, key, contribution, contribution.running !== null)
|
||||
updateContributionIndex(this.partialContributions, key, contribution, contribution.partial !== null)
|
||||
}
|
||||
|
||||
private rebuildFinalized(): void {
|
||||
const finalized = [...this.finalizedContributions.values()]
|
||||
.flatMap(value => value.nodes)
|
||||
.sort((left, right) => left.seq - right.seq)
|
||||
if (!sameReferences(this.finalized, finalized)) this.finalized = finalized
|
||||
}
|
||||
|
||||
private rebuildRunning(): void {
|
||||
const runningCalls = [...this.runningContributions.values()]
|
||||
.sort((left, right) => left.anchorSeq - right.anchorSeq)
|
||||
.flatMap(value => value.running === null ? [] : [value.running])
|
||||
if (!sameReferences(this.runningCalls, runningCalls)) this.runningCalls = runningCalls
|
||||
}
|
||||
|
||||
private rebuildPartial(): void {
|
||||
const partial = [...this.partialContributions.values()]
|
||||
.sort((left, right) => left.anchorSeq - right.anchorSeq)
|
||||
.findLast(value => value.partial !== null)?.partial ?? null
|
||||
if (this.partial?.blocks !== partial?.blocks
|
||||
|| this.partial?.turn !== partial?.turn
|
||||
|| this.partial?.step !== partial?.step) this.partial = partial
|
||||
}
|
||||
|
||||
private updateTimeline(timeline: ConversationTimelineSnapshot): void {
|
||||
if (this.timeline === timeline) return
|
||||
this.timeline = timeline
|
||||
const turnTimings = new Map<number, { startTime: number; endTime?: number }>()
|
||||
const turnEnds = new Map<number, number>()
|
||||
for (const turn of timeline.turns.values()) {
|
||||
if (turn.start !== undefined) {
|
||||
turnTimings.set(turn.turn, {
|
||||
startTime: turn.start.time,
|
||||
...turn.end === undefined ? {} : { endTime: turn.end.time },
|
||||
})
|
||||
}
|
||||
if (turn.end !== undefined) turnEnds.set(turn.turn, turn.end.seq)
|
||||
}
|
||||
this.turnTimings = turnTimings
|
||||
this.turnEnds = turnEnds
|
||||
}
|
||||
|
||||
private snapshot(): LegacyConversationSlice {
|
||||
return {
|
||||
nodes: this.finalized,
|
||||
turnTimings: this.turnTimings,
|
||||
turnEnds: this.turnEnds,
|
||||
partial: this.partial,
|
||||
runningCalls: this.runningCalls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateContributionIndex(
|
||||
index: Map<string, LegacyContribution>,
|
||||
key: string,
|
||||
contribution: LegacyContribution,
|
||||
present: boolean,
|
||||
): void {
|
||||
if (present) index.set(key, contribution)
|
||||
else index.delete(key)
|
||||
}
|
||||
|
||||
function finalizedContributionChanged(
|
||||
previous: LegacyContribution | undefined,
|
||||
next: LegacyContribution,
|
||||
): boolean {
|
||||
const previousNodes = previous?.nodes ?? EMPTY_LIST
|
||||
return !sameReferences(previousNodes, next.nodes)
|
||||
|| ((previousNodes.length > 0 || next.nodes.length > 0) && previous?.anchorSeq !== next.anchorSeq)
|
||||
}
|
||||
|
||||
function runningContributionChanged(
|
||||
previous: LegacyContribution | undefined,
|
||||
next: LegacyContribution,
|
||||
): boolean {
|
||||
return previous?.running !== next.running
|
||||
|| ((previous.running !== null || next.running !== null)
|
||||
&& previous.anchorSeq !== next.anchorSeq)
|
||||
}
|
||||
|
||||
function partialContributionChanged(
|
||||
previous: LegacyContribution | undefined,
|
||||
next: LegacyContribution,
|
||||
): boolean {
|
||||
return previous?.partial?.blocks !== next.partial?.blocks
|
||||
|| previous?.partial?.turn !== next.partial?.turn
|
||||
|| previous?.partial?.step !== next.partial?.step
|
||||
|| (((previous?.partial ?? null) !== null || next.partial !== null)
|
||||
&& previous?.anchorSeq !== next.anchorSeq)
|
||||
}
|
||||
|
||||
/** Incremental keyed Chat builder registered under the `chat` target. */
|
||||
export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversationViewNode, ChatSnapshot> {
|
||||
private readonly store = new MutableChatNodeStore()
|
||||
private readonly locations = new MutableChatLocationIndex()
|
||||
private readonly legacy = new LegacySliceBuilder()
|
||||
private order: readonly string[] = EMPTY_KEYS
|
||||
readonly empty: ChatSnapshot
|
||||
|
||||
constructor() {
|
||||
this.empty = this.snapshot({ turnOrder: EMPTY_TURNS, turns: new Map() })
|
||||
}
|
||||
|
||||
replace(input: {
|
||||
readonly nodes: readonly ChatConversationViewNode[]
|
||||
readonly timeline: ConversationTimelineSnapshot
|
||||
}): ChatSnapshot {
|
||||
this.store.replace(input.nodes)
|
||||
this.order = orderedVisible(input.nodes).map(node => node.key)
|
||||
this.locations.rebuild(this.order, this.store)
|
||||
return this.snapshot(input.timeline, this.legacy.replace(input.nodes, input.timeline))
|
||||
}
|
||||
|
||||
apply(input: {
|
||||
readonly upserts: readonly ChatConversationViewNode[]
|
||||
readonly timeline: ConversationTimelineSnapshot
|
||||
}): ChatSnapshot {
|
||||
let structural = false
|
||||
const contentOnly: ChatConversationViewNode[] = []
|
||||
for (const node of input.upserts) {
|
||||
const previous = this.store.get(node.key)
|
||||
const nodeStructural = previous === undefined
|
||||
|| previous.anchorSeq !== node.anchorSeq
|
||||
|| previous.visibility !== node.visibility
|
||||
|| locationIdentity(previous.location) !== locationIdentity(node.location)
|
||||
structural ||= nodeStructural
|
||||
if (!nodeStructural) contentOnly.push(node)
|
||||
}
|
||||
this.store.upsert(input.upserts)
|
||||
if (structural) {
|
||||
const next = orderedVisible(this.store.values()).map(node => node.key)
|
||||
this.order = sameReferences(this.order, next) ? this.order : next
|
||||
this.locations.rebuild(this.order, this.store)
|
||||
}
|
||||
this.locations.touch(contentOnly)
|
||||
return this.snapshot(input.timeline, this.legacy.apply(input.upserts, input.timeline))
|
||||
}
|
||||
|
||||
private snapshot(
|
||||
timeline: ConversationTimelineSnapshot,
|
||||
legacy = this.legacy.replace(EMPTY_LIST, timeline),
|
||||
): ChatSnapshot {
|
||||
return {
|
||||
order: this.order,
|
||||
nodes: this.store,
|
||||
locations: this.locations,
|
||||
timeline,
|
||||
legacy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function locationIdentity(location: ConversationLocation): string {
|
||||
const coordinates = locationCoordinates(location)
|
||||
return `${location.kind}:${coordinates.turn ?? ''}:${coordinates.step ?? ''}`
|
||||
}
|
||||
|
||||
/** Chat target factory contributed to the Runtime view registry. */
|
||||
export const chatViewDefinition: ConversationViewDefinition<ChatConversationViewNode, ChatSnapshot> = {
|
||||
target: 'chat',
|
||||
create: () => new ChatSnapshotBuilder(),
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the incremental Chat target builder.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerChatConversationView(ctx: Context): void {
|
||||
ctx.conversationViews.register(chatViewDefinition)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext,
|
||||
ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CompactCheckpointSource } from '@deepseek-ai/dsh-compact/checkpoint'
|
||||
import type {} from '@deepseek-ai/dsh-compact/types'
|
||||
import type {} from '@deepseek-ai/dsh-commands/types'
|
||||
import type { ManualCompactionChatData } from '../contract/chat-nodes.ts'
|
||||
import { chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Ordinary slash-command lifecycle. */
|
||||
command: CommandNode
|
||||
/** Manual compact command combined with its compaction transaction. */
|
||||
'manual-compaction': ManualCompactionChatData
|
||||
}
|
||||
}
|
||||
|
||||
type CommandId = CommandNode['commandId']
|
||||
|
||||
const COMPACT_PLUGIN: CompactCheckpointSource['plugin'] = 'compact'
|
||||
|
||||
interface CommandState {
|
||||
readonly command: CommandNode
|
||||
readonly summary?: ConversationMatch
|
||||
readonly checkpoint?: ConversationMatch
|
||||
}
|
||||
|
||||
interface CompactionEvidence {
|
||||
readonly summary?: ConversationMatch
|
||||
readonly checkpoint?: ConversationMatch
|
||||
}
|
||||
|
||||
function commandFromRun(match: ConversationMatch): CommandNode {
|
||||
if (match.event.type !== 'command/run') throw new Error('command start requires command/run')
|
||||
const data = match.event.data
|
||||
return {
|
||||
kind: 'command',
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
commandId: data.commandId,
|
||||
name: data.name,
|
||||
args: data.args ?? null,
|
||||
outcome: null,
|
||||
}
|
||||
}
|
||||
|
||||
function commandFromDone(match: ConversationMatch, previous?: CommandNode): CommandNode {
|
||||
if (match.event.type !== 'command/done') throw new Error('command update requires command/done')
|
||||
const data = match.event.data
|
||||
const sourceEventSeq = data.kind === 'success'
|
||||
&& data.sourceEventSeq !== undefined
|
||||
&& Number.isSafeInteger(data.sourceEventSeq) && data.sourceEventSeq >= 0
|
||||
? data.sourceEventSeq
|
||||
: undefined
|
||||
return {
|
||||
kind: 'command',
|
||||
seq: previous?.seq ?? match.event.seq,
|
||||
time: previous?.time ?? match.event.time,
|
||||
commandId: data.commandId,
|
||||
name: previous?.name ?? null,
|
||||
args: previous?.args ?? null,
|
||||
outcome: {
|
||||
kind: data.kind,
|
||||
...data.text === undefined ? {} : { text: data.text },
|
||||
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read correlation identity from a compaction replacement checkpoint.
|
||||
* @param event - candidate Session event.
|
||||
* @returns correlated compaction and optional command identity.
|
||||
*/
|
||||
function compactSource(event: Parameters<ConversationNodeDefinition['match']>[0]): {
|
||||
compactionId: string
|
||||
sourceCommandId?: CommandId
|
||||
} | undefined {
|
||||
if (event.type !== 'user/message' || !isReplacementSurfaceEvent(event)) return undefined
|
||||
const source = event.data.source as unknown as {
|
||||
kind?: unknown
|
||||
plugin?: unknown
|
||||
compactionId?: unknown
|
||||
sourceCommandId?: CommandId
|
||||
}
|
||||
if (source.kind !== 'plugin' || source.plugin !== COMPACT_PLUGIN || typeof source.compactionId !== 'string') return undefined
|
||||
return {
|
||||
compactionId: source.compactionId,
|
||||
...source.sourceCommandId === undefined ? {} : { sourceCommandId: source.sourceCommandId },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the visible summary marker from optional lifecycle evidence.
|
||||
* @param match - compact/summary Match, when loaded.
|
||||
* @param checkpoint - replacement checkpoint Match.
|
||||
* @returns final compaction summary Node data.
|
||||
*/
|
||||
function compactSummary(match: ConversationMatch | undefined, checkpoint: ConversationMatch): CompactionSummaryNode {
|
||||
let summary: string | null = null
|
||||
let shadowedItemCount: number | null = null
|
||||
let shadowedTokenCount: number | null = null
|
||||
if (match?.event.type === 'compact/summary') {
|
||||
const data = match.event.data
|
||||
if (Array.isArray(data.summary)) {
|
||||
const text = data.summary
|
||||
.map(block => block.type === 'text' ? block.text : '')
|
||||
.join('')
|
||||
summary = text.trim() === '' ? null : text
|
||||
}
|
||||
shadowedItemCount = Array.isArray(data.shadowedSeqs)
|
||||
&& data.shadowedSeqs.every(seq => Number.isSafeInteger(seq) && seq >= 0)
|
||||
? data.shadowedSeqs.length
|
||||
: null
|
||||
shadowedTokenCount = Number.isSafeInteger(data.shadowedTokenCount)
|
||||
&& data.shadowedTokenCount >= 0
|
||||
? data.shadowedTokenCount
|
||||
: null
|
||||
}
|
||||
return {
|
||||
kind: 'compaction',
|
||||
seq: checkpoint.event.seq,
|
||||
time: checkpoint.event.time,
|
||||
summary,
|
||||
summaryEventSeq: match?.event.seq ?? null,
|
||||
shadowedItemCount,
|
||||
shadowedTokenCount,
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackState(context: ConversationNodeContext<CommandState>): CommandState | undefined {
|
||||
const done = context.matches.find(match => match.event.type === 'command/done')
|
||||
const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined)
|
||||
const summary = context.matches.find(match => match.event.type === 'compact/summary')
|
||||
if (checkpoint === undefined) return done === undefined ? undefined : { command: commandFromDone(done) }
|
||||
const source = compactSource(checkpoint.event)
|
||||
if (source?.sourceCommandId === undefined) return done === undefined ? undefined : { command: commandFromDone(done) }
|
||||
const fallbackCommand = done === undefined
|
||||
? {
|
||||
kind: 'command' as const,
|
||||
seq: checkpoint.event.seq,
|
||||
time: checkpoint.event.time,
|
||||
commandId: source.sourceCommandId,
|
||||
name: 'compact',
|
||||
args: null,
|
||||
outcome: null,
|
||||
}
|
||||
: { ...commandFromDone(done), name: 'compact' }
|
||||
return {
|
||||
command: fallbackCommand,
|
||||
checkpoint,
|
||||
...summary === undefined ? {} : { summary },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold shared compaction evidence into a Definition-owned State.
|
||||
* @param state - current business State carrying optional compaction evidence.
|
||||
* @param match - next compaction lifecycle Match.
|
||||
* @returns adopted State, preserving reference identity when the Match adds no evidence.
|
||||
*/
|
||||
export function updateCompactionState<State extends CompactionEvidence>(
|
||||
state: State,
|
||||
match: ConversationMatch,
|
||||
): State {
|
||||
if (match.event.type === 'compact/summary') return { ...state, summary: match }
|
||||
if (compactSource(match.event) !== undefined) return { ...state, checkpoint: match }
|
||||
return state
|
||||
}
|
||||
|
||||
/** Slash-command lifecycle, including integrated manual compaction, Definition. */
|
||||
export const commandDefinition: ConversationNodeDefinition<CommandState> = {
|
||||
kind: 'command',
|
||||
match: (event) => {
|
||||
if (event.type === 'command/run') {
|
||||
return { id: String(event.data.commandId), role: 'start' }
|
||||
}
|
||||
if (event.type === 'command/done') {
|
||||
return { id: String(event.data.commandId), role: 'update' }
|
||||
}
|
||||
const checkpoint = compactSource(event)
|
||||
if (checkpoint?.sourceCommandId !== undefined) {
|
||||
return { id: String(checkpoint.sourceCommandId), role: 'update' }
|
||||
}
|
||||
if (event.type === 'compact/start'
|
||||
|| event.type === 'compact/summary'
|
||||
|| event.type === 'compact/end') {
|
||||
if (event.data.sourceCommandId !== undefined) {
|
||||
return { id: String(event.data.sourceCommandId), role: 'update' }
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: (_context, match) => ({ command: commandFromRun(match) }),
|
||||
update: (context, match) => {
|
||||
if (match.event.type === 'command/done') {
|
||||
return { ...context.state, command: commandFromDone(match, context.state.command) }
|
||||
}
|
||||
return updateCompactionState(context.state, match)
|
||||
},
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat') return null
|
||||
const state = context.state ?? fallbackState(context)
|
||||
if (state === undefined) return null
|
||||
if (state.command.name !== 'compact') {
|
||||
return chatNode(context, 'command', state.command.seq, state.command)
|
||||
}
|
||||
const compaction = state.checkpoint === undefined
|
||||
? null
|
||||
: compactSummary(state.summary, state.checkpoint)
|
||||
const data: ManualCompactionChatData = { command: state.command, compaction }
|
||||
return chatNode(context, 'manual-compaction', compaction?.seq ?? state.command.seq, data)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the command lifecycle business contribution.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerCommandConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(commandDefinition)
|
||||
}
|
||||
|
||||
/** Shared structural checkpoint recognizer for automatic compaction. */
|
||||
export { compactSource, compactSummary }
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {
|
||||
ConversationLocation, ConversationNodeContext,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ChatNode, ChatNodeDataMap, ChatNodeKind,
|
||||
} from '../contract/chat-nodes.ts'
|
||||
|
||||
/**
|
||||
* Relative positions in one durable event's seq neighborhood: interrupted
|
||||
* Assistant, its follow-up Nodes, then follow-ups to an ordinary final.
|
||||
*/
|
||||
export const CHAT_SYNTHETIC_SEQ_OFFSETS = {
|
||||
interruptedAssistant: -0.9,
|
||||
interruptedFollowup: -0.8,
|
||||
finalizedFollowup: 0.1,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Resolve one Context's best currently loaded event Location.
|
||||
* @param context - assembled business Context.
|
||||
* @returns start or first-match Location, otherwise unresolved.
|
||||
*/
|
||||
export function contextLocation(context: ConversationNodeContext): ConversationLocation {
|
||||
return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one final Chat target Node with the engine-owned stable key.
|
||||
* @param context - assembled business Context.
|
||||
* @param kind - Chat renderer dispatch key.
|
||||
* @param anchorSeq - sortable render position.
|
||||
* @param data - renderer-owned payload.
|
||||
* @param options - optional Location and visibility overrides.
|
||||
* @returns final Chat view Node.
|
||||
*/
|
||||
export function chatNode<Kind extends ChatNodeKind>(
|
||||
context: ConversationNodeContext,
|
||||
kind: Kind,
|
||||
anchorSeq: number,
|
||||
data: ChatNodeDataMap[Kind],
|
||||
options: {
|
||||
readonly location?: ConversationLocation
|
||||
readonly visibility?: 'visible' | 'hidden'
|
||||
} = {},
|
||||
): ChatNode<Kind> {
|
||||
return {
|
||||
key: context.key,
|
||||
kind,
|
||||
id: context.id,
|
||||
target: 'chat',
|
||||
anchorSeq,
|
||||
location: options.location ?? contextLocation(context),
|
||||
visibility: options.visibility ?? 'visible',
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a finite non-negative integer from a structurally narrowed payload.
|
||||
* @param value - untrusted payload field.
|
||||
* @returns valid coordinate, otherwise undefined.
|
||||
*/
|
||||
export function coordinate(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-compact/types'
|
||||
import { chatNode } from './common.ts'
|
||||
import { compactSource, compactSummary, updateCompactionState } from './command.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Automatic compaction checkpoint marker. */
|
||||
compaction: CompactionSummaryNode
|
||||
}
|
||||
}
|
||||
|
||||
interface CompactionState {
|
||||
readonly summary?: ConversationMatch
|
||||
readonly checkpoint?: ConversationMatch
|
||||
}
|
||||
|
||||
function fallbackState(context: ConversationNodeContext<CompactionState>): CompactionState {
|
||||
const summary = context.matches.find(match => match.event.type === 'compact/summary')
|
||||
const checkpoint = context.matches.find(match => compactSource(match.event) !== undefined)
|
||||
return {
|
||||
...summary === undefined ? {} : { summary },
|
||||
...checkpoint === undefined ? {} : { checkpoint },
|
||||
}
|
||||
}
|
||||
|
||||
/** Automatic compaction lifecycle and landed checkpoint Definition. */
|
||||
export const compactionDefinition: ConversationNodeDefinition<CompactionState> = {
|
||||
kind: 'compaction',
|
||||
match: (event) => {
|
||||
const checkpoint = compactSource(event)
|
||||
if (checkpoint !== undefined && checkpoint.sourceCommandId === undefined) {
|
||||
return { id: checkpoint.compactionId, role: 'update' }
|
||||
}
|
||||
if (event.type === 'compact/start'
|
||||
|| event.type === 'compact/summary'
|
||||
|| event.type === 'compact/end') {
|
||||
if (event.data.sourceCommandId !== undefined) return null
|
||||
const compactionId: unknown = event.data.compactionId
|
||||
if (typeof compactionId !== 'string' || compactionId === '') return null
|
||||
return { id: compactionId, role: event.type === 'compact/start' ? 'start' : 'update' }
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: () => ({}),
|
||||
update: (context, match) => updateCompactionState(context.state, match),
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat') return null
|
||||
const state = context.state ?? fallbackState(context)
|
||||
if (state.checkpoint === undefined) return null
|
||||
const marker = compactSummary(state.summary, state.checkpoint)
|
||||
return chatNode(context, 'compaction', marker.seq, marker)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the automatic-compaction business contribution.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerCompactionConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(compactionDefinition)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
ConversationNodeDefinition, UnknownSurfaceNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Generic presentation of an unclaimed append-surface event. */
|
||||
unknown: UnknownSurfaceNode
|
||||
}
|
||||
}
|
||||
|
||||
/** Unclaimed append-surface fallback Definition. */
|
||||
export const unknownFallbackDefinition: ConversationNodeDefinition<UnknownSurfaceNode> = {
|
||||
kind: 'unknown-surface',
|
||||
match: event => isAppendSurfaceEvent(event)
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match) => ({
|
||||
kind: 'unknown',
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
type: match.event.type,
|
||||
data: match.event.data,
|
||||
}),
|
||||
update: context => context.state,
|
||||
buildViewNode: (context, target) => target !== 'chat' || context.state === undefined
|
||||
? null
|
||||
: chatNode(context, 'unknown', context.state.seq, context.state),
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the unmatched append-surface fallback contribution.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerUnknownConversationFallback(ctx: Context): void {
|
||||
ctx.conversationEvents.registerFallback(unknownFallbackDefinition)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
ConversationNodeDefinition, ConversationPreviousContext,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InboxTarget } from '@deepseek-ai/dsh-agent/types'
|
||||
|
||||
interface InboxIdentity {
|
||||
readonly id: string
|
||||
}
|
||||
|
||||
interface InboxSplice {
|
||||
readonly target: InboxTarget
|
||||
readonly start: number
|
||||
readonly removedCount?: number
|
||||
readonly inserted: readonly InboxIdentity[]
|
||||
readonly outcome?: 'canceled'
|
||||
}
|
||||
|
||||
/** Cumulative state after one durable inbox splice. */
|
||||
export interface InboxState {
|
||||
readonly pending: readonly InboxIdentity[]
|
||||
readonly claimed: ReadonlySet<string>
|
||||
}
|
||||
|
||||
function applySplice(
|
||||
previous: ConversationPreviousContext<InboxState> | undefined,
|
||||
splice: InboxSplice,
|
||||
): InboxState {
|
||||
const pending = [...(previous?.state.pending ?? [])]
|
||||
const claimed = new Set(previous?.state.claimed ?? [])
|
||||
const removed = pending.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted)
|
||||
for (const identity of splice.inserted) claimed.delete(identity.id)
|
||||
if (splice.target === 'next-step' && splice.outcome !== 'canceled') {
|
||||
for (const identity of removed) claimed.add(identity.id)
|
||||
}
|
||||
return { pending, claimed }
|
||||
}
|
||||
|
||||
function inboxDefinition(target: InboxTarget): ConversationNodeDefinition<InboxState> {
|
||||
const kind = `inbox-${target}`
|
||||
return {
|
||||
kind,
|
||||
match: event => event.type === 'agent/inbox/spliced'
|
||||
&& event.data.target === target
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match, reader) => {
|
||||
if (match.event.type !== 'agent/inbox/spliced') throw new Error(`${kind} start requires agent/inbox/spliced`)
|
||||
return applySplice(reader.previous<InboxState>(kind), match.event.data)
|
||||
},
|
||||
update: context => context.state,
|
||||
publication: () => 'none',
|
||||
buildViewNode: () => null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Cumulative next-turn inbox splice Definition. */
|
||||
export const nextTurnInboxDefinition = inboxDefinition('next-turn')
|
||||
|
||||
/** Cumulative next-step inbox splice Definition used to classify steering. */
|
||||
export const nextStepInboxDefinition = inboxDefinition('next-step')
|
||||
|
||||
/**
|
||||
* Register the two durable Inbox-state contributions.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerInboxConversationNodes(ctx: Context): void {
|
||||
ctx.conversationEvents.register(nextTurnInboxDefinition)
|
||||
ctx.conversationEvents.register(nextStepInboxDefinition)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
contextForm, contextProvenance, isAppendSurfaceEvent, isReplacementSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InboxState } from './inbox.ts'
|
||||
import { chatNode } from './common.ts'
|
||||
|
||||
type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Ordinary turn-opening user message. */
|
||||
user: UserMessageNode
|
||||
/** User message admitted into an active turn. */
|
||||
steering: SteeringMessageNode
|
||||
/** Non-user context injected into model history. */
|
||||
context: ContextMessageNode
|
||||
}
|
||||
}
|
||||
|
||||
function isCompactionCheckpoint(event: Parameters<ConversationNodeDefinition['match']>[0]): boolean {
|
||||
if (event.type !== 'user/message' || !isReplacementSurfaceEvent(event)) return false
|
||||
const source = event.data.source
|
||||
return source.kind === 'plugin' && source.plugin === 'compact'
|
||||
}
|
||||
|
||||
/** User, steering, and injected-context message classification Definition. */
|
||||
export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
|
||||
kind: 'input-message',
|
||||
match: event => event.type === 'user/message'
|
||||
&& isAppendSurfaceEvent(event)
|
||||
&& !isCompactionCheckpoint(event)
|
||||
? { id: String(event.data.id), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match, reader) => {
|
||||
if (match.event.type !== 'user/message') throw new Error('input-message start requires user/message')
|
||||
const event = match.event
|
||||
if (event.data.source.kind !== 'user') {
|
||||
return {
|
||||
kind: 'context',
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
content: event.data.content,
|
||||
source: event.data.source,
|
||||
provenance: contextProvenance(event.data.source),
|
||||
form: contextForm(event.data.source),
|
||||
}
|
||||
}
|
||||
const claimed = reader.previous<InboxState>('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true
|
||||
return claimed
|
||||
? {
|
||||
kind: 'steering',
|
||||
messageId: event.data.id,
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
content: event.data.content,
|
||||
source: event.data.source,
|
||||
}
|
||||
: {
|
||||
kind: 'user',
|
||||
seq: event.seq,
|
||||
time: event.time,
|
||||
content: event.data.content,
|
||||
source: event.data.source,
|
||||
}
|
||||
},
|
||||
update: context => context.state,
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat' || context.state === undefined) return null
|
||||
return chatNode(context, context.state.kind, context.state.seq, context.state)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the user, steering, and injected-context message contribution.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerMessageConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(messageDefinition)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { registerAssistantConversationNode } from './assistant.ts'
|
||||
import { registerChatConversationView } from './chat-snapshot-builder.ts'
|
||||
import { registerCommandConversationNode } from './command.ts'
|
||||
import { registerCompactionConversationNode } from './compaction.ts'
|
||||
import { registerUnknownConversationFallback } from './fallback.ts'
|
||||
import { registerInboxConversationNodes } from './inbox.ts'
|
||||
import { registerMessageConversationNode } from './message.ts'
|
||||
import { registerRetryConversationNode } from './retry.ts'
|
||||
import { registerToolConversationNode } from './tool.ts'
|
||||
import { registerTurnErrorConversationNode } from './turn-error.ts'
|
||||
import { registerTurnTailConversationNode } from './turn-tail.ts'
|
||||
|
||||
/**
|
||||
* Register the Chat business Definitions and target builder contributed by this package.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerConversationNodes(ctx: Context): void {
|
||||
registerInboxConversationNodes(ctx)
|
||||
registerMessageConversationNode(ctx)
|
||||
registerAssistantConversationNode(ctx)
|
||||
registerToolConversationNode(ctx)
|
||||
registerCommandConversationNode(ctx)
|
||||
registerCompactionConversationNode(ctx)
|
||||
registerRetryConversationNode(ctx)
|
||||
registerTurnErrorConversationNode(ctx)
|
||||
registerTurnTailConversationNode(ctx)
|
||||
registerUnknownConversationFallback(ctx)
|
||||
registerChatConversationView(ctx)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
ConversationLocation, ConversationNodeDefinition, ModelRetryNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type { RetryChatData } from '../contract/chat-nodes.ts'
|
||||
import { chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Producer-correlated model retry chain. */
|
||||
'model-retry': RetryChatData
|
||||
}
|
||||
}
|
||||
|
||||
/** Accumulated retry attempts sharing one producer-owned RetryId. */
|
||||
export interface RetryState {
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
readonly attempts: readonly ModelRetryNode[]
|
||||
}
|
||||
|
||||
function scheduledNode(match: Parameters<ConversationNodeDefinition['start']>[1]): ModelRetryNode | undefined {
|
||||
if (match.event.type !== 'llm/retry') return undefined
|
||||
return {
|
||||
kind: 'model-retry',
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
retryState: 'scheduled',
|
||||
...match.event.data,
|
||||
}
|
||||
}
|
||||
|
||||
/** A scheduled attempt is cancelled once either owning boundary closes. */
|
||||
function isClosed(location: ConversationLocation): boolean {
|
||||
return (location.kind === 'step' && location.step.status === 'closed')
|
||||
|| ((location.kind === 'step' || location.kind === 'turn') && location.turn.status === 'closed')
|
||||
}
|
||||
|
||||
/** Producer-correlated model retry chain Definition. */
|
||||
export const retryDefinition: ConversationNodeDefinition<RetryState> = {
|
||||
kind: 'model-retry',
|
||||
match: (event) => {
|
||||
if (event.type === 'llm/retry') {
|
||||
const retryId: unknown = event.data.retryId
|
||||
if (typeof retryId !== 'string' || retryId === '') return null
|
||||
return { id: retryId, role: event.data.retry === 1 ? 'start' : 'update' }
|
||||
}
|
||||
if (event.type === 'llm/retry-started') {
|
||||
const retryId: unknown = event.data.retryId
|
||||
return typeof retryId === 'string' && retryId !== '' ? { id: retryId, role: 'update' } : null
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: (_context, match) => {
|
||||
const node = scheduledNode(match)
|
||||
if (node === undefined) throw new Error('model-retry start requires a valid llm/retry event')
|
||||
return { turn: node.turn, step: node.step, attempts: [node] }
|
||||
},
|
||||
update: (context, match) => {
|
||||
if (match.event.type === 'llm/retry') {
|
||||
const node = scheduledNode(match)
|
||||
return node === undefined ? context.state : { ...context.state, attempts: [...context.state.attempts, node] }
|
||||
}
|
||||
if (match.event.type !== 'llm/retry-started') return context.state
|
||||
const retry = match.event.data.retry
|
||||
return {
|
||||
...context.state,
|
||||
attempts: context.state.attempts.map(attempt =>
|
||||
attempt.retry === retry ? { ...attempt, retryState: 'started' } : attempt),
|
||||
}
|
||||
},
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat' || context.state === undefined || context.state.attempts.length === 0) return null
|
||||
const location = context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' as const }
|
||||
const stateAttempts = context.state.attempts
|
||||
const attempts = stateAttempts.map((attempt, index) =>
|
||||
index === stateAttempts.length - 1
|
||||
&& attempt.retryState === 'scheduled'
|
||||
&& isClosed(location)
|
||||
? { ...attempt, retryState: 'cancelled' as const }
|
||||
: attempt)
|
||||
const current = attempts.at(-1)
|
||||
if (current === undefined) return null
|
||||
const data: RetryChatData = { attempts, current }
|
||||
return chatNode(context, 'model-retry', attempts[0]?.seq ?? current.seq, data)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the correlated model-retry business contribution.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerRetryConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(retryDefinition)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition,
|
||||
RunningToolCall, ToolCallBlock, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-tools/types'
|
||||
import type { ToolChatData } from '../contract/chat-nodes.ts'
|
||||
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Root Tool lifecycle with recursively nested subcalls. */
|
||||
'tool-call': ToolChatData
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_DEPTH = 256
|
||||
|
||||
interface ToolState {
|
||||
readonly root: ToolCallBlock
|
||||
readonly children: ReadonlyMap<string, readonly ToolCallBlock[]>
|
||||
readonly parents: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
interface ProjectedBlockCache {
|
||||
readonly children: readonly ToolCallBlock[]
|
||||
readonly interruptionSeq: number | undefined
|
||||
readonly interruptionTime: number | undefined
|
||||
readonly value: ToolCallBlock
|
||||
}
|
||||
|
||||
const projectedBlocks = new WeakMap<ToolCallBlock, ProjectedBlockCache>()
|
||||
|
||||
function jsonArguments(value: unknown): string {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function rootCall(match: ConversationMatch): RunningToolCall {
|
||||
if (match.event.type !== 'tool/call') throw new Error('tool-call start requires tool/call')
|
||||
return {
|
||||
callId: String(match.event.data.callId),
|
||||
name: match.event.data.name,
|
||||
argsRaw: match.event.data.arguments,
|
||||
turn: match.event.data.turn,
|
||||
step: match.event.data.step,
|
||||
time: match.event.time,
|
||||
callView: match.view?.for === 'call' ? match.view.view : null,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
|
||||
function rootResult(match: ConversationMatch, previous?: RunningToolCall): ToolResultNode | undefined {
|
||||
if (match.event.type !== 'tool/result') return undefined
|
||||
const result = match.event.data.message.content[0]
|
||||
return {
|
||||
kind: 'tool-result',
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
callId: String(match.event.data.message.source.callId),
|
||||
call: previous === undefined ? null : { name: previous.name, argsRaw: previous.argsRaw },
|
||||
callTime: previous?.time ?? null,
|
||||
content: result.content,
|
||||
isError: result.isError === true,
|
||||
...match.event.data.error === undefined ? {} : { error: match.event.data.error },
|
||||
meta: match.event.data.meta,
|
||||
callView: previous?.callView ?? null,
|
||||
resultView: match.view?.for === 'result' ? match.view.view : null,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
|
||||
interface DispatchData {
|
||||
readonly parentCallId: string
|
||||
readonly subCallId: string
|
||||
readonly name: string
|
||||
readonly arguments: unknown
|
||||
readonly isError?: boolean
|
||||
readonly content?: ToolResultNode['content']
|
||||
}
|
||||
|
||||
function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall {
|
||||
return {
|
||||
callId: data.subCallId,
|
||||
name: data.name,
|
||||
argsRaw: jsonArguments(data.arguments),
|
||||
turn: locationTurn(match),
|
||||
step: locationStep(match),
|
||||
time: match.event.time,
|
||||
callView: null,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
|
||||
function childResult(match: ConversationMatch, data: DispatchData, previous?: ToolCallBlock): ToolResultNode {
|
||||
return {
|
||||
kind: 'tool-result',
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
callId: data.subCallId,
|
||||
call: { name: data.name, argsRaw: jsonArguments(data.arguments) },
|
||||
callTime: previous?.time ?? null,
|
||||
content: data.content ?? [],
|
||||
isError: data.isError === true,
|
||||
callView: null,
|
||||
resultView: null,
|
||||
subCalls: [],
|
||||
}
|
||||
}
|
||||
|
||||
function locationTurn(match: ConversationMatch): number {
|
||||
return match.location.kind === 'step' || match.location.kind === 'turn' ? match.location.turn.turn : 0
|
||||
}
|
||||
|
||||
function locationStep(match: ConversationMatch): number {
|
||||
return match.location.kind === 'step' ? match.location.step.step : 0
|
||||
}
|
||||
|
||||
function acceptsEdge(state: ToolState, parent: string, child: string): boolean {
|
||||
if (parent === child || state.parents.has(child)) return false
|
||||
let cursor: string | undefined = parent
|
||||
let parentDepth = 0
|
||||
const ancestors = new Set<string>()
|
||||
while (cursor !== undefined) {
|
||||
if (cursor === child || ancestors.has(cursor)) return false
|
||||
ancestors.add(cursor)
|
||||
parentDepth++
|
||||
cursor = state.parents.get(cursor)
|
||||
}
|
||||
const pending = [{ callId: child, depth: 1 }]
|
||||
const descendants = new Set<string>()
|
||||
let subtreeDepth = 0
|
||||
for (const candidate of pending) {
|
||||
if (descendants.has(candidate.callId)) return false
|
||||
descendants.add(candidate.callId)
|
||||
subtreeDepth = Math.max(subtreeDepth, candidate.depth)
|
||||
for (const nested of state.children.get(candidate.callId) ?? []) {
|
||||
pending.push({ callId: nested.callId, depth: candidate.depth + 1 })
|
||||
}
|
||||
}
|
||||
return parentDepth + subtreeDepth <= MAX_DEPTH
|
||||
}
|
||||
|
||||
function updateDispatch(state: ToolState, match: ConversationMatch): ToolState {
|
||||
const event = match.event
|
||||
if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return state
|
||||
const data = event.data
|
||||
const parentCallId = String(data.parentCallId)
|
||||
const subCallId = String(data.subCallId)
|
||||
const siblings = state.children.get(parentCallId) ?? []
|
||||
const index = siblings.findIndex(candidate => candidate.callId === subCallId)
|
||||
if (event.type === 'tool/code-dispatch-start') {
|
||||
if (index >= 0 || !acceptsEdge(state, parentCallId, subCallId)) return state
|
||||
const children = new Map(state.children)
|
||||
children.set(parentCallId, [...siblings, childCall(match, data)])
|
||||
const parents = new Map(state.parents)
|
||||
parents.set(subCallId, parentCallId)
|
||||
return { ...state, children, parents }
|
||||
}
|
||||
if (index < 0 && !acceptsEdge(state, parentCallId, subCallId)) return state
|
||||
const previous = index < 0 ? undefined : siblings[index]
|
||||
const settled = childResult(match, data, previous)
|
||||
const children = new Map(state.children)
|
||||
children.set(parentCallId, index < 0
|
||||
? [...siblings, settled]
|
||||
: siblings.map((child, at) => at === index ? settled : child))
|
||||
const parents = new Map(state.parents)
|
||||
if (index < 0) parents.set(subCallId, parentCallId)
|
||||
return { ...state, children, parents }
|
||||
}
|
||||
|
||||
function projectBlock(
|
||||
block: ToolCallBlock,
|
||||
state: ToolState,
|
||||
interruptedAt: { seq: number; time: number } | undefined,
|
||||
visited = new Set<string>(),
|
||||
depth = 1,
|
||||
): ToolCallBlock {
|
||||
if (visited.has(block.callId) || depth > MAX_DEPTH) return { ...block, subCalls: [] }
|
||||
const nextVisited = new Set(visited)
|
||||
nextVisited.add(block.callId)
|
||||
const children = (state.children.get(block.callId) ?? block.subCalls)
|
||||
.map(child => projectBlock(child, state, interruptedAt, nextVisited, depth + 1))
|
||||
const interruptionSeq = 'kind' in block ? undefined : interruptedAt?.seq
|
||||
const interruptionTime = 'kind' in block ? undefined : interruptedAt?.time
|
||||
const cached = projectedBlocks.get(block)
|
||||
if (cached !== undefined
|
||||
&& cached.interruptionSeq === interruptionSeq
|
||||
&& cached.interruptionTime === interruptionTime
|
||||
&& sameReferences(cached.children, children)) {
|
||||
return cached.value
|
||||
}
|
||||
const projected: ToolCallBlock = 'kind' in block || interruptedAt === undefined
|
||||
? sameReferences(block.subCalls, children) ? block : { ...block, subCalls: children }
|
||||
: {
|
||||
kind: 'tool-result',
|
||||
seq: interruptedAt.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup,
|
||||
time: interruptedAt.time,
|
||||
callId: block.callId,
|
||||
call: { name: block.name, argsRaw: block.argsRaw },
|
||||
callTime: block.time,
|
||||
content: [],
|
||||
isError: true,
|
||||
error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: block.callView,
|
||||
resultView: null,
|
||||
subCalls: children,
|
||||
}
|
||||
projectedBlocks.set(block, { children, interruptionSeq, interruptionTime, value: projected })
|
||||
return projected
|
||||
}
|
||||
|
||||
function sameReferences<T>(left: readonly T[], right: readonly T[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function interruption(context: ConversationNodeContext<ToolState>): { seq: number; time: number } | undefined {
|
||||
const location = context.start?.location
|
||||
if (location?.kind === 'step' && location.step.status === 'closed') return location.step.end
|
||||
if ((location?.kind === 'step' || location?.kind === 'turn') && location.turn.status === 'closed') {
|
||||
return location.turn.end
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function fallbackState(context: ConversationNodeContext<ToolState>): ToolState | undefined {
|
||||
const match = context.matches.find(candidate => candidate.event.type === 'tool/result')
|
||||
const root = match === undefined ? undefined : rootResult(match)
|
||||
if (root === undefined) return undefined
|
||||
let state: ToolState = { root, children: new Map(), parents: new Map() }
|
||||
for (const candidate of context.matches) state = updateDispatch(state, candidate)
|
||||
return state
|
||||
}
|
||||
|
||||
/** Root Tool lifecycle and nested Code Dispatch Definition. */
|
||||
export const toolDefinition: ConversationNodeDefinition<ToolState> = {
|
||||
kind: 'tool-call',
|
||||
match: (event) => {
|
||||
if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' }
|
||||
if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) {
|
||||
return { id: String(event.data.message.source.callId), role: 'update' }
|
||||
}
|
||||
if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') {
|
||||
const rootCallId: unknown = event.data.rootCallId
|
||||
return typeof rootCallId === 'string' && rootCallId !== ''
|
||||
? { id: rootCallId, role: 'update' }
|
||||
: null
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: (_context, match) => ({ root: rootCall(match), children: new Map(), parents: new Map() }),
|
||||
update: (context, match) => {
|
||||
if (match.event.type === 'tool/result') {
|
||||
const running = 'kind' in context.state.root ? undefined : context.state.root
|
||||
const result = rootResult(match, running)
|
||||
return result === undefined ? context.state : { ...context.state, root: result }
|
||||
}
|
||||
return updateDispatch(context.state, match)
|
||||
},
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat') return null
|
||||
const state = context.state ?? fallbackState(context)
|
||||
if (state === undefined) return null
|
||||
const projected = projectBlock(state.root, state, interruption(context))
|
||||
const anchor = context.start?.event.seq
|
||||
?? ('kind' in state.root ? state.root.seq : context.matches[0]?.event.seq ?? 0)
|
||||
return chatNode(context, 'tool-call', anchor, { root: projected } satisfies ToolChatData)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the root Tool lifecycle and nested-subcall contribution.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerToolConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(toolDefinition)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { displayFailureMessage } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import { chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Terminal turn failure not superseded by retry. */
|
||||
'turn-error': TurnErrorNode
|
||||
}
|
||||
}
|
||||
|
||||
interface TurnErrorState {
|
||||
readonly turn: number
|
||||
readonly hidden: boolean
|
||||
readonly failure?: {
|
||||
readonly seq: number
|
||||
readonly time: number
|
||||
readonly message: string
|
||||
readonly code?: string
|
||||
}
|
||||
}
|
||||
|
||||
function lastStep(context: ConversationNodeContext<TurnErrorState>): number {
|
||||
const location = context.start?.location ?? context.matches[0]?.location
|
||||
if (location?.kind !== 'turn' && location?.kind !== 'step') return 0
|
||||
return location.turn.steps.at(-1)?.step ?? 0
|
||||
}
|
||||
|
||||
function retryTurn(event: Parameters<ConversationNodeDefinition['match']>[0]): number | undefined {
|
||||
return event.type === 'llm/retry' || event.type === 'llm/retry-started'
|
||||
? event.data.turn
|
||||
: undefined
|
||||
}
|
||||
|
||||
function failureFrom(match: ConversationMatch): TurnErrorState['failure'] | undefined {
|
||||
if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'error') return undefined
|
||||
const failure = match.event.data.reason.error
|
||||
return {
|
||||
seq: match.event.seq,
|
||||
time: match.event.time,
|
||||
message: displayFailureMessage(failure),
|
||||
code: failure.code,
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackState(context: ConversationNodeContext<TurnErrorState>): TurnErrorState | undefined {
|
||||
const end = context.matches.find(match => failureFrom(match) !== undefined)
|
||||
if (end?.event.type !== 'turn/end') return undefined
|
||||
const failure = failureFrom(end)
|
||||
if (failure === undefined) return undefined
|
||||
const turn = end.event.data.turn
|
||||
return {
|
||||
turn,
|
||||
hidden: context.matches.some(match => retryTurn(match.event) === turn),
|
||||
failure,
|
||||
}
|
||||
}
|
||||
|
||||
/** Terminal turn failure Definition, suppressed when the turn owns a retry chain. */
|
||||
export const turnErrorDefinition: ConversationNodeDefinition<TurnErrorState> = {
|
||||
kind: 'turn-error',
|
||||
match: (event) => {
|
||||
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
|
||||
if (event.type === 'turn/end' && event.data.reason.kind === 'error') {
|
||||
return { id: String(event.data.turn), role: 'update' }
|
||||
}
|
||||
const turn = retryTurn(event)
|
||||
return turn === undefined ? null : { id: String(turn), role: 'update' }
|
||||
},
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'turn/start') throw new Error('turn-error start requires turn/start')
|
||||
return { turn: match.event.data.turn, hidden: false }
|
||||
},
|
||||
update: (context, match) => {
|
||||
const failure = failureFrom(match)
|
||||
if (failure !== undefined) return { ...context.state, failure }
|
||||
return retryTurn(match.event) === context.state.turn
|
||||
? { ...context.state, hidden: true }
|
||||
: context.state
|
||||
},
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat') return null
|
||||
const state = context.state ?? fallbackState(context)
|
||||
if (state?.failure === undefined) return null
|
||||
const failure = state.failure
|
||||
const node: TurnErrorNode = {
|
||||
kind: 'turn-error',
|
||||
seq: failure.seq,
|
||||
time: failure.time,
|
||||
turn: state.turn,
|
||||
step: lastStep(context),
|
||||
message: failure.message,
|
||||
...failure.code === undefined ? {} : { code: failure.code },
|
||||
}
|
||||
if (!state.hidden) return chatNode(context, 'turn-error', node.seq, node)
|
||||
const current = context.current.get('chat')
|
||||
return current === undefined || current === null
|
||||
? null
|
||||
: chatNode(context, 'turn-error', node.seq, node, { visibility: 'hidden' })
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the terminal Turn-error business contribution.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerTurnErrorConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(turnErrorDefinition)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { isAppendSurfaceEvent, toAssistantBlocks } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import type {
|
||||
AssistantChatData, FinalAssistantChatData, TurnTailChatData,
|
||||
} from '../contract/chat-nodes.ts'
|
||||
import { deriveTurnMetrics } from '../chat/turn-metrics.ts'
|
||||
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Completed-turn actions and extension tail. */
|
||||
'turn-tail': TurnTailChatData
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-runtime/client' {
|
||||
interface ConversationTurnDataMap {
|
||||
/** Closing Assistant and footer facts derived for this completed Turn. */
|
||||
'turn-tail': TurnTailChatData
|
||||
}
|
||||
}
|
||||
|
||||
interface TurnTailState {
|
||||
readonly turn: number
|
||||
readonly end?: ConversationMatch
|
||||
}
|
||||
|
||||
interface StepEvidence {
|
||||
readonly streamedText: boolean
|
||||
readonly finalized: boolean
|
||||
}
|
||||
|
||||
function hasTextAssistant(event: Parameters<ConversationNodeDefinition['match']>[0]): boolean {
|
||||
return event.type === 'assistant/message'
|
||||
&& isAppendSurfaceEvent(event)
|
||||
&& toAssistantBlocks(event.data.message.content)
|
||||
.some(block => block.kind === 'text' && block.text.trim() !== '')
|
||||
}
|
||||
|
||||
function chunkHasText(event: Parameters<ConversationNodeDefinition['match']>[0]): boolean {
|
||||
if (event.type !== 'assistant/chunk') return false
|
||||
const chunk = event.data.chunk
|
||||
if (chunk.type === 'text-delta') return chunk.text.trim() !== ''
|
||||
return chunk.type === 'block-end'
|
||||
&& chunk.block.type === 'text'
|
||||
&& chunk.block.text.trim() !== ''
|
||||
}
|
||||
|
||||
function turnCoordinates(event: Parameters<ConversationNodeDefinition['match']>[0]): {
|
||||
readonly turn: number
|
||||
readonly step?: number
|
||||
} | undefined {
|
||||
if (event.type === 'assistant/message'
|
||||
|| event.type === 'assistant/chunk'
|
||||
|| event.type === 'step/end') {
|
||||
return { turn: event.data.turn, step: event.data.step }
|
||||
}
|
||||
if (event.type === 'llm/retry') return { turn: event.data.turn, step: event.data.step }
|
||||
return undefined
|
||||
}
|
||||
|
||||
function closingAnchor(context: ConversationNodeContext<TurnTailState>): number {
|
||||
let anchor = context.matches.find(match => match.event.type === 'turn/end')?.event.seq
|
||||
?? context.start?.event.seq
|
||||
?? context.matches[0]?.event.seq
|
||||
?? 0
|
||||
const steps = new Map<number, StepEvidence>()
|
||||
for (const match of context.matches) {
|
||||
const event = match.event
|
||||
if (event.type === 'turn/end') continue
|
||||
const coordinates = turnCoordinates(event)
|
||||
if (coordinates?.step === undefined) continue
|
||||
const previous = steps.get(coordinates.step) ?? { streamedText: false, finalized: false }
|
||||
if (event.type === 'assistant/chunk') {
|
||||
steps.set(coordinates.step, {
|
||||
...previous,
|
||||
streamedText: previous.streamedText || chunkHasText(event),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === 'assistant/message') {
|
||||
steps.set(coordinates.step, { streamedText: false, finalized: true })
|
||||
if (hasTextAssistant(event)) {
|
||||
anchor = event.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.finalizedFollowup
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (event.type === 'llm/retry') {
|
||||
steps.set(coordinates.step, { streamedText: false, finalized: false })
|
||||
continue
|
||||
}
|
||||
if (event.type === 'step/end' && previous.streamedText && !previous.finalized) {
|
||||
anchor = event.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.interruptedFollowup
|
||||
}
|
||||
}
|
||||
return anchor
|
||||
}
|
||||
|
||||
function turnLocation(context: ConversationNodeContext<TurnTailState>): TurnLocation | undefined {
|
||||
const location = context.start?.location ?? context.matches[0]?.location
|
||||
return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined
|
||||
}
|
||||
|
||||
function hasText(data: AssistantChatData): data is FinalAssistantChatData {
|
||||
return data.finalNode !== undefined
|
||||
&& data.blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
|
||||
}
|
||||
|
||||
function tailData(context: ConversationNodeContext<TurnTailState>): TurnTailChatData | null {
|
||||
const end = context.state?.end
|
||||
?? context.matches.find(match => match.event.type === 'turn/end')
|
||||
if (end?.event.type !== 'turn/end') return null
|
||||
const turn = turnLocation(context)
|
||||
if (turn === undefined) return null
|
||||
const assistants = turn.steps
|
||||
.map(step => step.data.get('assistant-step'))
|
||||
.filter((candidate): candidate is Readonly<AssistantChatData> => candidate !== undefined)
|
||||
const finalized = assistants
|
||||
.filter((candidate): candidate is Readonly<FinalAssistantChatData> => candidate.finalNode !== undefined)
|
||||
.sort((left, right) => left.finalNode.seq - right.finalNode.seq)
|
||||
const closing = finalized.findLast(hasText) ?? null
|
||||
let latestTranscriptSeq = finalized.at(-1)?.finalNode.seq
|
||||
for (const match of context.matches) {
|
||||
const event = match.event
|
||||
const candidate = event.type === 'tool/call'
|
||||
|| (event.type === 'tool/result' && isAppendSurfaceEvent(event))
|
||||
|| (event.type === 'turn/end' && event.data.reason.kind === 'error')
|
||||
|| event.type === 'llm/retry'
|
||||
? event.seq
|
||||
: undefined
|
||||
if (candidate !== undefined && (latestTranscriptSeq === undefined || candidate > latestTranscriptSeq)) {
|
||||
latestTranscriptSeq = candidate
|
||||
}
|
||||
}
|
||||
const metrics = deriveTurnMetrics(finalized.map(candidate => candidate.finalNode)).get(end.event.data.turn)
|
||||
return {
|
||||
turn: end.event.data.turn,
|
||||
seq: end.event.seq,
|
||||
time: end.event.time,
|
||||
closing,
|
||||
branchUnavailable: closing === null || latestTranscriptSeq !== closing.finalNode.seq,
|
||||
...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs },
|
||||
...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond },
|
||||
}
|
||||
}
|
||||
|
||||
/** Completed-turn footer Definition independent of any Assistant row. */
|
||||
export const turnTailDefinition: ConversationNodeDefinition<TurnTailState> = {
|
||||
kind: 'turn-tail',
|
||||
match: (event) => {
|
||||
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
|
||||
if (event.type === 'turn/end') return { id: String(event.data.turn), role: 'update' }
|
||||
if (event.type === 'tool/call' || event.type === 'tool/result') {
|
||||
return { id: String(event.data.turn), role: 'update' }
|
||||
}
|
||||
const coordinates = turnCoordinates(event)
|
||||
if (coordinates !== undefined) return { id: String(coordinates.turn), role: 'update' }
|
||||
return null
|
||||
},
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'turn/start') throw new Error('turn-tail start requires turn/start')
|
||||
return { turn: match.event.data.turn }
|
||||
},
|
||||
update: (context, match) => match.event.type === 'turn/end'
|
||||
? { ...context.state, end: match }
|
||||
: context.state,
|
||||
publication: match => match.event.type === 'turn/end' ? 'immediate' : 'none',
|
||||
buildLocationData: (context, scope) => {
|
||||
if (scope !== 'turn') return null
|
||||
const value = tailData(context)
|
||||
return value === null ? null : {
|
||||
kind: 'turn',
|
||||
turn: value.turn,
|
||||
key: 'turn-tail',
|
||||
value,
|
||||
}
|
||||
},
|
||||
buildViewNode: (context, target) => {
|
||||
if (target !== 'chat') return null
|
||||
const turn = turnLocation(context)
|
||||
const data = turn?.data.get('turn-tail')
|
||||
return data === undefined ? null : chatNode(context, 'turn-tail', closingAnchor(context), data)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Register completed-Turn footer data and its Chat node contribution.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerTurnTailConversationNode(ctx: Context): void {
|
||||
ctx.conversationEvents.register(turnTailDefinition)
|
||||
}
|
||||
@@ -3,6 +3,16 @@
|
||||
* between the independently implemented skeleton and chat domains; `apply.ts`
|
||||
* owns their slot assembly.
|
||||
*/
|
||||
export type {} from './conversation-nodes/assistant.ts'
|
||||
export type {} from './conversation-nodes/command.ts'
|
||||
export type {} from './conversation-nodes/compaction.ts'
|
||||
export type {} from './conversation-nodes/fallback.ts'
|
||||
export type {} from './conversation-nodes/message.ts'
|
||||
export type {} from './conversation-nodes/retry.ts'
|
||||
export type {} from './conversation-nodes/tool.ts'
|
||||
export type {} from './conversation-nodes/turn-error.ts'
|
||||
export type {} from './conversation-nodes/turn-tail.ts'
|
||||
|
||||
export { apply, inject } from './apply.ts'
|
||||
export { ConversationService } from './service.ts'
|
||||
export type { IConversation } from './service.ts'
|
||||
@@ -12,11 +22,16 @@ export type {
|
||||
} from './contract/views.ts'
|
||||
export type { ConversationKey } from './locales.ts'
|
||||
export type {
|
||||
AssistantChatData, ChatNode, ChatNodeDataMap, ChatNodeKind, ManualCompactionChatData,
|
||||
RetryChatData, ToolChatData, TurnTailChatData,
|
||||
} from './contract/chat-nodes.ts'
|
||||
export type {
|
||||
ChatFileMentions, ChatNodeOwnerProps, ChatNodeViewProps,
|
||||
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
|
||||
ComposerChainProps, ConversationInjected,
|
||||
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,
|
||||
ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps,
|
||||
ToolTreeOwnerProps, TurnTailOwnerProps,
|
||||
TurnTailOwnerProps, UseChatNodeTurnData,
|
||||
} from './contract/slots.ts'
|
||||
// Export discipline: packages/client/AGENTS.md.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Frozen input-machine contract (design §9.1, eng. plan §3.9-3.12). Types
|
||||
* Frozen input-machine contract. Types
|
||||
* only. Three-tier visibility: business packages see InputState via the
|
||||
* InputZone currency; the scoped input events carry the mutation verbs; the
|
||||
* conversation wiring layer alone sees the full SessionInput. InputMachine
|
||||
@@ -56,7 +56,7 @@ export interface InputService {
|
||||
|
||||
/**
|
||||
* The public input action face provided to every session-scope slot
|
||||
* component (decision 20): two stable-identity void callbacks, mirroring the
|
||||
* component: two stable-identity void callbacks, mirroring the
|
||||
* useStore+actions convention. Command-style handles (track/arbitrate/space/
|
||||
* undo/paste/…) stay InputBar-private and never ride this face.
|
||||
*/
|
||||
@@ -75,7 +75,7 @@ export interface InputNotice {
|
||||
}
|
||||
|
||||
/**
|
||||
* The InputBar-exclusive keyboard/DOM command face (decision 20): synchronous
|
||||
* The InputBar-exclusive keyboard/DOM command face: synchronous
|
||||
* returns and event-handler semantics that must not enter the public provide
|
||||
* channel. Handed to the composer-bar entry through its own inject —
|
||||
* package-internal, never across a plugin boundary. The session shell
|
||||
@@ -128,7 +128,7 @@ export interface EditRange extends EditSelection {
|
||||
|
||||
/**
|
||||
* One reference chip occurrence, backing exactly one U+FFFC placeholder in
|
||||
* the draft (design §9.1 底层表示). Identity is occurrenceId — same-named
|
||||
* the draft. Identity is occurrenceId — same-named
|
||||
* references stay independently addressable. label/clipboardText are the
|
||||
* owner's insert-time projections, cached so the chip survives owner loss
|
||||
* (invalid flips instead of dropping the occurrence).
|
||||
@@ -157,7 +157,7 @@ export interface PasteComponent extends EditSelection {
|
||||
|
||||
/**
|
||||
* Live paste-match attempt published while async matching may still upgrade
|
||||
* pasted tokens (design §9.1 剪贴板 round-trip). Any non-paste transaction,
|
||||
* pasted tokens (the clipboard round-trip). Any non-paste transaction,
|
||||
* submit start, invalidate-paste, or release ends it; a paste-upgrade keeps
|
||||
* it current (later tokens re-CAS against the advanced draftRev).
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Draft decoration pure core (design §9.1: chips render from the occurrence
|
||||
* Draft decoration pure core (chips render from the occurrence
|
||||
* table at placeholder offsets; the claim token renders as a mirror-layer
|
||||
* highlight, the claim hint as ghost text). Zero React — the skeleton renders
|
||||
* the instructions; tests drive this directly.
|
||||
@@ -24,7 +24,9 @@ export interface ChipRender {
|
||||
}
|
||||
|
||||
/**
|
||||
* One plain-text reference range (decision 21): a `/name` or `@name` token
|
||||
* One plain-text reference range (the plain-text-reference decision;
|
||||
* see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
|
||||
* a `/name` or `@name` token
|
||||
* whose name is on the trigger's lexicon. Pure derivation — editing the text
|
||||
* out of match shape simply drops the range next scan.
|
||||
*/
|
||||
@@ -50,8 +52,8 @@ export interface DraftDecorations {
|
||||
const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g
|
||||
|
||||
/**
|
||||
* Scan the draft for plain-text reference tokens against the hot lexicons
|
||||
* (decision 21). Word-boundary discipline: the trigger must sit at the draft
|
||||
* Scan the draft for plain-text reference tokens against the hot lexicons.
|
||||
* Word-boundary discipline: the trigger must sit at the draft
|
||||
* start or after whitespace ('x/name' never matches); the name must be an
|
||||
* exact lexicon member.
|
||||
* @param draft - draft text.
|
||||
@@ -82,7 +84,7 @@ const EMPTY_LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map()
|
||||
/**
|
||||
* Derive the mirror-layer decorations from the input state.
|
||||
* @param state - published input state.
|
||||
* @param lexicon - optional per-trigger reference lexicons (decision 21 scan).
|
||||
* @param lexicon - optional per-trigger reference lexicons (plain-text-reference scan).
|
||||
* @returns token range, chip instructions, text-ref ranges, and the ghost hint.
|
||||
*/
|
||||
export function deriveDecorations(
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface PopupDismissFace {
|
||||
}
|
||||
|
||||
/**
|
||||
* Construction seams of one facade. The slash/popup faces are THUNKS: the
|
||||
* Construction dependencies of one facade. The slash/popup faces are THUNKS: the
|
||||
* shell is created inside the sessions provide materialization (before the
|
||||
* scope record is queryable), where `slash.sessionOf`/`command.popupFor`
|
||||
* cannot resolve yet — resolution defers to first interactive use.
|
||||
@@ -66,7 +66,7 @@ export class SessionInputShell implements SessionInput {
|
||||
readonly state: SnapshotStore<InputState>
|
||||
/** Latest surfaced notice (null after clear); the wiring renders it beside the error strip. */
|
||||
readonly notices: SnapshotStore<InputNotice | null> = createSnapshotStore<InputNotice | null>(null)
|
||||
/** The public provide-channel action face (one stable identity per session — decision 20). */
|
||||
/** The public provide-channel action face (one stable identity per session). */
|
||||
readonly actions: InputActions = {
|
||||
setDraft: (text) => { this.setDraft(text) },
|
||||
submit: () => { this.submit('queue') },
|
||||
@@ -198,7 +198,9 @@ export class SessionInputShell implements SessionInput {
|
||||
|
||||
/**
|
||||
* Hot plain-text reference lexicon source for the decoration scan
|
||||
* (decision 21): delegates to the controller's aggregated store. Stable
|
||||
* (the plain-text-reference decision;
|
||||
* see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
|
||||
* delegates to the controller's aggregated store. Stable
|
||||
* identity per shell; without a pipeline the snapshot is the empty Map and
|
||||
* subscribers never fire.
|
||||
*/
|
||||
@@ -253,7 +255,8 @@ export class SessionInputShell implements SessionInput {
|
||||
|
||||
/**
|
||||
* Insert plain reference text over the pick-time span (scoped insert-text
|
||||
* event listener body, decision 21). Same CAS-then-splice shape as the
|
||||
* event listener body; plain-text-reference decision, web-input-machine
|
||||
* note). Same CAS-then-splice shape as the
|
||||
* consume-token span branch: the machine sees an ordinary draft-changed
|
||||
* transaction (one undo step), no occurrence is minted — the chip look is
|
||||
* a scan-derived decoration, never state.
|
||||
@@ -339,7 +342,7 @@ export class SessionInputShell implements SessionInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt serialization before the sink (design §3.12): expand each
|
||||
* Prompt serialization before the sink: expand each
|
||||
* placeholder to its owner's model form via the session controller's
|
||||
* codec routing. Owner missing / serialize failure / disposal blocks the
|
||||
* send — notice + draft and chips retained, never a silent downgrade to
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* InputHub: the InputService implementation (`ctx.conversation.input`) — one
|
||||
* SessionInputShell per session, created inside the sessions provide
|
||||
* materialization (decision 19: the 'input' standard-kit entry IS the
|
||||
* materialization (the 'input' standard-kit entry IS the
|
||||
* creation trigger) and torn down by the scope disposer (instance-and-scope
|
||||
* share one lifecycle). The hub registers the three scoped input-mutation
|
||||
* listeners on each session's actx (the sole consumer side of the ui-slash
|
||||
@@ -61,7 +61,7 @@ export class InputHub implements InputService {
|
||||
})
|
||||
this.shells.set(id, shell)
|
||||
// The one teardown axis: listeners, shell, and map entries all ride the
|
||||
// scope fiber (decision 12 — nothing here outlives the scope).
|
||||
// scope fiber (nothing here outlives the scope).
|
||||
actx.effect(() => {
|
||||
const offs = [
|
||||
actx.on('slash/input-begin-command', req =>
|
||||
@@ -97,7 +97,7 @@ export class InputHub implements InputService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The InputBar-exclusive keyboard command face (decision 20): the shell
|
||||
* The InputBar-exclusive keyboard command face: the shell
|
||||
* satisfies it structurally; package-internal — handed through the
|
||||
* composer-bar entry's inject, never across a plugin boundary.
|
||||
* @param id - session id.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* InputMachine: the pure per-session input state machine (design §9.1, eng.
|
||||
* plan §3.9-3.12). Events in, effects out; zero React / DOM / cordis / ambient
|
||||
* InputMachine: the pure per-session input state machine.
|
||||
* Events in, effects out; zero React / DOM / cordis / ambient
|
||||
* clock. Package-private — the SessionInput shell is the only caller and the
|
||||
* sole executor of the returned effects.
|
||||
*
|
||||
@@ -23,10 +23,10 @@ import type {
|
||||
/** The object-replacement character backing every chip occurrence in the draft. */
|
||||
export const PLACEHOLDER = ''
|
||||
|
||||
/** The machine never writes the queue; the wiring layer overlays the T9 store projection. */
|
||||
/** The machine never writes the queue; the wiring layer overlays the queue store's projection. */
|
||||
const EMPTY_QUEUE: InputState['queue'] = []
|
||||
|
||||
/** Undo ring depth (design §9.1: bounded self-managed transaction log). */
|
||||
/** Undo ring depth (bounded self-managed transaction log). */
|
||||
const LOG_LIMIT = 100
|
||||
|
||||
/** Exhaustiveness backstop for the closed InputEvent / guard unions. */
|
||||
@@ -68,7 +68,7 @@ function diffEdit(prev: string, next: string): EditRange {
|
||||
|
||||
/**
|
||||
* Expand the draft's placeholders into their occurrences' clipboard text
|
||||
* (decision 16: the persistence mirror and clipboard both write this
|
||||
* (the persistence mirror and clipboard both write this
|
||||
* projection — U+FFFC never leaves the machine). Table order is offset
|
||||
* order, so one linear walk pairs placeholders with entries.
|
||||
* @param state - published input state.
|
||||
@@ -194,7 +194,7 @@ export class InputMachine {
|
||||
/**
|
||||
* Reconcile the occurrence table with one edit (old-draft coordinates):
|
||||
* entries past the range shift by the length delta; entries whose
|
||||
* placeholder sits inside the replaced range go away whole (design §9.1: a
|
||||
* placeholder sits inside the replaced range go away whole (a
|
||||
* deletion/replacement intersecting a placeholder acts on the whole chip).
|
||||
*/
|
||||
private reconcile(range: EditRange): void {
|
||||
@@ -338,7 +338,7 @@ export class InputMachine {
|
||||
/**
|
||||
* Owner-resolution style bits: exactly the listed occurrences render
|
||||
* invalid. Not a transaction — the draft, revision, and undo log are
|
||||
* untouched (design §9.1: invalidation never deletes or rewrites chips).
|
||||
* untouched (invalidation never deletes or rewrites chips).
|
||||
*/
|
||||
private onSetInvalid(invalidIds: readonly number[]): InputEffect[] {
|
||||
const ids = new Set(invalidIds)
|
||||
|
||||
@@ -17,6 +17,7 @@ export const zh = {
|
||||
'placeholder.plan': PLAN_NEXT_ACTION_ZH,
|
||||
'placeholder.default': '给智能体发消息',
|
||||
'placeholder.unavailable': '会话不可用',
|
||||
'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行',
|
||||
'placeholder.hero': '描述你想要构建的内容',
|
||||
'placeholder.workspace': '选择一个工作区开始',
|
||||
'input.commands': '命令',
|
||||
@@ -159,6 +160,7 @@ export const en = {
|
||||
'placeholder.plan': PLAN_NEXT_ACTION_EN,
|
||||
'placeholder.default': 'Message the agent',
|
||||
'placeholder.unavailable': 'Session unavailable',
|
||||
'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run',
|
||||
'placeholder.hero': 'Describe what you want to build',
|
||||
'placeholder.workspace': 'Choose a workspace to start',
|
||||
'input.commands': 'Commands',
|
||||
|
||||
@@ -214,7 +214,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
|
||||
|
||||
/**
|
||||
* The dock entry as a plain registrant plugin. The conversation service is
|
||||
* the action seam; the slot declaration is its independent lifecycle seam.
|
||||
* the action contract; the slot declaration has an independent lifecycle boundary.
|
||||
*/
|
||||
export const queueDockEntry = {
|
||||
name: 'conversation-queue-dock',
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { QueuedMessage } from '../input/contract.ts'
|
||||
|
||||
/**
|
||||
* Project a session's transient inbox rows as a bare observable (subscribe/getSnapshot).
|
||||
* The wiring layer (T5) overlays this onto InputState.queue; the runtime
|
||||
* The wiring layer overlays this onto InputState.queue; the runtime
|
||||
* QueuedMessage and the input-contract QueuedMessage are structurally
|
||||
* identical.
|
||||
* @param session - the resident session face.
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface IConversation {
|
||||
|
||||
/** Scope-addressed conversation service (root singleton, provided as `conversation`). */
|
||||
export class ConversationService extends Service implements IConversation {
|
||||
/** The per-session input machine registry (InputService face, design §5.2). */
|
||||
/** The per-session input machine registry (InputService face). */
|
||||
readonly input: InputService
|
||||
/** The per-session composer-block registry. */
|
||||
readonly blocks: ComposerBlocks
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
// buttons must be reachable no matter how long the command is.
|
||||
// One-shot: the buttons disable
|
||||
// after a click and the panel leaves (the InputBar returns) on the broadcast
|
||||
// resolved frame. The draft's "Always allow this type" is deferred with
|
||||
// grant storage.
|
||||
// resolved frame.
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { RunningToolCall } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingApproval, type ApprovalComposerProps } from '../contract/slots.ts'
|
||||
import { rootToolCall } from '../chat/tool-node-reader.ts'
|
||||
import css from './ApprovalPanel.module.css'
|
||||
|
||||
/** Extract the shell command from an approval's paired running call (bash-family args carry `command`); undefined hides the line. */
|
||||
@@ -40,8 +40,12 @@ export function commandOf(call: RunningToolCall | undefined): string | undefined
|
||||
*/
|
||||
export function ApprovalPanel(props: ApprovalComposerProps) {
|
||||
const approval = useMemo(() => new PendingApproval(props.matched), [props.matched])
|
||||
const command = props.useSession(s => commandOf(
|
||||
approval.callId === undefined ? undefined : s.runningCalls.find(call => call.callId === approval.callId)))
|
||||
const command = props.useSession((snapshot) => {
|
||||
if (approval.callId === undefined) return undefined
|
||||
const root = rootToolCall(snapshot, approval.callId)
|
||||
if (root === undefined) return undefined
|
||||
return root.callId === approval.callId && !('kind' in root) ? commandOf(root) : undefined
|
||||
})
|
||||
return <ApprovalFlow key={approval.key} pending={approval} t={props.t} {...command === undefined ? {} : { command }} />
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** Composer context-occupancy meter: a ring beside the send button fed by the
|
||||
* `contextPressure` projection, with a click-open panel of the heuristic
|
||||
* `contextBreakdown` composition (system prompt, tools, conversation).
|
||||
* Renders nothing until a provider reports both pressure and a route capacity
|
||||
* (same gate as the stats row used). */
|
||||
* Renders nothing until a provider reports both pressure and a route
|
||||
* capacity. */
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { UseProjection } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
@@ -81,7 +81,6 @@ export function ConversationRoot({
|
||||
const zone: InputZone | undefined =
|
||||
session === undefined || inputState === undefined ? undefined : { session, input: inputState }
|
||||
|
||||
// Flow optimization — worth a close PR review for code/boundary issues.
|
||||
// The chip is a selector; label resolution walks the flow top-down:
|
||||
// 1. a just-picked workspace (pending) → its title;
|
||||
// 2. cold start, no session yet → placeholder ("Choose workspace");
|
||||
@@ -120,6 +119,7 @@ export function ConversationRoot({
|
||||
},
|
||||
onClose: () => { setPickerOpen(false) },
|
||||
})}
|
||||
{renderSlot('conversation.hero.agentPreset', {})}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Details third column, minimal P-I fill: header (name + close) over a
|
||||
/* Details third column, minimal fill: header (name + close) over a
|
||||
scrolling body of Input/Output code sections. Panel width/squeeze belongs
|
||||
to layout; this fills whatever the column gives. */
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// DetailsPanel, P-I minimal form: close button + the selected call's args and
|
||||
// DetailsPanel: close button + the selected call's args and
|
||||
// result — args as JSON, the result raw except for a terminal-card call, whose
|
||||
// Output section is the command's terminal card. The three-段 Switch /
|
||||
// Prev-Next stepping / See-in-trajectory are deferred (ledger). Reads the
|
||||
// Output section is the command's terminal card. Reads the
|
||||
// selection from the shared chat
|
||||
// store (conversation writes, this panel reads — the cross-registration
|
||||
// share the store seat exists for) and derives the call material from the
|
||||
@@ -12,6 +11,7 @@ import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { DetailsSlotProps } from '../contract/slots.ts'
|
||||
import { findToolCall } from '../chat/tool-node-reader.ts'
|
||||
import css from './DetailsPanel.module.css'
|
||||
|
||||
/** Full props composed by reference from the contract (automatic shares & injected share). */
|
||||
@@ -40,30 +40,10 @@ function runningMaterial(call: RunningToolCall): CallMaterial {
|
||||
return { name: call.name, argsRaw: call.argsRaw, block: call }
|
||||
}
|
||||
|
||||
function findCall(block: ToolCallBlock, callId: string): ToolCallBlock | undefined {
|
||||
if (block.callId === callId) return block
|
||||
for (const child of block.subCalls) {
|
||||
const found = findCall(child, callId)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
|
||||
for (const node of s.nodes) {
|
||||
if (node.kind !== 'tool-result') continue
|
||||
const found = findCall(node, callId)
|
||||
if (found !== undefined) {
|
||||
return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
|
||||
}
|
||||
}
|
||||
for (const root of s.runningCalls) {
|
||||
const found = findCall(root, callId)
|
||||
if (found !== undefined) {
|
||||
return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
|
||||
}
|
||||
}
|
||||
return null
|
||||
const found = findToolCall(s, callId)
|
||||
if (found === undefined) return null
|
||||
return 'kind' in found ? settledMaterial(found, callId) : runningMaterial(found)
|
||||
}
|
||||
|
||||
function pretty(raw: string): string {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
|
||||
viewport bottom inside the centered message column; textarea on top, action
|
||||
row below, one primary circle button bottom-right. Input width rides the
|
||||
row below, primary action controls bottom-right. Input width rides the
|
||||
column (--dsh-composer-card-max-width = chat content + 32px, 16px per side,
|
||||
is a cap, not a fixed size — layout rule: the box shrinks with the center
|
||||
column keeping its clearance). Hero variant = the same card centered in the
|
||||
@@ -225,9 +225,9 @@
|
||||
so by construction now that all three sit INSIDE .scroll — a scrollbar
|
||||
that consumes layout space narrows the scrollport, which is their shared
|
||||
containing block, so it costs all three the same width on every engine.
|
||||
Scrolling the textarea itself is what used to break this, and no property
|
||||
fixed it: WebKit reserved gutter space for the overflow-y:auto textarea
|
||||
and not for the overflow:hidden layers beside it, leaving them 8px apart
|
||||
A textarea that scrolls itself would break this, and no property fixes
|
||||
it: WebKit reserves gutter space for an overflow-y:auto textarea and not
|
||||
for the overflow:hidden layers beside it, leaving them 8px apart
|
||||
(768 against 776) — worth 2 to 5 wrapped lines on a long draft. */
|
||||
}
|
||||
|
||||
@@ -395,7 +395,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Plain-text reference highlight (decision 21): a pure range mark over the
|
||||
/* Plain-text reference highlight: a pure range mark over the
|
||||
draft's own glyphs — advance untouched, so the two layers cannot drift.
|
||||
Chip family colors; clone keeps rounded ends on soft-wrap fragments. */
|
||||
.textRef {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** The default composer body: the 'conversation.composer.bar' slot entry
|
||||
* (decision 20). Machine state arrives through the standard provide channel
|
||||
/** The default composer body: the 'conversation.composer.bar' slot entry.
|
||||
* Machine state arrives through the standard provide channel
|
||||
* (useInput + inputActions); the keyboard/DOM command face and stop arrive
|
||||
* through this entry's own inject, whose hooks compartment binds
|
||||
* useNotices/useLexicon; layout-phase inputs (variant, placeholder,
|
||||
@@ -83,11 +83,15 @@ export function InputBar({
|
||||
// (undefined = capability absent → the chip renders nothing).
|
||||
const permissions = useProjection('permissions')
|
||||
|
||||
// Queue cut 1: running input stays free; locked = session removed, the
|
||||
// inert no-workspace state, or the machine faces absent (no session). The
|
||||
// transient machine locks (adjudicating pending / submitting) render
|
||||
// read-only — the draft stays visible and focused, keystrokes drop.
|
||||
const disabled = removed || inert || !live || blocked !== undefined
|
||||
// A continuable child without its live parent cannot accept human input,
|
||||
// but its independent Stop below stays available while it runs.
|
||||
const continuable = subagent?.address.mode === 'continuable'
|
||||
const parentOffline = continuable && !subagent.parentAvailable
|
||||
// Running input stays free; locked = session removed, the
|
||||
// inert no-workspace state, the machine faces absent (no session), or a
|
||||
// parent-offline continuable child. An owner block also disables input;
|
||||
// adjudicating and submitting render read-only so the draft stays visible.
|
||||
const disabled = removed || inert || !live || blocked !== undefined || parentOffline
|
||||
const locked = disabled
|
||||
// The model seat is the ONE control a block leaves live: every block this
|
||||
// contract has is cleared by choosing a model, so locking it too would leave
|
||||
@@ -350,11 +354,14 @@ export function InputBar({
|
||||
if (el !== null) toggleCommandMenu?.(selectionOf(el))
|
||||
}
|
||||
|
||||
const ordinary = subagent === null
|
||||
const stopping = running && ordinary
|
||||
const primaryLabel = stopping ? t('input.stop') : t('input.send')
|
||||
// Ordinary sessions retain their primary Send/Stop toggle. A continuable
|
||||
// child keeps Send as the primary action and exposes Stop independently so
|
||||
// pointer users can queue follow-ups while its current turn is running.
|
||||
const primaryStops = running && subagent === null
|
||||
const interruptible = running && continuable
|
||||
const primaryLabel = primaryStops ? t('input.stop') : t('input.send')
|
||||
const onPrimary = (): void => {
|
||||
if (stopping) {
|
||||
if (primaryStops) {
|
||||
stop?.()
|
||||
return
|
||||
}
|
||||
@@ -378,7 +385,7 @@ export function InputBar({
|
||||
const backdrop: ReactNode[] = []
|
||||
{
|
||||
// Segment boundaries: the token range end, every chip offset, and every
|
||||
// text-ref range (decision 21) — merged in draft order (the sources never
|
||||
// text-ref range — merged in draft order (the sources never
|
||||
// overlap: chips sit on placeholders, text-refs on plain tokens, the
|
||||
// claim token only leads).
|
||||
let cursor = 0
|
||||
@@ -423,7 +430,7 @@ export function InputBar({
|
||||
)
|
||||
cursor = chip.offset + 1 // the placeholder char the chip stands for
|
||||
} else {
|
||||
// Plain-range highlight (decision 21): the glyphs stay the
|
||||
// Plain-range highlight: the glyphs stay the
|
||||
// textarea's (advance untouched); the mark paints the chip look.
|
||||
backdrop.push(
|
||||
<mark key={`ref-${b.ref.start}`} className={css.textRef} data-decoration="text-ref">
|
||||
@@ -478,9 +485,11 @@ export function InputBar({
|
||||
disabled={locked}
|
||||
readOnly={machineBusy}
|
||||
data-phase={input?.phase ?? 'inert'}
|
||||
placeholder={placeholder ?? (disabled
|
||||
? t('placeholder.unavailable')
|
||||
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
|
||||
placeholder={placeholder ?? (parentOffline
|
||||
? t('placeholder.parentOffline')
|
||||
: disabled
|
||||
? t('placeholder.unavailable')
|
||||
: planActive ? t('placeholder.plan') : t('placeholder.default'))}
|
||||
rows={2}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -520,17 +529,32 @@ export function InputBar({
|
||||
{rightItems}
|
||||
{renderSlot('conversation.input.model', { locked: modelSeatLocked })}
|
||||
<ContextMeter useProjection={useProjection} t={t} />
|
||||
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
|
||||
{interruptible && (
|
||||
<Tooltip label={t('input.stop')} side="top" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.primary}
|
||||
aria-label={t('input.stop')}
|
||||
disabled={stop === undefined}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={stop}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
|
||||
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={primaryLabel} side="top" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.primary}
|
||||
aria-label={primaryLabel}
|
||||
disabled={stopping ? stop === undefined : empty || disabled || machineBusy}
|
||||
disabled={primaryStops ? stop === undefined : empty || disabled || machineBusy}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onPrimary}
|
||||
>
|
||||
{stopping ? (
|
||||
{primaryStops ? (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
|
||||
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
|
||||
</svg>
|
||||
|
||||
@@ -17,7 +17,7 @@ export const inject = ['invariants']
|
||||
/**
|
||||
* 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.tool' whole-call seat) ride the slot system, whose ledger
|
||||
* 'conversation.chat.node' business renderer seat) ride the slot system, whose ledger
|
||||
* invariants live with the runtime slots package.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
Reference in New Issue
Block a user