Merge branch 'stack/agent-profiles-5-web-ui' into stack/agent-profiles-8-authoring
# Conflicts: # docs/module-graph.i18n.yaml # docs/module-graph.md # docs/module-graph.zh.md # docs/subsystems/tools.i18n.yaml # docs/subsystems/tools.md # docs/subsystems/tools.zh.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 911bca28dcfb31b1d8ef9ea5458a0d2017d8b31d
|
||||
README.zh.md: 25574421bbcc3992188e378a49a69acf25744af7
|
||||
README.md: d894d3e578f065e9cb2c45676224efd1f4f76fa8
|
||||
README.zh.md: ec99a8d7daa593cdf3014f292a095fb2fd6f39d3
|
||||
|
||||
@@ -12,6 +12,8 @@ Another plugin can make one session's composer inert through `ctx.conversation.b
|
||||
|
||||
The view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome.
|
||||
|
||||
Chat business rows are independent registry contributions rather than a closed built-in union. A client plugin declaration-merges its typed `ChatNodeDataMap` key, registers a `ConversationNodeDefinition` on `ctx.conversationEvents`, and registers the matching keyed renderer on `conversation.chat.node`; it does not modify Session folds or a central renderer switch. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) covers stable event ids, append/prepend replay, Location data, and renderer constraints.
|
||||
|
||||
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
|
||||
|
||||
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
|
||||
@@ -20,7 +22,7 @@ Logged non-user messages render as a default-collapsed disclosure whose header n
|
||||
|
||||
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).
|
||||
|
||||
The chat view keeps Tool placement but delegates Tool presentation. It passes each ordered root call through `conversation.chat.tool`, and the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle fills the whole-Tool seat with [`ui-tool`](../ui-tool/README.md), which selects Runtime-projected Code Dispatch children and owns root/child composition, per-name dispatch, generic rendering, and render-intent cards; the details seat alone retains a raw-result fallback when that renderer is absent.
|
||||
The chat view keeps Tool placement but delegates Tool presentation. Each ordered `tool-call` Conversation Node dispatches through the matching key of `conversation.chat.node`, while the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle registers [`ui-tool`](../ui-tool/README.md) for that Chat Node key; it renders the Runtime-projected recursive root/child tree and owns per-name dispatch, generic rendering, and render-intent cards. The details seat alone retains a raw-result fallback when that renderer is absent.
|
||||
|
||||
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments.
|
||||
|
||||
@@ -40,7 +42,7 @@ The chat stats line takes its token accounting from the generic token-meter `tok
|
||||
|
||||
`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` export surface contains only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations.
|
||||
|
||||
A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` contract — an absent service leaves the prose inert.
|
||||
A finished turn materializes one ordered `turn-tail` Conversation Node. Its engine-owned `TurnLocation` supplies the closing Assistant and Turn data; the renderer places the `conversation.chat.turnTail` chain before that node's IconActions and dispatches `TurnTailOwnerProps` containing the Turn, closing seq, and `openFile`. This package owns only the hole; `@deepseek-ai/dsh-client-ui-deliverables` accumulates mutation-tool `locations` into Turn data and owns the produced-files row, chip cap, and copy, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -12,13 +12,15 @@
|
||||
|
||||
视图环是一个 slot:严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。
|
||||
|
||||
Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。Client 插件通过 declaration merging 增加类型化 `ChatNodeDataMap` key,在 `ctx.conversationEvents` 上注册 `ConversationNodeDefinition`,再向 `conversation.chat.node` 注册匹配的 keyed renderer;它无须修改 Session fold 或中央 renderer switch。稳定事件 id、append/prepend 回放、Location data 与 renderer 约束见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.md)。
|
||||
|
||||
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
|
||||
|
||||
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。
|
||||
|
||||
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
|
||||
|
||||
聊天视图保留 Tool 的消息流位置,但委托其展示。它通过 `conversation.chat.tool` 传递每个已排序的 root call;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 由 [`ui-tool`](../ui-tool/README.md) 填充整体 Tool 席位,并由后者选择 Runtime 已投影的 Code Dispatch 子调用,负责 root/child 编排、按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。
|
||||
聊天视图保留 Tool 的消息流位置,但委托其展示。每个已排序的 `tool-call` Conversation Node 都通过 `conversation.chat.node` 的同名 key 分发;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 为该 Chat Node key 注册 [`ui-tool`](../ui-tool/README.md),由后者渲染 Runtime 已投影的递归 root/child 树,并负责按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。
|
||||
|
||||
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。
|
||||
|
||||
@@ -40,7 +42,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
|
||||
|
||||
`src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/`、`chat/`、`input/`、`queue/` 和 `settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。
|
||||
|
||||
完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` 约定——service 缺席时正文保持死文本。
|
||||
完成的一轮会物化一个有序的 `turn-tail` Conversation Node。它由引擎维护的 `TurnLocation` 提供收尾 Assistant 和 Turn data;renderer 在该 Node 的 IconActions 之前渲染 `conversation.chat.turnTail` chain,并派发包含 Turn、收尾 seq 和 `openFile` 的 `TurnTailOwnerProps`。本包只拥有空位;`@deepseek-ai/dsh-client-ui-deliverables` 把改写工具的 `locations` 累积到 Turn data,并拥有产物行、chip 上限和文案,因此把该插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -39,20 +39,28 @@
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-compact": "^0.0.1",
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-token-meter": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-goal": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
@@ -60,9 +68,11 @@
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
|
||||
@@ -7,8 +7,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'
|
||||
@@ -30,6 +31,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'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
@@ -39,7 +42,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
|
||||
export const inject = [
|
||||
'slots', 'layout', 'sessions', 'workspaces', 'locale',
|
||||
'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
|
||||
@@ -63,6 +69,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)
|
||||
@@ -86,6 +105,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
|
||||
@@ -297,8 +319,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',
|
||||
@@ -306,9 +328,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 => {
|
||||
|
||||
@@ -11,12 +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 { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, ChatViewInjected, TurnTailOwnerProps } from '../contract/slots.ts'
|
||||
import { hasContentText } from './chat-flow.ts'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { ReasoningRow } from './ReasoningRow.tsx'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
|
||||
@@ -25,61 +22,19 @@ 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
|
||||
/** Prose file-mention factory (the injected face); omitted wherever `turnTail` is. */
|
||||
fileMentions?: ChatViewInjected['fileMentions'] | 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,
|
||||
fileMentions, 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.
|
||||
const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t])
|
||||
// Mention vocabulary for the closing prose. Keyed on the anchor seq, not the
|
||||
// growing transcript: a settled turn's produced files are final, and a
|
||||
// fresh identity per append would discard MarkdownText's cached parse for
|
||||
// every settled closing message on every stream chunk. The window-prepend
|
||||
// edge (a mid-turn window start later gaining earlier same-turn writes)
|
||||
// leaves a mention unlinked until remount — never a wrong link.
|
||||
const owner = turnTail?.owner
|
||||
const mentions: MarkdownFileMentions | undefined = useMemo(
|
||||
() => (owner === undefined ? undefined : fileMentions?.(owner)),
|
||||
// Deliberately not `owner`: its identity changes per append while the
|
||||
// seq-addressed vocabulary it yields does not.
|
||||
[fileMentions, owner?.seq],
|
||||
)
|
||||
const last = blocks.length - 1
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass, so
|
||||
// a node that is only those heads (or empty) would paint an empty root
|
||||
@@ -88,10 +43,8 @@ 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) {
|
||||
@@ -119,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,52 +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,
|
||||
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)
|
||||
@@ -312,11 +184,12 @@ export function ChatView({
|
||||
* 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
|
||||
@@ -377,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
|
||||
@@ -490,71 +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}
|
||||
fileMentions={actionSeqs.has(node.seq) ? fileMentions : 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}>
|
||||
@@ -572,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>
|
||||
)
|
||||
})
|
||||
@@ -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[] = []
|
||||
@@ -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,15 +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' {
|
||||
@@ -31,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
|
||||
@@ -48,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. */
|
||||
@@ -199,7 +206,7 @@ export interface ConvViewOwnerProps {
|
||||
export interface ChatFileMentions {
|
||||
/**
|
||||
* Mention vocabulary for the closing message the owner currency names.
|
||||
* @param owner - Turn-tail owner currency (nodes, closing seq, opener).
|
||||
* @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.
|
||||
*/
|
||||
@@ -214,14 +221,13 @@ declare module 'cordis' {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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
|
||||
/**
|
||||
@@ -231,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. */
|
||||
@@ -584,7 +590,7 @@ export interface ChatViewInjected {
|
||||
|
||||
/** 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,12 +22,16 @@ export type {
|
||||
} from './contract/views.ts'
|
||||
export type { ConversationKey } from './locales.ts'
|
||||
export type {
|
||||
ChatFileMentions,
|
||||
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.
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 +41,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 }} />
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,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 +41,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 {
|
||||
|
||||
@@ -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 = () => {}
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('apply wiring', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('registers the chat view as the first ring entry, declaring the whole-Tool seat', async () => {
|
||||
it('registers the chat view and its keyed business-node seat', async () => {
|
||||
const b = await bench()
|
||||
const entries = b.slots.entries('conversation.view')
|
||||
expect(entries.map(e => e.options.id)).toEqual(['chat'])
|
||||
@@ -62,7 +62,9 @@ describe('apply wiring', () => {
|
||||
expect(entries[0]?.options.order).toBe(0)
|
||||
// Declaring is claiming: the chat entry's registration put the hole on
|
||||
// the ledger with the contract's kind/scope.
|
||||
expect(b.slots.spec('conversation.chat.tool')).toEqual({ kind: 'single', scope: 'session' })
|
||||
const nodeSlot = b.slots.spec('conversation.chat.node')
|
||||
expect(nodeSlot).toMatchObject({ kind: 'keyed', scope: 'session' })
|
||||
expect(nodeSlot?.inject?.hooks?.turnData).toBeTypeOf('function')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -97,7 +99,7 @@ describe('apply wiring', () => {
|
||||
// file-mutation registrant claims both write and edit for the diff card; the
|
||||
// one search row registers under both grep and glob; the web rows register
|
||||
// one component under both web tool names.
|
||||
expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.chat.node').map(entry => entry.options.key)).not.toContain('tool-call')
|
||||
// Stats stick with the composer (not inside ChatView).
|
||||
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
|
||||
await b.runtime.dispose()
|
||||
@@ -110,8 +112,8 @@ describe('apply wiring', () => {
|
||||
// The declared ring collapses with its declaring entry, and the chat
|
||||
// entry's keyed hole (with the sample's registration) collapses with it.
|
||||
expect(b.slots.entries('conversation.view')).toHaveLength(0)
|
||||
expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0)
|
||||
expect(b.slots.spec('conversation.chat.tool')).toBeUndefined()
|
||||
expect(b.slots.entries('conversation.chat.node')).toHaveLength(0)
|
||||
expect(b.slots.spec('conversation.chat.node')).toBeUndefined()
|
||||
expect(b.slots.entries('details')).toHaveLength(0)
|
||||
expect(b.slots.entries('settings.general.item')).toHaveLength(0)
|
||||
expect(b.runtime.ctx.get('conversation')).toBeUndefined()
|
||||
|
||||
@@ -10,13 +10,21 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type {
|
||||
ChatConversationViewNode, ConversationNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatNodeViewProps } from '../src/client/contract/slots.ts'
|
||||
import {
|
||||
formatMessageClock, msUntilNextLocalMidnight, startOfLocalDay,
|
||||
} from '../src/client/chat/message-chrome.ts'
|
||||
import { MessageItem, type MessageItemProps } from '../src/client/chat/MessageItem.tsx'
|
||||
import {
|
||||
CompactionNodeView, ContextMessageNodeView, RetryNodeView, UnknownNodeView,
|
||||
UserMessageNodeView,
|
||||
} from '../src/client/chat/MessageItem.tsx'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.ts'
|
||||
|
||||
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
|
||||
class ResizeObserverStub {
|
||||
@@ -33,7 +41,44 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
|
||||
const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh)
|
||||
const RETRY_ID = 'retry-fixture' as Extract<ConversationNode, { kind: 'model-retry' }>['retryId']
|
||||
|
||||
interface MessageItemProps {
|
||||
readonly node: ConversationNode
|
||||
readonly t: ChatNodeViewProps['t']
|
||||
}
|
||||
|
||||
/** Legacy-node fixture adapter for the independently registered renderers. */
|
||||
function MessageItem({ node, t: translate }: MessageItemProps) {
|
||||
const kind = node.kind === 'assistant' ? 'assistant-step' : node.kind
|
||||
const viewNode: ChatConversationViewNode = {
|
||||
key: `fixture:${node.kind}:${node.seq}`,
|
||||
kind,
|
||||
id: String(node.seq),
|
||||
target: 'chat',
|
||||
anchorSeq: node.seq,
|
||||
location: { kind: 'session' },
|
||||
visibility: 'visible',
|
||||
data: node.kind === 'model-retry' ? { attempts: [node], current: node } : node,
|
||||
}
|
||||
const props = { node: viewNode, t: translate } as ChatNodeViewProps
|
||||
switch (node.kind) {
|
||||
case 'user':
|
||||
case 'steering':
|
||||
return <UserMessageNodeView {...props as ChatNodeViewProps<'user' | 'steering'>} />
|
||||
case 'context':
|
||||
return <ContextMessageNodeView {...props as ChatNodeViewProps<'context'>} />
|
||||
case 'compaction':
|
||||
return <CompactionNodeView {...props as ChatNodeViewProps<'compaction'>} />
|
||||
case 'model-retry':
|
||||
return <RetryNodeView {...props as ChatNodeViewProps<'model-retry'>} />
|
||||
case 'unknown':
|
||||
return <UnknownNodeView {...props as ChatNodeViewProps<'unknown'>} />
|
||||
default:
|
||||
throw new Error(`unsupported MessageItem fixture kind: ${node.kind}`)
|
||||
}
|
||||
}
|
||||
|
||||
describe('MessageItem arms', () => {
|
||||
it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => {
|
||||
@@ -727,9 +772,9 @@ describe('MessageItem arms', () => {
|
||||
const view = render(
|
||||
<MessageItem
|
||||
t={t}
|
||||
retryActive
|
||||
node={{
|
||||
kind: 'model-retry',
|
||||
retryId: RETRY_ID,
|
||||
seq: 5,
|
||||
time: 10_000,
|
||||
retryState: 'scheduled',
|
||||
@@ -761,9 +806,9 @@ describe('MessageItem arms', () => {
|
||||
view.rerender(
|
||||
<MessageItem
|
||||
t={t}
|
||||
retryActive
|
||||
node={{
|
||||
kind: 'model-retry',
|
||||
retryId: RETRY_ID,
|
||||
seq: 6,
|
||||
time: 12_100,
|
||||
retryState: 'scheduled',
|
||||
@@ -788,6 +833,7 @@ describe('MessageItem arms', () => {
|
||||
view.rerender(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'model-retry',
|
||||
retryId: RETRY_ID,
|
||||
seq: 6,
|
||||
time: 12_100,
|
||||
retryState: 'started',
|
||||
@@ -809,6 +855,7 @@ describe('MessageItem arms', () => {
|
||||
view.rerender(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'model-retry',
|
||||
retryId: RETRY_ID,
|
||||
seq: 7,
|
||||
time: 12_100,
|
||||
retryState: 'started',
|
||||
@@ -828,6 +875,7 @@ describe('MessageItem arms', () => {
|
||||
view.rerender(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'model-retry',
|
||||
retryId: RETRY_ID,
|
||||
seq: 8,
|
||||
time: 12_100,
|
||||
retryState: 'cancelled',
|
||||
@@ -846,32 +894,6 @@ describe('MessageItem arms', () => {
|
||||
expect(view.getByRole('status').textContent).toBe('模型请求重试已取消(1/2) · 4s')
|
||||
})
|
||||
|
||||
it('synchronizes the countdown when an inactive retry becomes active at the one-second floor', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(10_000)
|
||||
const node = {
|
||||
kind: 'model-retry',
|
||||
seq: 5,
|
||||
time: 10_000,
|
||||
retryState: 'scheduled',
|
||||
turn: 1,
|
||||
step: 0,
|
||||
provider: 'mock',
|
||||
mode: 'normal',
|
||||
policyKey: 'mock-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 5_000,
|
||||
failure: { code: 'TRANSPORT', message: '连接被重置' },
|
||||
} as const
|
||||
const view = render(<MessageItem t={t} node={node} />)
|
||||
expect(view.getByRole('status').textContent).toBe('等待重试模型请求(1/2) · 5s')
|
||||
|
||||
act(() => { vi.advanceTimersByTime(4_200) })
|
||||
view.rerender(<MessageItem t={t} node={node} retryActive />)
|
||||
expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('formatMessageClock', () => {
|
||||
@@ -932,84 +954,13 @@ describe('small branch tails', () => {
|
||||
expect(view.getByText('one-liner')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('finalized content messages expose copy / branch / clock; Think-only and streaming omit them', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const now = new Date()
|
||||
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
|
||||
const onFork = vi.fn()
|
||||
const settled = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'text', text: 'answer body' }, { kind: 'reasoning', text: 'hidden' }]}
|
||||
streaming={false}
|
||||
time={time}
|
||||
seq={3}
|
||||
onFork={onFork}
|
||||
/>,
|
||||
)
|
||||
expect(settled.getByText('14:24')).toBeTruthy()
|
||||
expect(settled.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(settled.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
||||
fireEvent.click(settled.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('answer body')
|
||||
fireEvent.click(settled.getByRole('button', { name: '在新对话中分支' }))
|
||||
expect(onFork).toHaveBeenCalledWith(3)
|
||||
settled.unmount()
|
||||
|
||||
const thinkOnly = render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'reasoning', text: 'only thinking' }]}
|
||||
streaming={false}
|
||||
time={time}
|
||||
/>,
|
||||
)
|
||||
expect(thinkOnly.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
expect(thinkOnly.queryByText('14:24')).toBeNull()
|
||||
thinkOnly.unmount()
|
||||
|
||||
const streaming = render(
|
||||
<AssistantMarkdown t={t} blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
|
||||
)
|
||||
expect(streaming.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
expect(streaming.queryByText('14:24')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an unavailable branch focusable and explains why without sending a fork', () => {
|
||||
const onFork = vi.fn()
|
||||
render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'text', text: 'answer before a trailing tool row' }]}
|
||||
streaming={false}
|
||||
time={1_000}
|
||||
seq={1}
|
||||
onFork={onFork}
|
||||
forkUnavailable
|
||||
/>,
|
||||
)
|
||||
const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement
|
||||
expect(branch.disabled).toBe(false)
|
||||
expect(branch.getAttribute('aria-disabled')).toBe('true')
|
||||
const reasonId = branch.getAttribute('aria-describedby')
|
||||
expect(reasonId).not.toBeNull()
|
||||
expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
fireEvent.click(branch)
|
||||
expect(onFork).not.toHaveBeenCalled()
|
||||
fireEvent.focus(branch)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
})
|
||||
|
||||
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
|
||||
// Cache hit is null only when all three prompt buckets are zero (pure
|
||||
// output accounting) — any billed input makes it a real 0%.
|
||||
const snap = {
|
||||
nodes: [{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 } }],
|
||||
}
|
||||
const nodes = [{
|
||||
kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [], usage: { outputTokens: 10 },
|
||||
}] as const
|
||||
const snap = { chat: chatSnapshotFixture({ nodes }), nodes }
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const view = render(
|
||||
<StatsLine
|
||||
|
||||
298
packages/client/ui-conversation/tests/chat-snapshot-fixture.ts
Normal file
298
packages/client/ui-conversation/tests/chat-snapshot-fixture.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import type {
|
||||
AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode,
|
||||
ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, ConversationLocationDataStore,
|
||||
ConversationTurnDataMap, LegacyConversationSlice, PartialAssistant, RunningToolCall,
|
||||
ToolCallBlock, TurnLocation,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deriveTurnMetrics } from '../src/client/chat/turn-metrics.ts'
|
||||
|
||||
const EMPTY: readonly never[] = []
|
||||
|
||||
function sameValues<T>(left: readonly T[], right: readonly T[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function nodeSource(node: ChatConversationViewNode): unknown {
|
||||
if (node.kind === 'assistant-step') {
|
||||
const data = node.data as ReturnType<typeof assistantData>
|
||||
return data.finalNode ?? data.blocks
|
||||
}
|
||||
if (node.kind === 'tool-call') return (node.data as { readonly root: ToolCallBlock }).root
|
||||
if (node.kind === 'model-retry') return (node.data as { readonly current: unknown }).current
|
||||
if (node.kind === 'turn-tail') return (node.data as { readonly seq: number }).seq
|
||||
return node.data
|
||||
}
|
||||
|
||||
class FixtureNodeStore implements ChatNodeStore {
|
||||
private byKey = new Map<string, ChatConversationViewNode>()
|
||||
private list: readonly ChatConversationViewNode[] = EMPTY
|
||||
|
||||
get(key: string): ChatConversationViewNode | undefined {
|
||||
return this.byKey.get(key)
|
||||
}
|
||||
|
||||
values(): readonly ChatConversationViewNode[] {
|
||||
return this.list
|
||||
}
|
||||
|
||||
replace(candidates: readonly ChatConversationViewNode[]): void {
|
||||
const next = new Map<string, ChatConversationViewNode>()
|
||||
const list = candidates.map((candidate) => {
|
||||
const previous = this.byKey.get(candidate.key)
|
||||
const node = previous !== undefined
|
||||
&& previous.kind === candidate.kind
|
||||
&& previous.anchorSeq === candidate.anchorSeq
|
||||
&& previous.visibility === candidate.visibility
|
||||
&& nodeSource(previous) === nodeSource(candidate)
|
||||
? previous
|
||||
: candidate
|
||||
next.set(node.key, node)
|
||||
return node
|
||||
})
|
||||
this.byKey = next
|
||||
this.list = sameValues(this.list, list) ? this.list : list
|
||||
}
|
||||
}
|
||||
|
||||
class FixtureLocationIndex implements ChatLocationNodeIndex {
|
||||
private turns = new Map<number, readonly string[]>()
|
||||
|
||||
getTurn(turn: number): readonly string[] {
|
||||
return this.turns.get(turn) ?? EMPTY
|
||||
}
|
||||
|
||||
getStep(): readonly string[] {
|
||||
return EMPTY
|
||||
}
|
||||
|
||||
replace(next: ReadonlyMap<number, readonly string[]>): void {
|
||||
const stable = new Map<number, readonly string[]>()
|
||||
for (const [turn, keys] of next) {
|
||||
const previous = this.turns.get(turn) ?? EMPTY
|
||||
stable.set(turn, sameValues(previous, keys) ? previous : keys)
|
||||
}
|
||||
this.turns = stable
|
||||
}
|
||||
}
|
||||
|
||||
class FixtureTurnDataStore implements ConversationLocationDataStore<ConversationTurnDataMap> {
|
||||
private readonly values = new Map<string, unknown>()
|
||||
|
||||
get<Key extends Extract<keyof ConversationTurnDataMap, string>>(
|
||||
key: Key,
|
||||
): Readonly<ConversationTurnDataMap[Key]> | undefined {
|
||||
return this.values.get(key) as Readonly<ConversationTurnDataMap[Key]> | undefined
|
||||
}
|
||||
|
||||
set<Key extends Extract<keyof ConversationTurnDataMap, string>>(
|
||||
key: Key,
|
||||
value: ConversationTurnDataMap[Key],
|
||||
): void {
|
||||
this.values.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
function assistantData(node: AssistantMessageNode) {
|
||||
return {
|
||||
status: node.interrupted === true ? 'interrupted' as const : 'settled' as const,
|
||||
turn: node.turn,
|
||||
step: node.step,
|
||||
blocks: node.blocks,
|
||||
time: node.time,
|
||||
finalNode: node,
|
||||
}
|
||||
}
|
||||
|
||||
function settledNode(
|
||||
node: ConversationNode,
|
||||
turns: ReadonlyMap<number, TurnLocation>,
|
||||
): ChatConversationViewNode {
|
||||
const turn = 'turn' in node && typeof node.turn === 'number' ? turns.get(node.turn) : undefined
|
||||
const base = {
|
||||
key: `fixture:${node.kind}:${node.seq}`,
|
||||
id: String(node.seq),
|
||||
target: 'chat' as const,
|
||||
anchorSeq: node.seq,
|
||||
location: turn === undefined
|
||||
? { kind: 'session' as const }
|
||||
: { kind: 'turn' as const, turn },
|
||||
visibility: 'visible' as const,
|
||||
}
|
||||
switch (node.kind) {
|
||||
case 'assistant':
|
||||
return { ...base, kind: 'assistant-step', data: assistantData(node) }
|
||||
case 'tool-result':
|
||||
return { ...base, key: `fixture:tool:${node.callId}`, kind: 'tool-call', data: { root: node } }
|
||||
case 'model-retry':
|
||||
return { ...base, key: 'fixture:model-retry', kind: 'model-retry', data: { attempts: [node], current: node } }
|
||||
default:
|
||||
return { ...base, kind: node.kind, data: node }
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the canonical Chat fixture corresponding to one legacy test slice. */
|
||||
export function chatSnapshotFixture(input: {
|
||||
readonly nodes?: readonly ConversationNode[]
|
||||
readonly partial?: PartialAssistant | null
|
||||
readonly runningCalls?: readonly RunningToolCall[]
|
||||
readonly turnTimings?: LegacyConversationSlice['turnTimings']
|
||||
readonly turnEnds?: LegacyConversationSlice['turnEnds']
|
||||
} = {}, previous?: ChatSnapshot): ChatSnapshot {
|
||||
const legacy: LegacyConversationSlice = {
|
||||
nodes: input.nodes ?? EMPTY,
|
||||
partial: input.partial ?? null,
|
||||
runningCalls: input.runningCalls ?? EMPTY,
|
||||
turnTimings: input.turnTimings ?? new Map(),
|
||||
turnEnds: input.turnEnds ?? new Map(),
|
||||
}
|
||||
const turnNumbers = new Set([...legacy.turnTimings.keys(), ...legacy.turnEnds.keys()])
|
||||
for (const node of legacy.nodes) {
|
||||
if ('turn' in node && typeof node.turn === 'number') turnNumbers.add(node.turn)
|
||||
}
|
||||
if (legacy.partial !== null) turnNumbers.add(legacy.partial.turn)
|
||||
for (const call of legacy.runningCalls) turnNumbers.add(call.turn)
|
||||
const turns = new Map<number, TurnLocation>()
|
||||
const turnData = new Map<number, FixtureTurnDataStore>()
|
||||
for (const turn of [...turnNumbers].sort((left, right) => left - right)) {
|
||||
const timing = legacy.turnTimings.get(turn)
|
||||
const endSeq = legacy.turnEnds.get(turn)
|
||||
const data = new FixtureTurnDataStore()
|
||||
turnData.set(turn, data)
|
||||
turns.set(turn, {
|
||||
turn,
|
||||
start: timing === undefined ? undefined : {
|
||||
type: 'turn/start', seq: Math.max(0, (endSeq ?? 1) - 1), time: timing.startTime, turn,
|
||||
} as never,
|
||||
end: timing?.endTime === undefined || endSeq === undefined ? undefined : {
|
||||
type: 'turn/end', seq: endSeq, time: timing.endTime, turn, reason: 'completed',
|
||||
} as never,
|
||||
status: endSeq === undefined ? 'open' : 'closed',
|
||||
steps: EMPTY,
|
||||
data,
|
||||
})
|
||||
}
|
||||
const linkedCompactions = new Set<CompactionSummaryNode>()
|
||||
const nodes = legacy.nodes.flatMap((node): ChatConversationViewNode[] => {
|
||||
if (node.kind === 'command' && node.name === 'compact') {
|
||||
const sourceSeq = node.outcome?.kind === 'success' ? node.outcome.sourceEventSeq : undefined
|
||||
const candidates = sourceSeq === undefined
|
||||
? []
|
||||
: legacy.nodes.filter((candidate): candidate is CompactionSummaryNode =>
|
||||
candidate.kind === 'compaction' && candidate.summaryEventSeq === sourceSeq)
|
||||
const compaction = candidates.length === 1 ? candidates[0] : undefined
|
||||
if (node.outcome === null || compaction !== undefined) {
|
||||
if (compaction !== undefined) linkedCompactions.add(compaction)
|
||||
const base = settledNode(node, turns)
|
||||
return [{
|
||||
...base,
|
||||
key: `fixture:manual-compaction:${node.commandId}`,
|
||||
kind: 'manual-compaction',
|
||||
anchorSeq: compaction?.seq ?? node.seq,
|
||||
data: { command: node, compaction: compaction ?? null },
|
||||
}]
|
||||
}
|
||||
}
|
||||
if (node.kind === 'compaction' && linkedCompactions.has(node)) return []
|
||||
return [settledNode(node, turns)]
|
||||
})
|
||||
if (legacy.partial !== null) {
|
||||
const turn = turns.get(legacy.partial.turn)
|
||||
nodes.push({
|
||||
key: `fixture:assistant:${legacy.partial.turn}:${legacy.partial.step}`,
|
||||
id: `${legacy.partial.turn}:${legacy.partial.step}`,
|
||||
target: 'chat',
|
||||
kind: 'assistant-step',
|
||||
anchorSeq: Number.MAX_SAFE_INTEGER - 1,
|
||||
location: turn === undefined ? { kind: 'session' } : { kind: 'turn', turn },
|
||||
visibility: 'visible',
|
||||
data: {
|
||||
status: 'running',
|
||||
turn: legacy.partial.turn,
|
||||
step: legacy.partial.step,
|
||||
blocks: legacy.partial.blocks,
|
||||
time: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
for (const call of legacy.runningCalls) {
|
||||
const turn = turns.get(call.turn)
|
||||
nodes.push({
|
||||
key: `fixture:tool:${call.callId}`,
|
||||
id: call.callId,
|
||||
target: 'chat',
|
||||
kind: 'tool-call',
|
||||
anchorSeq: Number.MAX_SAFE_INTEGER,
|
||||
location: turn === undefined ? { kind: 'session' } : { kind: 'turn', turn },
|
||||
visibility: 'visible',
|
||||
data: { root: call },
|
||||
})
|
||||
}
|
||||
for (const [turnNumber, endSeq] of legacy.turnEnds) {
|
||||
const turn = turns.get(turnNumber)
|
||||
const dataStore = turnData.get(turnNumber)
|
||||
if (turn === undefined || dataStore === undefined) continue
|
||||
const closing = legacy.nodes
|
||||
.filter((candidate): candidate is AssistantMessageNode => candidate.kind === 'assistant'
|
||||
&& candidate.turn === turnNumber
|
||||
&& candidate.blocks.some(block => block.kind === 'text' && block.text.trim() !== ''))
|
||||
.map(assistantData)
|
||||
.at(-1) ?? null
|
||||
const preceding = nodes.findLast((candidate) => {
|
||||
const location = candidate.location
|
||||
return (location.kind === 'turn' || location.kind === 'step')
|
||||
&& location.turn.turn === turnNumber
|
||||
})
|
||||
const metrics = deriveTurnMetrics(legacy.nodes).get(turnNumber)
|
||||
const tailData = {
|
||||
turn: turnNumber,
|
||||
seq: endSeq,
|
||||
time: turn.end?.time ?? 0,
|
||||
closing,
|
||||
branchUnavailable: closing === null
|
||||
|| preceding?.kind !== 'assistant-step'
|
||||
|| (preceding.data as ReturnType<typeof assistantData>).finalNode.seq !== closing.finalNode.seq,
|
||||
...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs },
|
||||
...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond },
|
||||
}
|
||||
dataStore.set('turn-tail', tailData)
|
||||
nodes.push({
|
||||
key: `fixture:turn-tail:${turnNumber}`,
|
||||
id: String(turnNumber),
|
||||
target: 'chat',
|
||||
kind: 'turn-tail',
|
||||
anchorSeq: endSeq,
|
||||
location: { kind: 'turn', turn },
|
||||
visibility: 'visible',
|
||||
data: tailData,
|
||||
})
|
||||
}
|
||||
const store = previous?.nodes instanceof FixtureNodeStore ? previous.nodes : new FixtureNodeStore()
|
||||
store.replace(nodes)
|
||||
const byKey = new Map(store.values().map(node => [node.key, node]))
|
||||
const nextOrder = nodes.map(node => node.key)
|
||||
const order = previous !== undefined && sameValues(previous.order, nextOrder) ? previous.order : nextOrder
|
||||
const byTurn = new Map<number, readonly string[]>()
|
||||
for (const turn of turns.keys()) {
|
||||
byTurn.set(turn, order.filter((key) => {
|
||||
const location = byKey.get(key)?.location
|
||||
return location?.kind === 'turn' && location.turn.turn === turn
|
||||
|| location?.kind === 'step' && location.turn.turn === turn
|
||||
}))
|
||||
}
|
||||
const locations = previous?.locations instanceof FixtureLocationIndex
|
||||
? previous.locations
|
||||
: new FixtureLocationIndex()
|
||||
locations.replace(byTurn)
|
||||
const timeline = previous !== undefined
|
||||
&& previous.legacy.turnTimings === legacy.turnTimings
|
||||
&& previous.legacy.turnEnds === legacy.turnEnds
|
||||
? previous.timeline
|
||||
: { turnOrder: [...turns.keys()], turns }
|
||||
return {
|
||||
order,
|
||||
nodes: store,
|
||||
locations,
|
||||
timeline,
|
||||
legacy,
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.ts'
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: StatsLineProps['t'] = makeTranslate(zh, commonZh)
|
||||
@@ -42,18 +43,39 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: chatSnapshotFixture(),
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
}
|
||||
|
||||
function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
|
||||
const initial = { ...snapshotBase(), ...init }
|
||||
let snap: ConversationSnapshot = {
|
||||
...initial,
|
||||
chat: init?.chat ?? chatSnapshotFixture({
|
||||
nodes: initial.nodes,
|
||||
partial: initial.partial,
|
||||
runningCalls: initial.runningCalls,
|
||||
turnTimings: initial.turnTimings,
|
||||
turnEnds: initial.turnEnds,
|
||||
}),
|
||||
}
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
set: (next: Partial<ConversationSnapshot>) => {
|
||||
snap = { ...snap, ...next }
|
||||
const merged = { ...snap, ...next }
|
||||
snap = {
|
||||
...merged,
|
||||
chat: next.chat ?? (next.nodes === undefined ? snap.chat : chatSnapshotFixture({
|
||||
nodes: merged.nodes,
|
||||
partial: merged.partial,
|
||||
runningCalls: merged.runningCalls,
|
||||
turnTimings: merged.turnTimings,
|
||||
turnEnds: merged.turnEnds,
|
||||
})),
|
||||
}
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
source: {
|
||||
|
||||
@@ -4,24 +4,33 @@
|
||||
// ObservableSnapshot fake, no wire or Tool presentation plugin.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Profiler } from 'react'
|
||||
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||
import { useEffect } from 'react'
|
||||
import type {
|
||||
AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot,
|
||||
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode,
|
||||
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, TurnErrorNode,
|
||||
UserMessageNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ChatViewSlotProps, SelectionTarget, ToolTreeOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ChatView } from '../src/client/chat/ChatView.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, flowKeys, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
|
||||
import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx'
|
||||
import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx'
|
||||
import {
|
||||
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
|
||||
UnknownNodeView, UserMessageNodeView,
|
||||
} from '../src/client/chat/MessageItem.tsx'
|
||||
import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx'
|
||||
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.ts'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -34,10 +43,11 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode }
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: chatSnapshotFixture(), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
@@ -45,11 +55,21 @@ function snapshotBase(): ConversationSnapshot {
|
||||
|
||||
/** Scripted snapshot source: set() swaps the top-level object like the real Session. */
|
||||
function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
let snap: ConversationSnapshot = { ...snapshotBase(), ...init }
|
||||
const initial = { ...snapshotBase(), ...init }
|
||||
let snap: ConversationSnapshot = {
|
||||
...initial,
|
||||
chat: init?.chat ?? chatSnapshotFixture(initial),
|
||||
}
|
||||
const subs = new Set<() => void>()
|
||||
return {
|
||||
set: (next: Partial<ConversationSnapshot>) => {
|
||||
snap = { ...snap, ...next }
|
||||
const merged = { ...snap, ...next }
|
||||
snap = {
|
||||
...merged,
|
||||
chat: Object.hasOwn(next, 'chat') && next.chat !== undefined
|
||||
? next.chat
|
||||
: chatSnapshotFixture(merged, snap.chat),
|
||||
}
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
source: {
|
||||
@@ -73,7 +93,8 @@ const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode =>
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
|
||||
})
|
||||
const retry = (seq: number): ModelRetryNode => ({
|
||||
kind: 'model-retry', seq, time: seq * 1_000, turn: 1, step: 0,
|
||||
kind: 'model-retry', retryId: 'chat-view-retry' as ModelRetryNode['retryId'],
|
||||
seq, time: seq * 1_000, turn: 1, step: 0,
|
||||
retryState: 'scheduled',
|
||||
provider: 'mock', mode: 'normal', policyKey: 'mock-normal',
|
||||
retry: 1, maxRetries: 2, delayMs: 450,
|
||||
@@ -139,25 +160,97 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
// production; the view reads it through the PropsStore useStore share).
|
||||
const chat = createChatStore().create()
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
const toolOwners: ToolTreeOwnerProps[] = []
|
||||
const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
|
||||
if (key !== 'conversation.chat.tool') return opts?.fallback ?? null
|
||||
const tool = owner as ToolTreeOwnerProps
|
||||
toolOwners.push(tool)
|
||||
// Tool providers own their subtree. The host double carries only the
|
||||
// semantic anchor required by ChatView's prepend-position contract.
|
||||
return (
|
||||
<div
|
||||
data-testid={`tool-seat-${tool.callId}`}
|
||||
data-chat-anchor-key={`call:${tool.callId}`}
|
||||
data-chat-call-id={tool.callId}
|
||||
>
|
||||
{tool.toolName || '(unnamed)'}:{tool.callId}
|
||||
</div>
|
||||
const toolOwners: Array<{
|
||||
callId: string
|
||||
toolName: string
|
||||
block: ToolCallBlock
|
||||
selectedCallId: string | undefined
|
||||
openFile: ChatNodeOwnerProps['openFile']
|
||||
inspectCall: ChatNodeOwnerProps['inspectCall']
|
||||
}> = []
|
||||
const renderCommandSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
|
||||
opts?.fallback ?? null) as unknown as React.ComponentProps<typeof CommandNodeView>['renderSlot']
|
||||
const renderTurnTail = ((_key: string, _owner: object) => null) as unknown as
|
||||
React.ComponentProps<typeof TurnTailNodeView>['renderSlotChain']
|
||||
const renderTurnTailSlot = (() => null) as unknown as
|
||||
React.ComponentProps<typeof TurnTailNodeView>['renderSlot']
|
||||
const renderSlot = ((key: string, owner: object, opts?: {
|
||||
fallback?: React.ReactNode
|
||||
hookContext?: unknown
|
||||
}) => {
|
||||
if (key !== 'conversation.chat.node') return opts?.fallback ?? null
|
||||
const nodeOwner = owner as RoutedChatNodeOwner
|
||||
const nodeKey = opts?.hookContext as string | undefined
|
||||
const useTurnData: UseChatNodeTurnData = dataKey => props.useSession((snapshot) => {
|
||||
const location = nodeKey === undefined ? undefined : snapshot.chat.nodes.get(nodeKey)?.location
|
||||
return location?.kind === 'turn' || location?.kind === 'step'
|
||||
? location.turn.data.get(dataKey)
|
||||
: undefined
|
||||
})
|
||||
const nodeProps = <Kind extends ChatNode['kind']>(): ChatNodeViewProps<Kind> => (
|
||||
{ ...props, ...nodeOwner, useTurnData } as unknown as ChatNodeViewProps<Kind>
|
||||
)
|
||||
switch (nodeOwner.node.kind) {
|
||||
case 'user':
|
||||
case 'steering':
|
||||
return <UserMessageNodeView {...nodeProps<'user' | 'steering'>()} />
|
||||
case 'context':
|
||||
return <ContextMessageNodeView {...nodeProps<'context'>()} />
|
||||
case 'assistant-step':
|
||||
return <AssistantNodeView {...nodeProps<'assistant-step'>()} />
|
||||
case 'command':
|
||||
return (
|
||||
<CommandNodeView
|
||||
{...nodeProps<'command'>()}
|
||||
renderSlot={renderCommandSlot}
|
||||
SessionProvider={props.SessionProvider}
|
||||
/>
|
||||
)
|
||||
case 'manual-compaction':
|
||||
return <ManualCompactionNodeView {...nodeProps<'manual-compaction'>()} />
|
||||
case 'compaction':
|
||||
return <CompactionNodeView {...nodeProps<'compaction'>()} />
|
||||
case 'model-retry':
|
||||
return <RetryNodeView {...nodeProps<'model-retry'>()} />
|
||||
case 'turn-error':
|
||||
return <TurnErrorNodeView {...nodeProps<'turn-error'>()} />
|
||||
case 'turn-tail':
|
||||
return (
|
||||
<TurnTailNodeView
|
||||
{...nodeProps<'turn-tail'>()}
|
||||
renderSlot={renderTurnTailSlot}
|
||||
renderSlotChain={renderTurnTail}
|
||||
SessionProvider={props.SessionProvider}
|
||||
/>
|
||||
)
|
||||
case 'unknown':
|
||||
return <UnknownNodeView {...nodeProps<'unknown'>()} />
|
||||
case 'tool-call': {
|
||||
const block = nodeOwner.node.data.root
|
||||
const toolName = 'kind' in block ? block.call?.name ?? '' : block.name
|
||||
const tool = {
|
||||
callId: block.callId,
|
||||
toolName,
|
||||
block,
|
||||
selectedCallId: nodeOwner.selectedCallId,
|
||||
openFile: nodeOwner.openFile,
|
||||
inspectCall: nodeOwner.inspectCall,
|
||||
}
|
||||
toolOwners.push(tool)
|
||||
return (
|
||||
<div
|
||||
data-testid={`tool-seat-${tool.callId}`}
|
||||
data-chat-anchor-key={`call:${tool.callId}`}
|
||||
data-chat-call-id={tool.callId}
|
||||
>
|
||||
{tool.toolName || '(unnamed)'}:{tool.callId}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
default:
|
||||
return opts?.fallback ?? null
|
||||
}
|
||||
}) as unknown as ChatViewSlotProps['renderSlot']
|
||||
const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
|
||||
opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain']
|
||||
// SessionProvider seat arrives with the session-scope child declaration;
|
||||
// ChatView never invokes it (render-prop pass-through stub).
|
||||
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</>
|
||||
@@ -172,7 +265,6 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
renderSlot,
|
||||
renderSlotChain,
|
||||
SessionProvider: SessionProviderStub,
|
||||
openDetails,
|
||||
openFile,
|
||||
@@ -218,139 +310,7 @@ function installScrollMetrics(element: HTMLElement, initialHeight: number, clien
|
||||
}
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
it('groups consecutive tool results and keeps stable keys', () => {
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'hi'), assistant(2, 'let me look'), toolResult(3, 'a'), toolResult(4, 'b'),
|
||||
assistant(5, 'found'), toolResult(6, 'c'),
|
||||
]
|
||||
const items = deriveChatFlow(nodes)
|
||||
expect(items.map(i => i.kind)).toEqual(['node', 'node', 'tool-group', 'node', 'tool-group'])
|
||||
const group = items[2]!
|
||||
expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b'])
|
||||
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
|
||||
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
|
||||
})
|
||||
|
||||
it('reuses one stable row for consecutive retry turns', () => {
|
||||
const first = retry(2)
|
||||
const second = { ...retry(3), turn: 2, retry: 2 }
|
||||
const initial = deriveChatFlow([user(1, 'try'), first])
|
||||
const updated = deriveChatFlow([user(1, 'try'), first, second])
|
||||
expect(flowKeys(initial)).toBe('n1|n2')
|
||||
expect(flowKeys(updated)).toBe('n1|n2')
|
||||
expect(updated).toHaveLength(2)
|
||||
expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second)
|
||||
})
|
||||
|
||||
it('folds a successful /compact lifecycle into its explicitly linked checkpoint', () => {
|
||||
const running = command({
|
||||
seq: 1,
|
||||
commandId: 'cmd-compact' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: null,
|
||||
})
|
||||
expect(flowKeys(deriveChatFlow([user(0, 'before'), running]))).toBe('n0|ccmd-compact')
|
||||
|
||||
const settled = {
|
||||
...running,
|
||||
outcome: { kind: 'success' as const, text: 'Compacted 16 history items.', sourceEventSeq: 3 },
|
||||
}
|
||||
const checkpoint = compaction({ seq: 4, summaryEventSeq: 3 })
|
||||
const items = deriveChatFlow([user(0, 'before'), settled, user(2, 'injected while compacting'), checkpoint])
|
||||
expect(flowKeys(items)).toBe('n0|n2|ccmd-compact')
|
||||
expect(items.at(-1)).toEqual({
|
||||
kind: 'command-compaction',
|
||||
key: 'ccmd-compact',
|
||||
command: settled,
|
||||
compaction: checkpoint,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not split adjacent tool results around a folded /compact command', () => {
|
||||
const folded = command({
|
||||
seq: 2,
|
||||
commandId: 'cmd-compact' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', sourceEventSeq: 4 },
|
||||
})
|
||||
const items = deriveChatFlow([
|
||||
toolResult(1, 'a'),
|
||||
folded,
|
||||
toolResult(3, 'b'),
|
||||
compaction({ seq: 5, summaryEventSeq: 4 }),
|
||||
])
|
||||
expect(flowKeys(items)).toBe('g1|ccmd-compact')
|
||||
expect(
|
||||
items[0]?.kind === 'tool-group' && items[0].results.map(result => result.callId),
|
||||
).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('keeps automatic, unlinked, and ambiguously linked compactions as separate rows', () => {
|
||||
const automatic = compaction({ seq: 2, summaryEventSeq: 1 })
|
||||
expect(flowKeys(deriveChatFlow([automatic]))).toBe('n2')
|
||||
|
||||
const first = command({
|
||||
seq: 3,
|
||||
commandId: 'cmd-a' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', sourceEventSeq: 9 },
|
||||
})
|
||||
const second = command({
|
||||
seq: 4,
|
||||
commandId: 'cmd-b' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', sourceEventSeq: 9 },
|
||||
})
|
||||
const ambiguous = compaction({ seq: 10, summaryEventSeq: 9 })
|
||||
expect(flowKeys(deriveChatFlow([first, second, ambiguous]))).toBe('ccmd-a|ccmd-b|n10')
|
||||
|
||||
const sole = command({
|
||||
seq: 11,
|
||||
commandId: 'cmd-sole' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', sourceEventSeq: 12 },
|
||||
})
|
||||
const duplicateA = compaction({ seq: 13, summaryEventSeq: 12 })
|
||||
const duplicateB = compaction({ seq: 14, summaryEventSeq: 12 })
|
||||
expect(flowKeys(deriveChatFlow([sole, duplicateA, duplicateB]))).toBe('ccmd-sole|n13|n14')
|
||||
})
|
||||
|
||||
it('skips render-nothing assistant nodes so tool runs stay one group', () => {
|
||||
// A tool-call-only step message (and blank text/reasoning) renders nothing:
|
||||
// it must not split the run into two groups with an empty line between.
|
||||
const headsOnly: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'tool-call', callId: 'b', name: 'read', argsRaw: '{}' }, { kind: 'text', text: ' \n' }, { kind: 'reasoning', text: '' }],
|
||||
}
|
||||
const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')])
|
||||
expect(flowKeys(items)).toBe('g3')
|
||||
const group = items[0]!
|
||||
expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b'])
|
||||
// Interrupted and visible-content nodes still render (已停止 marker / prose).
|
||||
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5')
|
||||
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
|
||||
})
|
||||
|
||||
it('assistantActionsSeqs keeps only the last content assistant per completed turn', () => {
|
||||
const thinkOnly: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'reasoning', text: 'planning' }],
|
||||
}
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'hi'),
|
||||
assistant(2, 'looking', 1),
|
||||
thinkOnly,
|
||||
toolResult(4, 'a'),
|
||||
assistant(5, 'done', 1),
|
||||
user(6, 'again'),
|
||||
assistant(7, 'second turn', 2),
|
||||
]
|
||||
expect([...assistantActionsSeqs(nodes, new Map([[1, 5], [2, 7]]))].sort((a, b) => a - b)).toEqual([5, 7])
|
||||
// Turn 2 is still producing steps: its latest narration owns nothing, and
|
||||
// the settled turn 1 keeps its seat.
|
||||
expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5])
|
||||
})
|
||||
describe('Chat node rendering', () => {
|
||||
|
||||
it('threads the injected file-mention vocabulary into the closing prose only', () => {
|
||||
const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({
|
||||
@@ -391,17 +351,6 @@ describe('chat-flow derivation', () => {
|
||||
expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html')
|
||||
})
|
||||
|
||||
it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
|
||||
expect(runningTurnStartTime(new Map([
|
||||
[1, { startTime: 1_000, endTime: 5_000 }],
|
||||
[2, { startTime: 6_000 }],
|
||||
]))).toBe(6_000)
|
||||
expect(runningTurnStartTime(new Map([
|
||||
[1, { startTime: 1_000, endTime: 5_000 }],
|
||||
[2, { startTime: 6_000, endTime: 9_000 }],
|
||||
]))).toBeNull()
|
||||
})
|
||||
|
||||
it('formatRunDuration localizes units and floors partial seconds', () => {
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
expect(formatRunDuration(0, t)).toBe('0秒')
|
||||
@@ -410,24 +359,6 @@ describe('chat-flow derivation', () => {
|
||||
expect(formatRunDuration(125_000, t)).toBe('2分05秒')
|
||||
})
|
||||
|
||||
it('assistantBranchSeqs keeps only content-assistant tails; user/steering tails own no branch', () => {
|
||||
const interruptedThink: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
|
||||
}
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'first'),
|
||||
assistant(2, 'answer before tools'),
|
||||
toolResult(3, 'a'),
|
||||
interruptedThink,
|
||||
user(6, 'second'),
|
||||
assistant(7, 'clean tail', 2),
|
||||
user(10, 'user-only tail'),
|
||||
user(13, 'steering tail'),
|
||||
]
|
||||
const seqs = assistantBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
|
||||
expect([...seqs]).toEqual([7])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChatView', () => {
|
||||
@@ -444,8 +375,8 @@ describe('ChatView', () => {
|
||||
const h = makeHarness({ nodes: [user(9, 'first visible'), user(10, 'next visible')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
const first = view.container.querySelector('[data-chat-flow-key="n9"]') as HTMLDivElement
|
||||
const next = view.container.querySelector('[data-chat-flow-key="n10"]') as HTMLDivElement
|
||||
const first = view.container.querySelector('[data-chat-flow-key="fixture:user:9"]') as HTMLDivElement
|
||||
const next = view.container.querySelector('[data-chat-flow-key="fixture:user:10"]') as HTMLDivElement
|
||||
let firstTop = 100
|
||||
let nextTop = 300
|
||||
vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(
|
||||
@@ -472,7 +403,7 @@ describe('ChatView', () => {
|
||||
expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift
|
||||
})
|
||||
|
||||
it('renders the fixture main line: bubble, narration, grouped tool rows', () => {
|
||||
it('renders the fixture main line as independently keyed business nodes', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'do the thing'), assistant(2, 'running tools'), toolResult(3, 'a'), toolResult(4, 'b')],
|
||||
})
|
||||
@@ -485,14 +416,18 @@ describe('ChatView', () => {
|
||||
key: row.getAttribute('data-chat-flow-key'),
|
||||
kind: row.getAttribute('data-chat-flow-kind'),
|
||||
}))).toEqual([
|
||||
{ key: 'n1', kind: 'user' },
|
||||
{ key: 'n2', kind: 'assistant' },
|
||||
{ key: 'g3', kind: 'tool-group' },
|
||||
{ key: 'fixture:user:1', kind: 'user' },
|
||||
{ key: 'fixture:assistant:2', kind: 'assistant-step' },
|
||||
{ key: 'fixture:tool:a', kind: 'tool-call' },
|
||||
{ key: 'fixture:tool:b', kind: 'tool-call' },
|
||||
])
|
||||
expect([...view.container.querySelectorAll('[data-chat-call-id]')].map(row => row.getAttribute('data-chat-call-id')))
|
||||
.toEqual(['a', 'b'])
|
||||
expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key')))
|
||||
.toEqual(['node:1', 'node:2', 'call:a', 'call:b'])
|
||||
.toEqual([
|
||||
'fixture:user:1', 'fixture:assistant:2',
|
||||
'fixture:tool:a', 'call:a', 'fixture:tool:b', 'call:b',
|
||||
])
|
||||
})
|
||||
|
||||
it('renders Host-pending steering at the flow tail and hands off to the durable node', () => {
|
||||
@@ -559,14 +494,13 @@ describe('ChatView', () => {
|
||||
act(() => {
|
||||
h.set({ running: false, turnEnds: new Map([[1, 3]]) })
|
||||
})
|
||||
// The completed turn's transcript tail is the steering bubble, not the
|
||||
// narration, so the assistant's branch action stays unavailable and the
|
||||
// steering bubble still offers none.
|
||||
// The Turn Tail belongs to the closed Turn, independently of a later
|
||||
// steering bubble's placement in the Chat list.
|
||||
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(branchButtons).toHaveLength(1)
|
||||
expect(branchButtons[0]!.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(branchButtons[0]!.getAttribute('aria-disabled')).toBeNull()
|
||||
fireEvent.click(branchButtons[0]!)
|
||||
expect(h.forkAt).not.toHaveBeenCalled()
|
||||
expect(h.forkAt).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
|
||||
@@ -607,7 +541,7 @@ describe('ChatView', () => {
|
||||
expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s')
|
||||
|
||||
act(() => {
|
||||
h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] })
|
||||
h.set({ nodes: [user(1, 'try'), nextRetry] })
|
||||
})
|
||||
expect(within(disclosure).getAllByRole('status')).toHaveLength(1)
|
||||
expect(view.container.querySelector('details')).toBe(disclosure)
|
||||
@@ -617,7 +551,6 @@ describe('ChatView', () => {
|
||||
h.set({
|
||||
nodes: [
|
||||
user(1, 'try'),
|
||||
retryNode,
|
||||
{ ...nextRetry, retryState: 'started' },
|
||||
context,
|
||||
assistant(5, 'done'),
|
||||
@@ -760,7 +693,7 @@ describe('ChatView', () => {
|
||||
turnEnds: new Map([[1, 2]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// One scope per message row; the CSS reveal keys off this attribute.
|
||||
// The user row and the settled assistant's Turn Tail each own one clock scope.
|
||||
expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -787,7 +720,29 @@ describe('ChatView', () => {
|
||||
expect(h.forkAt.mock.calls).toEqual([[2]])
|
||||
})
|
||||
|
||||
it('keeps branch visible but unavailable when tool and interrupted Think follow the response', () => {
|
||||
it('disables fork when the indexed Turn has a later steering Node', () => {
|
||||
const base = chatSnapshotFixture({
|
||||
nodes: [user(1, 'question'), assistant(2, 'answer')],
|
||||
turnEnds: new Map([[1, 4]]),
|
||||
})
|
||||
const chat = {
|
||||
...base,
|
||||
locations: {
|
||||
getTurn: (turn: number) => turn === 1
|
||||
? [...base.locations.getTurn(turn), 'fixture:steering:later']
|
||||
: base.locations.getTurn(turn),
|
||||
getStep: (turn: number, step: number) => base.locations.getStep(turn, step),
|
||||
},
|
||||
}
|
||||
const h = makeHarness({ chat })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const branch = view.getByRole('button', { name: '在新对话中分支' })
|
||||
expect(branch.getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.click(branch)
|
||||
expect(h.forkAt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps final content actions but disables branch when Tool and interrupted Think follow it', () => {
|
||||
const interruptedThink: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
|
||||
@@ -843,19 +798,13 @@ describe('ChatView', () => {
|
||||
expect(view.container.querySelectorAll('h1')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('streaming partial frames re-render only the tail (Profiler count)', () => {
|
||||
it('streaming partial frames update the tail without replacing a sibling Tool row', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'q'), assistant(2, 'old answer'), toolResult(3, 'a')],
|
||||
})
|
||||
let renders = 0
|
||||
const counting = (
|
||||
<Profiler id="chat" onRender={() => { renders += 1 }}>
|
||||
<h.ChatView {...h.props} />
|
||||
</Profiler>
|
||||
)
|
||||
const view = render(counting)
|
||||
const before = renders
|
||||
const beforeHtml = view.container.querySelector('[class*="toolGroup"]')!.innerHTML
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const tool = view.getByTestId('tool-seat-a')
|
||||
const beforeHtml = tool.innerHTML
|
||||
act(() => {
|
||||
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming…' }] } })
|
||||
})
|
||||
@@ -863,9 +812,8 @@ describe('ChatView', () => {
|
||||
h.set({ partial: { turn: 2, step: 1, blocks: [{ kind: 'text', text: 'streaming… more' }] } })
|
||||
})
|
||||
expect(view.getByText('streaming… more')).toBeTruthy()
|
||||
// Each chunk commits exactly one profiler pass (the tail), never a full-tree storm.
|
||||
expect(renders - before).toBe(2)
|
||||
expect(view.container.querySelector('[class*="toolGroup"]')!.innerHTML).toBe(beforeHtml)
|
||||
expect(view.getByTestId('tool-seat-a')).toBe(tool)
|
||||
expect(tool.innerHTML).toBe(beforeHtml)
|
||||
})
|
||||
|
||||
it('streaming leaves neighbor tool rows and history items at zero re-renders', () => {
|
||||
@@ -875,8 +823,9 @@ describe('ChatView', () => {
|
||||
// Count renderSlot invocations: the memo boundary holds when CallRow does
|
||||
// not re-render, so the row's renderSlot call count freezes during chunks.
|
||||
let rowRenders = 0
|
||||
h.props.renderSlot = ((key: string, _owner: object) => {
|
||||
if (key !== 'conversation.chat.tool') return null
|
||||
h.props.renderSlot = ((key: string, owner: object) => {
|
||||
if (key !== 'conversation.chat.node'
|
||||
|| (owner as RoutedChatNodeOwner).node.kind !== 'tool-call') return null
|
||||
rowRenders += 1
|
||||
return <div data-testid="counting-row" />
|
||||
})
|
||||
@@ -908,6 +857,54 @@ describe('ChatView', () => {
|
||||
expect(view.getByRole('status').textContent).toBe('Deep diving...')
|
||||
})
|
||||
|
||||
it('keeps the Tool renderer mounted when a running call settles into log order', () => {
|
||||
const mounted = vi.fn()
|
||||
const unmounted = vi.fn()
|
||||
function StatefulToolNode({ node }: { readonly node: ChatNode<'tool-call'> }) {
|
||||
useEffect(() => {
|
||||
mounted()
|
||||
return () => { unmounted() }
|
||||
}, [])
|
||||
const root = node.data.root
|
||||
return (
|
||||
<div data-testid="stateful-tool" data-state={'kind' in root ? 'settled' : 'running'}>
|
||||
{root.callId}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'q'), assistant(4, 'later')],
|
||||
runningCalls: [runningCall('r1')],
|
||||
running: true,
|
||||
})
|
||||
h.props.renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
|
||||
const routed = owner as RoutedChatNodeOwner
|
||||
return key === 'conversation.chat.node' && routed.node.kind === 'tool-call'
|
||||
? <StatefulToolNode node={routed.node} />
|
||||
: opts?.fallback ?? null
|
||||
}) as ChatViewSlotProps['renderSlot']
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const tool = view.getByTestId('stateful-tool')
|
||||
const row = view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]')
|
||||
expect(tool.dataset.state).toBe('running')
|
||||
expect(mounted).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
nodes: [user(1, 'q'), toolResult(3, 'r1'), assistant(4, 'later')],
|
||||
runningCalls: [],
|
||||
running: false,
|
||||
})
|
||||
})
|
||||
|
||||
expect(view.getByTestId('stateful-tool')).toBe(tool)
|
||||
expect(view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]')).toBe(row)
|
||||
expect(tool.dataset.state).toBe('settled')
|
||||
expect(mounted).toHaveBeenCalledTimes(1)
|
||||
expect(unmounted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => {
|
||||
const startTime = Date.now() - 125_000
|
||||
const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 }
|
||||
@@ -932,7 +929,7 @@ describe('ChatView', () => {
|
||||
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
|
||||
})
|
||||
|
||||
it('hands each ordered root call to the whole-Tool slot', () => {
|
||||
it('hands each ordered root call to the keyed business-node slot', () => {
|
||||
const block = toolResult(3, 'a')
|
||||
const h = makeHarness({ nodes: [block] })
|
||||
const calls: { key: string; owner: object; entryKey?: string }[] = []
|
||||
@@ -943,14 +940,14 @@ describe('ChatView', () => {
|
||||
render(<h.ChatView {...h.props} />)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
key: 'conversation.chat.tool',
|
||||
owner: { callId: 'a', toolName: 'bash', selectedCallId: undefined },
|
||||
key: 'conversation.chat.node',
|
||||
owner: { node: { kind: 'tool-call' }, selectedCallId: undefined },
|
||||
entryKey: 'tool-call',
|
||||
})
|
||||
const owner = calls[0]?.owner as ToolTreeOwnerProps
|
||||
expect(owner.block).toBe(block)
|
||||
const owner = calls[0]?.owner as RoutedChatNodeOwner
|
||||
expect((owner.node.data as { readonly root: ToolCallBlock }).root).toBe(block)
|
||||
expect(owner.openFile).toBe(h.openFile)
|
||||
expect(owner.inspectCall).toBe(h.inspectCall)
|
||||
expect(calls[0]?.entryKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prepend preserves a semantic row; a trailing user node force-scrolls', () => {
|
||||
@@ -960,7 +957,7 @@ describe('ChatView', () => {
|
||||
// jsdom has no layout: fake the metrics the anchor math reads.
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true })
|
||||
const anchored = view.container.querySelector('[data-chat-flow-key="n5"]') as HTMLDivElement
|
||||
const anchored = view.container.querySelector('[data-chat-flow-key="fixture:user:5"]') as HTMLDivElement
|
||||
let anchoredTop = 100
|
||||
vi.spyOn(anchored, 'getBoundingClientRect').mockImplementation(
|
||||
() => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect),
|
||||
@@ -977,60 +974,6 @@ describe('ChatView', () => {
|
||||
expect(scroller.scrollTop).toBe(1600)
|
||||
})
|
||||
|
||||
it('uses stable call identity when a prepend changes the tool-group key amid unrelated growth', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(5, 'late')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
let prepended = false
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.chatAnchorKey === 'call:late') {
|
||||
const top = prepended ? 400 : 100
|
||||
return { top, bottom: top + 40 } as DOMRect
|
||||
}
|
||||
return { top: 0, bottom: 200 } as DOMRect
|
||||
})
|
||||
try {
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
readerScroll(scroller, 80)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
// Total height grows by 500, but only 300 belongs before the call row.
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
|
||||
prepended = true
|
||||
act(() => { h.set({ nodes: [toolResult(4, 'early'), toolResult(5, 'late')] }) })
|
||||
expect(scroller.scrollTop).toBe(380)
|
||||
} finally {
|
||||
rect.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the latest retry identity when prepending an earlier retry changes the flow key', () => {
|
||||
const h = makeHarness({ nodes: [retry(5)], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
let prepended = false
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.chatAnchorKey === 'node:5') {
|
||||
const top = prepended ? 400 : 100
|
||||
return { top, bottom: top + 40 } as DOMRect
|
||||
}
|
||||
return { top: 0, bottom: 200 } as DOMRect
|
||||
})
|
||||
try {
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 700, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
readerScroll(scroller, 80)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
|
||||
prepended = true
|
||||
act(() => { h.set({ nodes: [retry(4), retry(5)] }) })
|
||||
expect(scroller.scrollTop).toBe(380)
|
||||
expect(view.container.querySelector('[data-chat-flow-key="n4"][data-chat-anchor-key="node:5"]')).not.toBeNull()
|
||||
} finally {
|
||||
rect.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('back-to-bottom cancels an in-flight paging anchor', () => {
|
||||
const h = makeHarness({ nodes: [user(9, 'late')], hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
@@ -1177,7 +1120,7 @@ describe('ChatView', () => {
|
||||
() => ({ top: 0, bottom: 500 } as DOMRect),
|
||||
)
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.chatAnchorKey === 'node:1') {
|
||||
if (this.dataset.chatAnchorKey === 'fixture:user:1') {
|
||||
return { top: anchorTop, bottom: anchorTop + 40 } as DOMRect
|
||||
}
|
||||
return { top: 0, bottom: 40 } as DOMRect
|
||||
@@ -1216,12 +1159,12 @@ describe('ChatView', () => {
|
||||
})
|
||||
document.body.appendChild(host)
|
||||
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.chatAnchorKey === 'node:1') return { top: 300, bottom: 340 } as DOMRect
|
||||
if (this.dataset.chatAnchorKey === 'fixture:user:1') return { top: 300, bottom: 340 } as DOMRect
|
||||
return { top: 0, bottom: 500 } as DOMRect
|
||||
})
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
|
||||
h.chatScroll.save({ anchorKey: 'node:1', anchorTop: 80, scrollTop: 1_400 })
|
||||
h.chatScroll.save({ anchorKey: 'fixture:user:1', anchorTop: 80, scrollTop: 1_400 })
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
expect(host.scrollTop).toBe(1_500)
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
|
||||
@@ -0,0 +1,862 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
ChatConversationViewNode, ChatSnapshot, ConversationEventInput,
|
||||
ConversationNodeDefinition, ConversationViewDefinition,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
|
||||
import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
|
||||
import { commandDefinition } from '../src/client/conversation-nodes/command.ts'
|
||||
import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts'
|
||||
import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts'
|
||||
import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../src/client/conversation-nodes/inbox.ts'
|
||||
import { messageDefinition } from '../src/client/conversation-nodes/message.ts'
|
||||
import { retryDefinition } from '../src/client/conversation-nodes/retry.ts'
|
||||
import { toolDefinition } from '../src/client/conversation-nodes/tool.ts'
|
||||
import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts'
|
||||
import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts'
|
||||
import type {
|
||||
AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData,
|
||||
} from '../src/client/contract/chat-nodes.ts'
|
||||
|
||||
const DEFINITIONS: readonly ConversationNodeDefinition[] = [
|
||||
nextTurnInboxDefinition,
|
||||
nextStepInboxDefinition,
|
||||
messageDefinition,
|
||||
assistantDefinition,
|
||||
toolDefinition,
|
||||
commandDefinition,
|
||||
compactionDefinition,
|
||||
retryDefinition,
|
||||
turnErrorDefinition,
|
||||
turnTailDefinition,
|
||||
]
|
||||
|
||||
class TestEventDefinitions {
|
||||
entries(): readonly ConversationNodeDefinition[] {
|
||||
return DEFINITIONS
|
||||
}
|
||||
|
||||
fallbackEntry(): ConversationNodeDefinition {
|
||||
return unknownFallbackDefinition
|
||||
}
|
||||
}
|
||||
|
||||
class TestViewDefinitions {
|
||||
entries(): readonly ConversationViewDefinition[] {
|
||||
return [chatViewDefinition]
|
||||
}
|
||||
}
|
||||
|
||||
function at(
|
||||
seq: number,
|
||||
type: string,
|
||||
data: unknown,
|
||||
extra: Record<string, unknown> = {},
|
||||
): ConversationEventInput {
|
||||
return {
|
||||
event: {
|
||||
seq,
|
||||
time: 1_700_000_000_000 + seq,
|
||||
type,
|
||||
data,
|
||||
...extra,
|
||||
} as unknown as ConversationEventInput['event'],
|
||||
view: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function assembler(entries: readonly ConversationEventInput[] = [], hasMore = false): ConversationNodeAssembler {
|
||||
const value = new ConversationNodeAssembler(new TestEventDefinitions(), new TestViewDefinitions())
|
||||
value.replaceWindow(entries, hasMore)
|
||||
value.flush()
|
||||
return value
|
||||
}
|
||||
|
||||
function snapshot(value: ConversationNodeAssembler): ChatSnapshot {
|
||||
const current = value.snapshot('chat') as ChatSnapshot | undefined
|
||||
if (current === undefined) throw new Error('chat view was not registered')
|
||||
return current
|
||||
}
|
||||
|
||||
function node(value: ChatSnapshot, kind: string): ChatConversationViewNode | undefined {
|
||||
return value.nodes.values().find(candidate => candidate.kind === kind)
|
||||
}
|
||||
|
||||
function textMessage(id: string, text: string) {
|
||||
return {
|
||||
id,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}
|
||||
}
|
||||
|
||||
function assistantMessage(id: string, text: string) {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'model', provider: 'fake', model: 'fake' },
|
||||
}
|
||||
}
|
||||
|
||||
function toolResult(callId: string, text: string) {
|
||||
return {
|
||||
id: `result-${callId}`,
|
||||
role: 'user',
|
||||
source: { kind: 'tool', callId },
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: callId,
|
||||
content: [{ type: 'text', text }],
|
||||
isError: false,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
describe('built-in conversation node Definitions', () => {
|
||||
it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'streaming' },
|
||||
}),
|
||||
])
|
||||
const runningSnapshot = snapshot(value)
|
||||
const running = node(runningSnapshot, 'assistant-step')
|
||||
expect(running?.data).toMatchObject({ status: 'running', blocks: [{ kind: 'text', text: 'streaming' }] })
|
||||
const order = runningSnapshot.order
|
||||
|
||||
value.append(at(4, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: assistantMessage('assistant-1', 'settled'),
|
||||
}, { surfaceOp: 'append' }))
|
||||
value.flush()
|
||||
|
||||
const settledSnapshot = snapshot(value)
|
||||
const settled = node(settledSnapshot, 'assistant-step')
|
||||
expect(settled?.key).toBe(running?.key)
|
||||
expect(settledSnapshot.order).toBe(order)
|
||||
expect(settled?.data).toMatchObject({ status: 'settled', blocks: [{ kind: 'text', text: 'settled' }] })
|
||||
|
||||
const interruptedValue = assembler([
|
||||
at(10, 'turn/start', { turn: 2 }),
|
||||
at(11, 'step/start', { turn: 2, step: 1 }),
|
||||
at(12, 'assistant/chunk', {
|
||||
turn: 2,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'partial' },
|
||||
}),
|
||||
at(13, 'step/end', { turn: 2, step: 1 }),
|
||||
])
|
||||
const interrupted = node(snapshot(interruptedValue), 'assistant-step')
|
||||
expect(interrupted?.data).toMatchObject({ status: 'interrupted' })
|
||||
expect((interrupted?.data as AssistantChatData).finalNode?.interrupted).toBe(true)
|
||||
|
||||
const hiddenValue = assembler([
|
||||
at(20, 'turn/start', { turn: 3 }),
|
||||
at(21, 'step/start', { turn: 3, step: 1 }),
|
||||
at(22, 'llm/retry', {
|
||||
retryId: 'retry-hidden',
|
||||
turn: 3,
|
||||
step: 1,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 10,
|
||||
failure: { code: 'TRANSPORT', message: 'temporary' },
|
||||
}),
|
||||
])
|
||||
expect(node(snapshot(hiddenValue), 'assistant-step')).toBeUndefined()
|
||||
|
||||
const toolOnlyValue = assembler([
|
||||
at(30, 'turn/start', { turn: 4 }),
|
||||
at(31, 'step/start', { turn: 4, step: 1 }),
|
||||
at(32, 'assistant/chunk', {
|
||||
turn: 4,
|
||||
step: 1,
|
||||
chunk: { type: 'tool-call-delta', index: 0, id: 'call-1', name: 'read', argumentsDelta: '' },
|
||||
}),
|
||||
at(33, 'assistant/message', {
|
||||
turn: 4,
|
||||
step: 1,
|
||||
message: {
|
||||
...assistantMessage('assistant-tool-only', ''),
|
||||
content: [{ type: 'tool-call', id: 'call-1', name: 'read', arguments: '{}' }],
|
||||
},
|
||||
}, { surfaceOp: 'append' }),
|
||||
])
|
||||
const toolOnlySnapshot = snapshot(toolOnlyValue)
|
||||
expect(toolOnlySnapshot.order).toEqual([])
|
||||
expect(node(toolOnlySnapshot, 'assistant-step')?.visibility).toBe('hidden')
|
||||
expect(toolOnlySnapshot.legacy.nodes).toMatchObject([{
|
||||
kind: 'assistant',
|
||||
seq: 33,
|
||||
timing: { firstTokenTime: 1_700_000_000_032 },
|
||||
}])
|
||||
|
||||
const interruptedToolOnlyValue = assembler([
|
||||
at(35, 'turn/start', { turn: 5 }),
|
||||
at(36, 'step/start', { turn: 5, step: 1 }),
|
||||
at(37, 'assistant/chunk', {
|
||||
turn: 5,
|
||||
step: 1,
|
||||
chunk: { type: 'tool-call-delta', index: 0, id: 'call-2', name: 'read', argumentsDelta: '' },
|
||||
}),
|
||||
at(38, 'step/end', { turn: 5, step: 1 }),
|
||||
])
|
||||
const interruptedToolOnly = node(snapshot(interruptedToolOnlyValue), 'assistant-step')
|
||||
expect(interruptedToolOnly?.visibility).toBe('visible')
|
||||
expect(interruptedToolOnly?.data).toMatchObject({ status: 'interrupted' })
|
||||
|
||||
const retryTimingValue = assembler([
|
||||
at(50, 'turn/start', { turn: 6 }),
|
||||
at(51, 'step/start', { turn: 6, step: 1 }),
|
||||
at(52, 'assistant/chunk', {
|
||||
turn: 6,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'first attempt' },
|
||||
}),
|
||||
at(53, 'llm/retry', {
|
||||
retryId: 'retry-timing', turn: 6, step: 1, provider: 'fake', mode: 'normal',
|
||||
policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
|
||||
failure: { code: 'TRANSPORT', message: 'temporary' },
|
||||
}),
|
||||
at(54, 'assistant/chunk', {
|
||||
turn: 6,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'second attempt' },
|
||||
}),
|
||||
at(55, 'assistant/message', {
|
||||
turn: 6,
|
||||
step: 1,
|
||||
message: assistantMessage('assistant-retried', 'done'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
])
|
||||
const retryTiming = (node(snapshot(retryTimingValue), 'assistant-step')?.data as AssistantChatData).finalNode
|
||||
expect(retryTiming?.timing?.firstTokenTime).toBe(1_700_000_000_052)
|
||||
|
||||
const partialWindow = assembler([
|
||||
at(40, 'assistant/chunk', {
|
||||
turn: 5,
|
||||
step: 2,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'loaded partial' },
|
||||
}),
|
||||
at(41, 'step/end', { turn: 5, step: 2 }),
|
||||
], true)
|
||||
const recovered = node(snapshot(partialWindow), 'assistant-step')
|
||||
expect(recovered?.data).toMatchObject({
|
||||
status: 'interrupted',
|
||||
blocks: [{ kind: 'text', text: 'loaded partial' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps one keyed Tool node from running through settlement and replays nested dispatch after prepend', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'code', arguments: '{}' }),
|
||||
])
|
||||
const runningSnapshot = snapshot(value)
|
||||
const running = node(runningSnapshot, 'tool-call')
|
||||
expect((running?.data as ToolChatData).root).toMatchObject({ callId: 'root', name: 'code' })
|
||||
const order = runningSnapshot.order
|
||||
|
||||
value.append(at(4, 'tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: toolResult('root', 'done'),
|
||||
}, { surfaceOp: 'append' }))
|
||||
value.flush()
|
||||
|
||||
const settledSnapshot = snapshot(value)
|
||||
const settled = node(settledSnapshot, 'tool-call')
|
||||
expect(settled?.key).toBe(running?.key)
|
||||
expect(settledSnapshot.order).toBe(order)
|
||||
expect((settled?.data as ToolChatData).root).toMatchObject({ kind: 'tool-result', callId: 'root' })
|
||||
|
||||
const history = assembler([
|
||||
at(14, 'tool/code-dispatch-start', {
|
||||
rootCallId: 'history-root',
|
||||
parentCallId: 'history-root',
|
||||
subCallId: 'child',
|
||||
name: 'read',
|
||||
arguments: { path: 'README.md' },
|
||||
}),
|
||||
at(15, 'tool/code-dispatch', {
|
||||
rootCallId: 'history-root',
|
||||
parentCallId: 'history-root',
|
||||
subCallId: 'child',
|
||||
name: 'read',
|
||||
arguments: { path: 'README.md' },
|
||||
isError: false,
|
||||
content: [{ type: 'text', text: 'contents' }],
|
||||
}),
|
||||
at(16, 'tool/result', {
|
||||
turn: 2,
|
||||
step: 1,
|
||||
message: toolResult('history-root', 'root done'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
], true)
|
||||
const before = node(snapshot(history), 'tool-call')
|
||||
expect((before?.data as ToolChatData).root.subCalls).toMatchObject([
|
||||
{ kind: 'tool-result', callId: 'child', call: { name: 'read' } },
|
||||
])
|
||||
|
||||
history.prepend([
|
||||
at(10, 'turn/start', { turn: 2 }),
|
||||
at(11, 'step/start', { turn: 2, step: 1 }),
|
||||
at(13, 'tool/call', {
|
||||
turn: 2,
|
||||
step: 1,
|
||||
callId: 'history-root',
|
||||
name: 'code',
|
||||
arguments: '{}',
|
||||
}),
|
||||
], false)
|
||||
history.flush()
|
||||
|
||||
const after = node(snapshot(history), 'tool-call')
|
||||
expect(after?.key).toBe(before?.key)
|
||||
expect((after?.data as ToolChatData).root.subCalls).toMatchObject([
|
||||
{ kind: 'tool-result', callId: 'child', call: { name: 'read' } },
|
||||
])
|
||||
|
||||
const firstChild = (after?.data as ToolChatData).root.subCalls[0]
|
||||
history.append(at(17, 'tool/code-dispatch-start', {
|
||||
rootCallId: 'history-root',
|
||||
parentCallId: 'history-root',
|
||||
subCallId: 'second-child',
|
||||
name: 'write',
|
||||
arguments: { path: 'out.txt' },
|
||||
}))
|
||||
history.flush()
|
||||
const withSecondChild = node(snapshot(history), 'tool-call')
|
||||
expect((withSecondChild?.data as ToolChatData).root.subCalls[0]).toBe(firstChild)
|
||||
})
|
||||
|
||||
it('prepends an older turn without replacing already materialized nodes', () => {
|
||||
const value = assembler([
|
||||
at(20, 'turn/start', { turn: 2 }),
|
||||
at(21, 'user/message', textMessage('newer-user', 'newer'), { surfaceOp: 'append' }),
|
||||
at(22, 'step/start', { turn: 2, step: 1 }),
|
||||
at(23, 'assistant/message', {
|
||||
turn: 2,
|
||||
step: 1,
|
||||
message: assistantMessage('newer-assistant', 'newer answer'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(24, 'step/end', { turn: 2, step: 1 }),
|
||||
at(25, 'turn/end', { turn: 2, reason: { kind: 'completed' } }),
|
||||
], true)
|
||||
const before = snapshot(value)
|
||||
const existing = before.nodes.get(before.order.find(key => before.nodes.get(key)?.kind === 'assistant-step') ?? '')
|
||||
const store = before.nodes
|
||||
|
||||
value.prepend([
|
||||
at(10, 'turn/start', { turn: 1 }),
|
||||
at(11, 'user/message', textMessage('older-user', 'older'), { surfaceOp: 'append' }),
|
||||
at(12, 'step/start', { turn: 1, step: 1 }),
|
||||
at(13, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: assistantMessage('older-assistant', 'older answer'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(14, 'step/end', { turn: 1, step: 1 }),
|
||||
at(15, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
], false)
|
||||
value.flush()
|
||||
|
||||
const after = snapshot(value)
|
||||
expect(after.nodes).toBe(store)
|
||||
expect(after.nodes.get(existing?.key ?? '')).toBe(existing)
|
||||
expect(after.order).toHaveLength(before.order.length + 3)
|
||||
expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
|
||||
'user', 'assistant-step', 'turn-tail',
|
||||
'user', 'assistant-step', 'turn-tail',
|
||||
])
|
||||
})
|
||||
|
||||
it('appends a later turn without replacing nodes from the completed turn', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'user/message', textMessage('first-user', 'first'), { surfaceOp: 'append' }),
|
||||
at(3, 'step/start', { turn: 1, step: 1 }),
|
||||
at(4, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: assistantMessage('first-assistant', 'first answer'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(5, 'step/end', { turn: 1, step: 1 }),
|
||||
at(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
])
|
||||
const before = snapshot(value)
|
||||
const oldOrder = before.order
|
||||
const oldNodes = oldOrder.map(key => before.nodes.get(key))
|
||||
|
||||
value.append(at(7, 'turn/start', { turn: 2 }))
|
||||
value.append(at(8, 'user/message', textMessage('second-user', 'second'), { surfaceOp: 'append' }))
|
||||
value.flush()
|
||||
|
||||
const after = snapshot(value)
|
||||
expect(after.nodes).toBe(before.nodes)
|
||||
expect(after.order.slice(0, oldOrder.length)).toEqual(oldOrder)
|
||||
expect(oldOrder.map(key => after.nodes.get(key))).toEqual(oldNodes)
|
||||
expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
|
||||
'user', 'assistant-step', 'turn-tail', 'user',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps branching unavailable when a tool result follows the closing Assistant', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: assistantMessage('assistant-before-tool', 'running a tool'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(4, 'tool/call', { turn: 1, step: 1, callId: 'late-tool', name: 'read', arguments: '{}' }),
|
||||
at(5, 'tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: toolResult('late-tool', 'done'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(6, 'step/end', { turn: 1, step: 1 }),
|
||||
at(7, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
])
|
||||
|
||||
const tail = node(snapshot(value), 'turn-tail')?.data as TurnTailChatData
|
||||
expect(tail.closing?.finalNode.seq).toBe(3)
|
||||
expect(tail.branchUnavailable).toBe(true)
|
||||
})
|
||||
|
||||
it('replays inbox predecessors after prepend and reclassifies the dependent message as steering', () => {
|
||||
const value = assembler([
|
||||
at(3, 'user/message', textMessage('steer-1', 'change direction'), { surfaceOp: 'append' }),
|
||||
], true)
|
||||
const before = node(snapshot(value), 'user')
|
||||
expect(before).toBeDefined()
|
||||
|
||||
value.prepend([
|
||||
at(1, 'agent/inbox/spliced', {
|
||||
target: 'next-step',
|
||||
start: 0,
|
||||
inserted: [textMessage('steer-1', 'change direction')],
|
||||
}),
|
||||
at(2, 'agent/inbox/spliced', {
|
||||
target: 'next-step',
|
||||
start: 0,
|
||||
removedCount: 1,
|
||||
inserted: [],
|
||||
}),
|
||||
], false)
|
||||
value.flush()
|
||||
|
||||
const after = node(snapshot(value), 'steering')
|
||||
expect(after?.key).toBe(before?.key)
|
||||
expect(after?.data).toMatchObject({ kind: 'steering', messageId: 'steer-1' })
|
||||
expect(node(snapshot(value), 'user')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('orders claimed steering after the finalized Turn tail', () => {
|
||||
const steering = textMessage('steer-after-answer', 'change direction')
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: assistantMessage('assistant-before-steering', 'initial answer'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(4, 'agent/inbox/spliced', {
|
||||
target: 'next-step',
|
||||
start: 0,
|
||||
inserted: [steering],
|
||||
}),
|
||||
at(5, 'agent/inbox/spliced', {
|
||||
target: 'next-step',
|
||||
start: 0,
|
||||
removedCount: 1,
|
||||
inserted: [],
|
||||
}),
|
||||
at(6, 'user/message', steering, { surfaceOp: 'append' }),
|
||||
at(7, 'step/end', { turn: 1, step: 1 }),
|
||||
at(8, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
])
|
||||
|
||||
const current = snapshot(value)
|
||||
const steeringNode = node(current, 'steering')
|
||||
expect(steeringNode).toBeDefined()
|
||||
expect(current.locations.getTurn(1).at(-1)).toBe(steeringNode?.key)
|
||||
})
|
||||
|
||||
it('classifies appended producer context from durable source metadata', () => {
|
||||
const value = assembler([
|
||||
at(1, 'user/message', {
|
||||
...textMessage('skill-context', 'follow these instructions'),
|
||||
source: { kind: 'skill-invocation', name: 'demo-skill', form: 'instructions' },
|
||||
}, { surfaceOp: 'append' }),
|
||||
])
|
||||
|
||||
expect(node(snapshot(value), 'context')?.data).toMatchObject({
|
||||
kind: 'context',
|
||||
provenance: { role: 'inject', label: 'demo-skill' },
|
||||
form: 'instructions',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps replacement copies out of Chat business nodes', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'user/message', {
|
||||
...textMessage('replacement-user', 'model-only context'),
|
||||
source: { kind: 'plugin', plugin: 'foreign' },
|
||||
}, { surfaceOp: { op: 'replace', start: 1, end: 1 } }),
|
||||
at(4, 'assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: assistantMessage('replacement-assistant', 'rewritten answer'),
|
||||
}, { surfaceOp: { op: 'replace', start: 2, end: 2 } }),
|
||||
at(5, 'tool/call', { turn: 1, step: 1, callId: 'root', name: 'read', arguments: '{}' }),
|
||||
at(6, 'tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: toolResult('root', 'pruned result'),
|
||||
}, { surfaceOp: { op: 'replace', start: 3, end: 3 } }),
|
||||
])
|
||||
|
||||
const current = snapshot(value)
|
||||
expect(node(current, 'user')).toBeUndefined()
|
||||
expect(node(current, 'context')).toBeUndefined()
|
||||
expect(node(current, 'assistant-step')).toBeUndefined()
|
||||
expect((node(current, 'tool-call')?.data as ToolChatData).root).not.toHaveProperty('kind')
|
||||
})
|
||||
|
||||
it('assembles retry chains and keeps manual and automatic compaction ownership separate', () => {
|
||||
const retry = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'llm/retry', {
|
||||
retryId: 'retry-1',
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 10,
|
||||
failure: { code: 'TRANSPORT', message: 'first' },
|
||||
}),
|
||||
at(4, 'llm/retry-started', { retryId: 'retry-1', turn: 1, step: 1, retry: 1 }),
|
||||
at(5, 'llm/retry', {
|
||||
retryId: 'retry-1',
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: 2,
|
||||
maxRetries: 2,
|
||||
delayMs: 20,
|
||||
failure: { code: 'TRANSPORT', message: 'second' },
|
||||
}),
|
||||
at(6, 'step/end', { turn: 1, step: 1 }),
|
||||
at(7, 'turn/end', {
|
||||
turn: 1,
|
||||
reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
|
||||
}),
|
||||
])
|
||||
const retryNode = node(snapshot(retry), 'model-retry')
|
||||
const retryData = retryNode?.data as RetryChatData
|
||||
expect(retryData.attempts.map(attempt => attempt.retryState)).toEqual(['started', 'cancelled'])
|
||||
expect(node(snapshot(retry), 'turn-error')).toBeUndefined()
|
||||
|
||||
const compactions = assembler([
|
||||
at(10, 'command/run', {
|
||||
commandId: 'command-1',
|
||||
name: 'compact',
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
at(11, 'compact/start', {
|
||||
compactionId: 'manual-1',
|
||||
sourceCommandId: 'command-1',
|
||||
turn: null,
|
||||
}),
|
||||
at(12, 'compact/summary', {
|
||||
compactionId: 'manual-1',
|
||||
sourceCommandId: 'command-1',
|
||||
summary: [{ type: 'text', text: 'manual summary' }],
|
||||
shadowedSeqs: [1, 2],
|
||||
shadowedTokenCount: 100,
|
||||
}),
|
||||
at(13, 'user/message', {
|
||||
...textMessage('manual-checkpoint', 'checkpoint'),
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'compact',
|
||||
compactionId: 'manual-1',
|
||||
sourceCommandId: 'command-1',
|
||||
},
|
||||
}, { surfaceOp: { op: 'replace', start: 1, end: 2 } }),
|
||||
at(14, 'compact/end', {
|
||||
compactionId: 'manual-1',
|
||||
sourceCommandId: 'command-1',
|
||||
turn: null,
|
||||
}),
|
||||
at(15, 'command/done', {
|
||||
commandId: 'command-1',
|
||||
kind: 'success',
|
||||
sourceEventSeq: 12,
|
||||
}),
|
||||
at(20, 'compact/start', { compactionId: 'automatic-1', turn: null }),
|
||||
at(21, 'compact/summary', {
|
||||
compactionId: 'automatic-1',
|
||||
summary: [{ type: 'text', text: 'automatic summary' }],
|
||||
shadowedSeqs: [3, 4],
|
||||
shadowedTokenCount: 200,
|
||||
}),
|
||||
at(22, 'user/message', {
|
||||
...textMessage('automatic-checkpoint', 'checkpoint'),
|
||||
source: { kind: 'plugin', plugin: 'compact', compactionId: 'automatic-1' },
|
||||
}, { surfaceOp: { op: 'replace', start: 3, end: 4 } }),
|
||||
at(23, 'compact/end', { compactionId: 'automatic-1', turn: null }),
|
||||
])
|
||||
|
||||
const manual = node(snapshot(compactions), 'manual-compaction')
|
||||
expect((manual?.data as ManualCompactionChatData).compaction).toMatchObject({
|
||||
summary: 'manual summary',
|
||||
summaryEventSeq: 12,
|
||||
})
|
||||
const automatic = node(snapshot(compactions), 'compaction')
|
||||
expect(automatic?.data).toMatchObject({ summary: 'automatic summary', summaryEventSeq: 21 })
|
||||
expect(snapshot(compactions).nodes.values().filter(candidate => candidate.kind === 'compaction')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fills a landed compaction marker when an older page supplies its summary', () => {
|
||||
const value = assembler([
|
||||
at(13, 'user/message', {
|
||||
...textMessage('checkpoint', 'checkpoint'),
|
||||
source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-1' },
|
||||
}, { surfaceOp: { op: 'replace', start: 1, end: 8 } }),
|
||||
], true)
|
||||
const before = node(snapshot(value), 'compaction')
|
||||
expect(before?.data).toMatchObject({ summary: null, summaryEventSeq: null })
|
||||
|
||||
value.prepend([
|
||||
at(9, 'compact/start', { compactionId: 'compact-1', turn: null }),
|
||||
at(10, 'compact/summary', {
|
||||
compactionId: 'compact-1',
|
||||
summary: [
|
||||
{ type: 'text', text: 'older ' },
|
||||
{ type: 'image', data: 'ignored' },
|
||||
{ type: 'text', text: 'summary' },
|
||||
],
|
||||
shadowedSeqs: [1, 2, 3],
|
||||
shadowedTokenCount: 42,
|
||||
}),
|
||||
], false)
|
||||
value.flush()
|
||||
|
||||
const after = node(snapshot(value), 'compaction')
|
||||
expect(after?.key).toBe(before?.key)
|
||||
expect(after?.data).toMatchObject({
|
||||
summary: 'older summary',
|
||||
summaryEventSeq: 10,
|
||||
shadowedItemCount: 3,
|
||||
shadowedTokenCount: 42,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders a historical compaction when its start remains outside the loaded window', () => {
|
||||
const value = assembler([
|
||||
at(10, 'compact/summary', {
|
||||
compactionId: 'compact-windowed',
|
||||
summary: [{ type: 'text', text: 'loaded summary' }],
|
||||
shadowedSeqs: [1, 2, 3],
|
||||
shadowedTokenCount: 42,
|
||||
}),
|
||||
at(11, 'user/message', {
|
||||
...textMessage('checkpoint-windowed', 'checkpoint'),
|
||||
source: { kind: 'plugin', plugin: 'compact', compactionId: 'compact-windowed' },
|
||||
}, { surfaceOp: { op: 'replace', start: 1, end: 3 } }),
|
||||
], true)
|
||||
|
||||
expect(node(snapshot(value), 'compaction')?.data).toMatchObject({
|
||||
summary: 'loaded summary',
|
||||
summaryEventSeq: 10,
|
||||
shadowedItemCount: 3,
|
||||
shadowedTokenCount: 42,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores legacy compaction transactions without correlation ids', () => {
|
||||
const value = assembler([
|
||||
at(10, 'compact/start', { turn: null }),
|
||||
at(11, 'compact/end', { turn: null, error: 'This operation was aborted' }),
|
||||
at(20, 'compact/start', { turn: null }),
|
||||
at(21, 'compact/summary', {
|
||||
summary: [{ type: 'text', text: 'legacy summary' }],
|
||||
shadowedSeqs: [1, 2, 3],
|
||||
shadowedTokenCount: 42,
|
||||
}),
|
||||
at(22, 'user/message', {
|
||||
...textMessage('legacy-checkpoint', 'checkpoint'),
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, { surfaceOp: { op: 'replace', start: 1, end: 3 } }),
|
||||
at(23, 'compact/end', { turn: null }),
|
||||
], true)
|
||||
|
||||
expect(node(snapshot(value), 'compaction')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores legacy retry and code-dispatch events without correlation ids', () => {
|
||||
const value = assembler([
|
||||
at(10, 'llm/retry', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 10,
|
||||
failure: { code: 'TRANSPORT', message: 'first legacy retry' },
|
||||
}),
|
||||
at(11, 'llm/retry-started', { turn: 1, step: 1, retry: 1 }),
|
||||
at(20, 'llm/retry', {
|
||||
turn: 2,
|
||||
step: 1,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 10,
|
||||
failure: { code: 'TRANSPORT', message: 'second legacy retry' },
|
||||
}),
|
||||
at(30, 'tool/code-dispatch-start', {
|
||||
parentCallId: 'root',
|
||||
subCallId: 'child',
|
||||
name: 'legacy-subcall',
|
||||
arguments: {},
|
||||
}),
|
||||
at(31, 'tool/code-dispatch', {
|
||||
parentCallId: 'root',
|
||||
subCallId: 'child',
|
||||
name: 'legacy-subcall',
|
||||
arguments: {},
|
||||
content: [],
|
||||
}),
|
||||
], true)
|
||||
|
||||
expect(node(snapshot(value), 'model-retry')).toBeUndefined()
|
||||
expect(node(snapshot(value), 'tool-call')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('suppresses a turn error when the loaded tail contains only a later retry attempt', () => {
|
||||
const value = assembler([
|
||||
at(5, 'llm/retry', {
|
||||
retryId: 'retry-paged',
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: 2,
|
||||
maxRetries: 2,
|
||||
delayMs: 20,
|
||||
failure: { code: 'TRANSPORT', message: 'second' },
|
||||
}),
|
||||
at(6, 'step/end', { turn: 1, step: 1 }),
|
||||
at(7, 'turn/end', {
|
||||
turn: 1,
|
||||
reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } },
|
||||
}),
|
||||
], true)
|
||||
|
||||
expect(node(snapshot(value), 'model-retry')).toBeUndefined()
|
||||
expect(node(snapshot(value), 'turn-error')).toBeUndefined()
|
||||
|
||||
value.prepend([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'llm/retry', {
|
||||
retryId: 'retry-paged',
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'fake',
|
||||
mode: 'normal',
|
||||
policyKey: 'fake-normal',
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 10,
|
||||
failure: { code: 'TRANSPORT', message: 'first' },
|
||||
}),
|
||||
at(4, 'llm/retry-started', {
|
||||
retryId: 'retry-paged', turn: 1, step: 1, retry: 1,
|
||||
}),
|
||||
], false)
|
||||
value.flush()
|
||||
|
||||
const retry = node(snapshot(value), 'model-retry')
|
||||
expect((retry?.data as RetryChatData).attempts).toHaveLength(2)
|
||||
expect(node(snapshot(value), 'turn-error')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => {
|
||||
const value = assembler([
|
||||
at(12, 'tool/code-dispatch-start', {
|
||||
rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' },
|
||||
}),
|
||||
at(13, 'tool/code-dispatch', {
|
||||
rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read_file', arguments: { path: 'a' },
|
||||
isError: false, content: [{ type: 'text', text: 'child result' }],
|
||||
}),
|
||||
at(14, 'tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: toolResult('root', 'root result'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(20, 'compact/summary', {
|
||||
compactionId: 'manual-1',
|
||||
sourceCommandId: 'command-1',
|
||||
summary: [{ type: 'text', text: 'manual summary' }],
|
||||
shadowedSeqs: [1, 2],
|
||||
shadowedTokenCount: 100,
|
||||
}),
|
||||
at(21, 'user/message', {
|
||||
...textMessage('manual-checkpoint', 'checkpoint'),
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'compact',
|
||||
compactionId: 'manual-1',
|
||||
sourceCommandId: 'command-1',
|
||||
},
|
||||
}, { surfaceOp: { op: 'replace', start: 1, end: 2 } }),
|
||||
at(22, 'command/done', {
|
||||
commandId: 'command-1',
|
||||
kind: 'success',
|
||||
sourceEventSeq: 20,
|
||||
}),
|
||||
], true)
|
||||
|
||||
const tool = node(snapshot(value), 'tool-call')
|
||||
const root = (tool?.data as ToolChatData).root
|
||||
expect(root.subCalls).toHaveLength(1)
|
||||
expect(root.subCalls[0]).toMatchObject({ callId: 'child', kind: 'tool-result' })
|
||||
const manual = node(snapshot(value), 'manual-compaction')
|
||||
expect((manual?.data as ManualCompactionChatData)).toMatchObject({
|
||||
command: { commandId: 'command-1', name: 'compact', outcome: { kind: 'success' } },
|
||||
compaction: { summary: 'manual summary', summaryEventSeq: 20 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -15,6 +15,7 @@ import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/ch
|
||||
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
|
||||
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.ts'
|
||||
|
||||
// Mirrors the real lookup chain (conversation namespace, then common).
|
||||
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
|
||||
@@ -40,14 +41,15 @@ const SessionProviderStub: SessionProviderComponent = ({ children }) => children
|
||||
/** Observe the owner currency without importing the Tool details renderer. */
|
||||
function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotProps['renderSlot'] {
|
||||
return (_key, owner) => {
|
||||
owners?.push(owner as DetailsToolOwnerProps)
|
||||
owners?.push(owner as unknown as DetailsToolOwnerProps)
|
||||
return <div data-testid="tool-details-seat" />
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotBase(): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
@@ -69,11 +71,16 @@ describe('render branch tails', () => {
|
||||
it('StatsLine counts window nodes but drops every token group without a projection', () => {
|
||||
// Node `usage` is deliberately ignored: billing rides the durable
|
||||
// tokenUsage projection, so an absent projection leaves counts only.
|
||||
const nodes = [
|
||||
{ kind: 'assistant', seq: 1, time: 1, turn: 1, step: 1, blocks: [] },
|
||||
{ kind: 'assistant', seq: 2, time: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } },
|
||||
{ kind: 'assistant', seq: 3, time: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
|
||||
] as const
|
||||
const snap = {
|
||||
...snapshotBase(),
|
||||
chat: chatSnapshotFixture({ nodes }),
|
||||
nodes: [
|
||||
{ kind: 'assistant', seq: 1, turn: 1, step: 1, blocks: [] },
|
||||
{ kind: 'assistant', seq: 2, turn: 1, step: 2, blocks: [], usage: { inputTokens: 4, outputTokens: 6 } },
|
||||
{ kind: 'assistant', seq: 3, turn: 2, step: 1, blocks: [], usage: { inputTokens: 5 } },
|
||||
...nodes,
|
||||
],
|
||||
}
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
@@ -146,6 +153,7 @@ describe('render branch tails', () => {
|
||||
}],
|
||||
}],
|
||||
}]
|
||||
snap.chat = chatSnapshotFixture({ runningCalls: snap.runningCalls })
|
||||
const chat = createChatStore().create()
|
||||
chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget)
|
||||
const emptyList = createSnapshotStore<SessionListState>(
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -35,7 +35,8 @@ const SID = 's1' as SessionId
|
||||
|
||||
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -26,7 +26,8 @@ const SID = 's1' as SessionId
|
||||
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
|
||||
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
|
||||
const session = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
|
||||
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
|
||||
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { EMPTY_CHAT_SNAPSHOT, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
|
||||
@@ -112,7 +112,8 @@ async function scopedBench(register?: (slash: SlashService) => void) {
|
||||
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
|
||||
const wiring = shell
|
||||
const sessionStore = createSnapshotStore<ConversationSnapshot>({
|
||||
sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, QueuedMessage, SessionId, SessionListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -32,7 +33,8 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
|
||||
|
||||
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
|
||||
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -70,7 +70,8 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
|
||||
|
||||
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
|
||||
return {
|
||||
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT,
|
||||
nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [],
|
||||
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
|
||||
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
|
||||
promptError: null, blank: false, subagent: null, lastAgentError: null,
|
||||
|
||||
@@ -23,12 +23,24 @@
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/token-meter"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm-retry"
|
||||
},
|
||||
{
|
||||
"path": "../../plan/plan-mode"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user