mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(ui-conversation): fold turn process before final answer (#2547)
* feat(ui-conversation): fold turn process before final answer * fix(ui-chat): polish turn-process control row from review * test(web): drive preset slash catalog with gestures * fix(ui-chat): keep turn process order stable * fix(ui-chat): preserve prompt order after pagination Co-authored-by: Yif <877193178@qq.com>
This commit is contained in:
committed by
GitHub
co-authored by
Yif
parent
2c9c871eff
commit
8b09a0be52
@@ -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-chat/README.md
|
||||
README.md: cd69eb7fdf704bb0e138e1f2aad88782e26aeeff
|
||||
README.zh.md: 3e9d1ccab71f2516f92ec5402eac49baaaccdf77
|
||||
README.md: 5c7549da07e8101c6b08b4cad57afd5158980b7f
|
||||
README.zh.md: a558bf94f5436eebdf035ca7507ecd25ec3c8d8a
|
||||
|
||||
@@ -14,6 +14,7 @@ The browser Chat target for Conversation assembly. It registers Chat event defin
|
||||
|
||||
- [System prompt row](#system-prompt-row)
|
||||
- [Turn token usage](#turn-token-usage)
|
||||
- [Turn Process Folding](#turn-process-folding)
|
||||
- [Model Experience](#model-experience)
|
||||
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
||||
- [Dev Note](#dev-note)
|
||||
@@ -34,6 +35,13 @@ A completed Turn shows an expandable usage row only when the loaded window inclu
|
||||
|
||||
-----
|
||||
|
||||
<a id="turn-process-folding"></a>
|
||||
## Turn Process Folding
|
||||
|
||||
Settings → General exposes a persisted `Normal` / `Compact` conversation-display preference in the `ui-chat` namespace; `Compact` is the default. Normal leaves process rows visible and renders no Turn-process control. In Compact mode, the System prompt remains independently visible before the opening User throughout the Turn. Context injection, reasoning, Assistant material, Tool rows, and Retry rows remain expanded while a Turn is open. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block; preceding Context injection, reasoning, earlier Assistant material, Tool rows, and Retry rows then collapse by default. The control reports Turn-wide durable counts for non-subagent Tool calls, reply-bearing Assistant messages before the final answer, and subagent delegation calls; zero-valued segments are omitted, the Tool and subagent figures are mutually exclusive, and neither System prompt nor Context injection contributes a count. When all three counts are zero, the process still folds and the control reads `Thought for a while`. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, System prompt, error, max-token, and turn-tail rows stay outside, and a closed Turn with no final answer keeps all process evidence visible. A newly available process control is inserted without changing the relative order of existing rows: opening human input precedes the control and process rows from their first projection, while System prompt remains above that input. While older history remains available through Load earlier, process controls stay absent and no members are hidden; once history is complete, every eligible closed Turn uses the collapsed default immediately. Stable Chat Node Seats keep every renderer mounted, hidden members add no flow spacing, and a closed control sits 8px above its answer only when no independent input intervenes. Completion collapse does not depend on tail-follow position, so a reader above the tail may see the transcript reflow. An automatic collapse that would hide keyboard focus keeps the group open and leaves focus in place; a manual close focuses the process control before hiding its members. The session-scoped store records only manually expanded Turn-and-answer-Step generations; a different answer generation starts collapsed ([folding decision](../../../.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.md), [ordering decision](../../../.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.md)).
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ Conversation 组装的浏览器 Chat target。本包注册 Chat event definition
|
||||
|
||||
- [系统提示词行](#system-prompt-row)
|
||||
- [轮次 token 用量](#turn-token-usage)
|
||||
- [轮次过程折叠](#turn-process-folding)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与暂缓事项](#known-limitations-and-deferred-work)
|
||||
- [开发备注](#dev-note)
|
||||
@@ -34,6 +35,13 @@ Chat 会为每个非空的初始或恢复请求、显式消息序列起点或真
|
||||
|
||||
-----
|
||||
|
||||
<a id="turn-process-folding"></a>
|
||||
## 轮次过程折叠
|
||||
|
||||
「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的 `Normal` / `Compact` 对话显示偏好,默认使用 `Compact`。Normal 保持所有过程行可见且不渲染轮次过程控件。Compact 模式下,系统提示词在整个轮次中始终独立显示于开场 User 上方。轮次打开期间,上下文注入、推理、Assistant 内容、工具行与重试行始终展开。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终正文边界;边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件展示覆盖整个轮次的非 subagent 工具调用数、最终正文之前带回复内容的 Assistant 消息数和 subagent 委派数;值为 0 的分段省略,工具调用与 subagent 两项互斥,系统提示词与上下文注入都不增加计数。三项全为 0 时过程仍会收起,控件标题显示「已思考」(英文为 `Thought for a while`)。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、系统提示词、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终正文的轮次保留全部过程证据。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词则始终位于该输入上方。只要仍可通过「加载更早」获取历史,过程控件就不出现,也不会隐藏任何成员;历史加载完整后,每个合格的已关闭轮次立即使用默认收起状态。稳定 Chat Node Seat 会让每个 renderer 保持挂载,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 高度变化。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。会话作用域 store 只记录用户手动展开的「轮次 + 正文步骤」generation;不同正文 generation 默认收起([折叠决策](../../../.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.zh.md),[排序决策](../../../.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.zh.md))。
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-layout",
|
||||
"@deepseek-ai/dsh-client-ui-renderer",
|
||||
"@deepseek-ai/dsh-client-ui-session",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-ui-workspace"
|
||||
],
|
||||
"platform": "web"
|
||||
@@ -62,6 +63,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-compaction": "workspace:^",
|
||||
@@ -70,6 +72,7 @@
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-stats": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^"
|
||||
@@ -89,6 +92,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
@@ -98,12 +102,16 @@
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-stats": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Chat transcript preferences stored in the Host user-settings document. */
|
||||
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
|
||||
/** Settings namespace owned by the Chat target. */
|
||||
export const CHAT_SETTINGS_NAMESPACE = 'ui-chat'
|
||||
|
||||
/** Field carrying the completed-Turn transcript presentation mode. */
|
||||
export const TRANSCRIPT_VIEW_FIELD = 'transcriptView'
|
||||
|
||||
/** Transcript presentation modes accepted at settings boundaries. */
|
||||
export const TRANSCRIPT_VIEW_MODES = ['normal', 'compact'] as const
|
||||
|
||||
/** Completed-Turn transcript presentation. */
|
||||
export type TranscriptViewMode = typeof TRANSCRIPT_VIEW_MODES[number]
|
||||
|
||||
/** Default preserves the compact process disclosure introduced by Chat. */
|
||||
export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'compact'
|
||||
|
||||
/** Durable Chat section shared by the Host schema and browser scope. */
|
||||
export interface ChatSettings {
|
||||
/** Presentation mode for completed Turn process content. */
|
||||
transcriptView: TranscriptViewMode
|
||||
}
|
||||
|
||||
/** Durable Chat schema; also the wire envelope the browser scope validates against. */
|
||||
export const ChatSettingsSchema: z<ChatSettings> = z.object({
|
||||
[TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE),
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type {
|
||||
ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, DetailsInjected,
|
||||
@@ -25,7 +26,10 @@ import { StatsLine } from './chat/StatsLine.tsx'
|
||||
import { registerConversationNodes } from './conversation-nodes/register.ts'
|
||||
import { DetailsPanel } from './details/DetailsPanel.tsx'
|
||||
import { en, NS, zh } from './locale.ts'
|
||||
import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/TranscriptViewRow.tsx'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { TranscriptViewPolicy } from './transcript-view.ts'
|
||||
import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../chat-settings.ts'
|
||||
|
||||
const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
|
||||
hooks: {
|
||||
@@ -42,7 +46,7 @@ const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
|
||||
|
||||
/** Services required by the Chat target and its presentation registrations. */
|
||||
export const inject = [
|
||||
'slots', 'sessions', 'uiSession', 'uiConversation', 'uiWorkspace', 'layout', 'locale',
|
||||
'slots', 'sessions', 'uiSession', 'uiConversation', 'uiWorkspace', 'layout', 'locale', 'settingsScope',
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -74,6 +78,20 @@ export function apply(ctx: Context): void {
|
||||
const t = ctx.locale.bind(NS)
|
||||
const chatStore = createChatStore()
|
||||
const chatScrollPositions = new Map<SessionId, ChatScrollPosition>()
|
||||
const transcriptView = new TranscriptViewPolicy(
|
||||
ctx.settingsScope.bind<ChatSettings>({ namespace: CHAT_SETTINGS_NAMESPACE }),
|
||||
)
|
||||
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'transcript-view',
|
||||
order: 12,
|
||||
locale: NS,
|
||||
inject: (): TranscriptViewRowInjected => ({
|
||||
hooks: { transcriptView: transcriptView.mode },
|
||||
setTranscriptView: (mode) => { transcriptView.setMode(mode) },
|
||||
}),
|
||||
}, TranscriptViewRow))
|
||||
|
||||
ctx.slots.inject('conversation.view', () => {
|
||||
const disposeView = ctx.slots.register({
|
||||
@@ -91,6 +109,7 @@ export function apply(ctx: Context): void {
|
||||
const session = ctx.sessions.binding(sessionId)?.session
|
||||
if (session === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`)
|
||||
return {
|
||||
hooks: { transcriptView: transcriptView.mode },
|
||||
openDetails: (target) => {
|
||||
actions.select(target)
|
||||
ctx.layout.openDetails()
|
||||
|
||||
@@ -42,6 +42,13 @@
|
||||
padding-left: var(--dsh-table-lead);
|
||||
}
|
||||
|
||||
/* hidden="until-found" keeps a zero-height reasoning box in flex layout.
|
||||
Cancel the one gap that box would otherwise leave before the visible reply;
|
||||
visible Assistant blocks retain the ordinary 16px rhythm. */
|
||||
.body > [data-turn-process-inline][hidden] {
|
||||
margin-bottom: -16px;
|
||||
}
|
||||
|
||||
/* Interrupted-turn terminal marker: quiet inline tag, no animation. Fixed
|
||||
size like the small/code token variants — 11px is dense secondary text
|
||||
that would fall to an illegible 9px at the 12px floor. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts
|
||||
import type { AssistantBlock } from '../contract/snapshot.ts'
|
||||
import { markdownLabels } from '../markdown-labels.ts'
|
||||
import { ReasoningRow } from './ReasoningRow.tsx'
|
||||
import { useSearchableHidden } from './searchable-hidden.ts'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
|
||||
export interface AssistantMarkdownProps {
|
||||
@@ -15,6 +16,10 @@ export interface AssistantMarkdownProps {
|
||||
interrupted?: boolean | undefined
|
||||
/** Render consecutive image blocks through the attachment slot. */
|
||||
renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
|
||||
/** Hide reasoning that belongs to the Turn-level process disclosure. */
|
||||
reasoningHidden?: boolean | undefined
|
||||
/** Reveal the owning Turn-level process disclosure. */
|
||||
revealProcess?: (() => void) | 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. */
|
||||
@@ -23,7 +28,8 @@ export interface AssistantMarkdownProps {
|
||||
|
||||
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, renderMessageImages, mentions, t,
|
||||
blocks, streaming, interrupted, renderMessageImages,
|
||||
reasoningHidden = false, revealProcess, 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.
|
||||
@@ -53,7 +59,15 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
)
|
||||
break
|
||||
case 'reasoning':
|
||||
rendered.push(<ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />)
|
||||
rendered.push(
|
||||
<ProcessReasoning
|
||||
key={i}
|
||||
hidden={reasoningHidden}
|
||||
reveal={revealProcess}
|
||||
>
|
||||
<ReasoningRow text={block.text} running={streaming && i === last} t={t} />
|
||||
</ProcessReasoning>,
|
||||
)
|
||||
break
|
||||
case 'image': {
|
||||
// Consecutive image blocks share one gallery so several images tile
|
||||
@@ -102,3 +116,14 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function ProcessReasoning({ hidden, reveal, children }: {
|
||||
hidden: boolean
|
||||
reveal?: (() => void) | undefined
|
||||
children: ReactNode
|
||||
}) {
|
||||
const ref = useSearchableHidden(hidden, reveal ?? NOOP)
|
||||
return <div ref={ref} data-turn-process-inline={hidden || undefined}>{children}</div>
|
||||
}
|
||||
|
||||
const NOOP = (): void => {}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { memo, useCallback, 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, renderMessageImages, fileMentions, t,
|
||||
node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, t,
|
||||
}: ChatNodeViewProps<'assistant-step'>) {
|
||||
const data = node.data
|
||||
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
|
||||
@@ -20,12 +20,20 @@ export const AssistantNodeView = memo(function AssistantNodeView({
|
||||
() => owner === undefined ? undefined : fileMentions(owner),
|
||||
[fileMentions, owner],
|
||||
)
|
||||
const reasoningHidden = turnProcess !== undefined
|
||||
&& turnProcess.foldable
|
||||
&& turnProcess.spec.answerStep === data.step
|
||||
&& turnProcess.spec.inlineReasoning
|
||||
&& !turnProcess.open
|
||||
const revealProcess = useCallback(() => { turnProcess?.setOpen(true) }, [turnProcess])
|
||||
return (
|
||||
<AssistantMarkdown
|
||||
blocks={data.blocks}
|
||||
streaming={data.status === 'running'}
|
||||
interrupted={data.status === 'interrupted'}
|
||||
renderMessageImages={renderMessageImages}
|
||||
reasoningHidden={reasoningHidden}
|
||||
revealProcess={revealProcess}
|
||||
mentions={mentions}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { memo, useCallback, 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 type { ChatNodeStore } from '../contract/snapshot.ts'
|
||||
import {
|
||||
decodeTurnProcess, TURN_PROCESS_INDEPENDENT_KINDS, turnProcessGeneration,
|
||||
type TurnProcessSpec,
|
||||
} from '../contract/turn-process.ts'
|
||||
import { storedTurnProcessEntry } from '../stores.ts'
|
||||
import { useSearchableHidden } from './searchable-hidden.ts'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
interface ChatNodeSeatProps extends ChatNodeOwnerProps {
|
||||
readonly nodeKey: string
|
||||
readonly historyIncomplete: boolean
|
||||
readonly compactTranscript: boolean
|
||||
readonly useChat: ChatViewSlotProps['useChat']
|
||||
readonly useStore: ChatViewSlotProps['useStore']
|
||||
readonly actions: ChatViewSlotProps['actions']
|
||||
readonly renderSlot: ChatViewSlotProps['renderSlot']
|
||||
readonly t: ChatViewSlotProps['t']
|
||||
}
|
||||
@@ -15,13 +26,153 @@ type RoutedChatNodeOwner = {
|
||||
[Kind in ChatNode['kind']]: ChatNodeOwnerProps & { readonly node: ChatNode<Kind> }
|
||||
}[ChatNode['kind']]
|
||||
|
||||
/** Subscribe and dispatch one stable Context key without observing sibling Nodes. */
|
||||
const EMPTY_PROCESS_KEYS: readonly string[] = []
|
||||
|
||||
interface TurnProcessLayout {
|
||||
readonly hasExternalProcess: boolean
|
||||
readonly compactAnswer: boolean
|
||||
}
|
||||
|
||||
function turnProcessOpeningHumanAnchor(
|
||||
keys: readonly string[],
|
||||
nodes: ChatNodeStore,
|
||||
spec: TurnProcessSpec,
|
||||
): number | undefined {
|
||||
let anchor: number | undefined
|
||||
for (const key of keys) {
|
||||
const node = nodes.get(key) as ChatNode | undefined
|
||||
if ((node?.kind === 'user' || node?.kind === 'steering')
|
||||
&& node.anchorSeq < spec.controlAnchorSeq) {
|
||||
anchor = Math.min(anchor ?? node.anchorSeq, node.anchorSeq)
|
||||
}
|
||||
}
|
||||
return anchor
|
||||
}
|
||||
|
||||
/** Derive disclosure facts from one content-revisioned Turn index. */
|
||||
function turnProcessLayout(
|
||||
keys: readonly string[],
|
||||
nodes: ChatNodeStore,
|
||||
spec: TurnProcessSpec,
|
||||
): TurnProcessLayout {
|
||||
let hasExternalProcess = false
|
||||
let compactAnswer = true
|
||||
const openingHumanAnchor = turnProcessOpeningHumanAnchor(keys, nodes, spec)
|
||||
for (const key of keys) {
|
||||
const node = nodes.get(key) as ChatNode | undefined
|
||||
if (node === undefined || node.kind === 'turn-process') continue
|
||||
if ((node.kind === 'user' || node.kind === 'steering')
|
||||
&& (openingHumanAnchor === undefined || node.anchorSeq > openingHumanAnchor)
|
||||
&& (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq)) {
|
||||
compactAnswer = false
|
||||
}
|
||||
if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)
|
||||
|| node.anchorSeq < spec.processStartSeq
|
||||
|| (spec.answerAnchorSeq !== null && node.anchorSeq >= spec.answerAnchorSeq)) continue
|
||||
if (node.kind !== 'assistant-step' || spec.answerStep === null || node.data.step !== spec.answerStep) {
|
||||
hasExternalProcess = true
|
||||
}
|
||||
}
|
||||
return { hasExternalProcess, compactAnswer }
|
||||
}
|
||||
|
||||
/** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */
|
||||
export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt,
|
||||
renderMessageImages, fileMentions, useChat, renderSlot, t,
|
||||
nodeKey, historyIncomplete, compactTranscript,
|
||||
selectedCallId, cwd, openFile, inspectCall, forkAt,
|
||||
renderMessageImages, fileMentions, useChat, useStore, actions, renderSlot, t,
|
||||
}: ChatNodeSeatProps) {
|
||||
const node = useChat(snapshot => snapshot.nodes.get(nodeKey))
|
||||
const processSignature = useChat((snapshot) => {
|
||||
const current = snapshot.nodes.get(nodeKey)
|
||||
const location = current?.location
|
||||
return location?.kind === 'turn' || location?.kind === 'step'
|
||||
? location.turn.data.get('turn-process')
|
||||
: undefined
|
||||
})
|
||||
const processSpec = useMemo(
|
||||
() => processSignature === undefined ? undefined : decodeTurnProcess(processSignature),
|
||||
[processSignature],
|
||||
)
|
||||
const nodeStore = useChat(snapshot => snapshot.nodes)
|
||||
const processLayoutKeys = useChat((snapshot) => {
|
||||
if (!compactTranscript || historyIncomplete || processSpec === undefined) return EMPTY_PROCESS_KEYS
|
||||
const current = snapshot.nodes.get(nodeKey) as ChatNode | undefined
|
||||
const location = current?.location
|
||||
if (current === undefined
|
||||
|| (location?.kind !== 'turn' && location?.kind !== 'step')
|
||||
|| location.turn.status !== 'closed'
|
||||
|| location.turn.turn !== processSpec.turn) return EMPTY_PROCESS_KEYS
|
||||
const ownsLayout = current.kind === 'turn-process'
|
||||
|| (current.kind === 'assistant-step' && current.data.step === processSpec.answerStep)
|
||||
return ownsLayout ? snapshot.locations.getTurn(processSpec.turn) : EMPTY_PROCESS_KEYS
|
||||
})
|
||||
const processLayout = useMemo(
|
||||
() => processSpec === undefined || processLayoutKeys.length === 0
|
||||
? undefined
|
||||
: turnProcessLayout(processLayoutKeys, nodeStore, processSpec),
|
||||
[nodeStore, processLayoutKeys, processSpec],
|
||||
)
|
||||
const processGeneration = useMemo(
|
||||
() => processSpec === undefined ? undefined : turnProcessGeneration(processSpec),
|
||||
[processSpec],
|
||||
)
|
||||
const storedEntry = useStore(state => processSpec === undefined
|
||||
? undefined
|
||||
: storedTurnProcessEntry(state, processSpec.turn))
|
||||
const processEntry = storedEntry?.generation === processGeneration ? storedEntry : undefined
|
||||
const processOpen = processEntry !== undefined
|
||||
const setOpen = useCallback((open: boolean) => {
|
||||
if (processGeneration !== undefined && processSpec !== undefined) {
|
||||
actions.setTurnProcessOpen(processSpec.turn, processGeneration, open)
|
||||
}
|
||||
}, [actions, processGeneration, processSpec])
|
||||
const routedNode = node as ChatNode | undefined
|
||||
const sameTurn = routedNode !== undefined
|
||||
&& processSpec !== undefined
|
||||
&& (routedNode.location.kind === 'turn' || routedNode.location.kind === 'step')
|
||||
&& routedNode.location.turn.turn === processSpec.turn
|
||||
const turnClosed = sameTurn
|
||||
&& routedNode.location.turn.status === 'closed'
|
||||
const processWindowReady = processSpec !== undefined
|
||||
&& compactTranscript
|
||||
&& processSpec.answerAnchorSeq !== null
|
||||
&& turnClosed
|
||||
&& !historyIncomplete
|
||||
const processMember = sameTurn
|
||||
&& processWindowReady
|
||||
&& !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind)
|
||||
&& routedNode.anchorSeq >= processSpec.processStartSeq
|
||||
&& routedNode.anchorSeq < processSpec.answerAnchorSeq
|
||||
const processAnswer = sameTurn
|
||||
&& processWindowReady
|
||||
&& routedNode.kind === 'assistant-step'
|
||||
&& routedNode.data.step === processSpec.answerStep
|
||||
const ownsDisclosure = routedNode?.kind === 'turn-process' || processAnswer
|
||||
const foldable = processWindowReady
|
||||
&& (processMember || (ownsDisclosure
|
||||
&& ((processLayout?.hasExternalProcess ?? false) || processSpec.inlineReasoning)))
|
||||
const turnProcess = useMemo(() => processGeneration === undefined || processSpec === undefined
|
||||
? undefined
|
||||
: {
|
||||
spec: processSpec,
|
||||
foldable,
|
||||
open: processOpen,
|
||||
setOpen,
|
||||
}, [
|
||||
foldable, processGeneration, processOpen, processSpec, setOpen,
|
||||
])
|
||||
const controllerInactive = routedNode?.kind === 'turn-process'
|
||||
&& !foldable
|
||||
const compactAnswer = processAnswer
|
||||
&& foldable
|
||||
&& processLayout?.compactAnswer === true
|
||||
&& !processOpen
|
||||
const processHidden = controllerInactive || (foldable && processMember && !processOpen)
|
||||
const revealProcess = useCallback(() => {
|
||||
if (processMember) setOpen(true)
|
||||
}, [processMember, setOpen])
|
||||
const wrapperRef = useSearchableHidden(processHidden, revealProcess)
|
||||
const owner = useMemo<ChatNodeOwnerProps | null>(() => node === undefined
|
||||
? null
|
||||
: {
|
||||
@@ -32,8 +183,10 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
forkAt,
|
||||
renderMessageImages,
|
||||
fileMentions,
|
||||
turnProcess,
|
||||
}, [
|
||||
node, selectedCallId, cwd, openFile, inspectCall, forkAt, renderMessageImages, fileMentions,
|
||||
node, selectedCallId, cwd, openFile, inspectCall, forkAt,
|
||||
renderMessageImages, fileMentions, turnProcess,
|
||||
])
|
||||
if (routedNode === undefined || owner === null) return null
|
||||
const location = routedNode.location
|
||||
@@ -46,11 +199,15 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
|
||||
const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={css.flowItem}
|
||||
data-chat-anchor-key={routedNode.key}
|
||||
data-chat-flow-key={routedNode.key}
|
||||
data-chat-flow-kind={routedNode.kind}
|
||||
data-chat-turn={turn}
|
||||
data-turn-process-member={processMember || undefined}
|
||||
data-turn-process-hidden={processHidden || undefined}
|
||||
data-turn-process-answer={compactAnswer || undefined}
|
||||
>
|
||||
{renderSlot('conversation.chat.node', routedOwner, {
|
||||
entryKey: routedNode.kind,
|
||||
|
||||
@@ -41,7 +41,14 @@
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* `hidden="until-found"` retains a zero-height box so browser find can reveal
|
||||
its subtree. Direct Chat Node Seats and auxiliary rows share one flow;
|
||||
hidden and empty Seats do not contribute spacing. */
|
||||
.column > :not([hidden]):not(.flowItem:empty)
|
||||
~ :not([hidden]):not(.flowItem:empty) {
|
||||
margin-top: var(--dsh-chat-flow-gap, 16px);
|
||||
}
|
||||
|
||||
/* Settled-flow identity boundary. It is neutral until it becomes the natural
|
||||
@@ -50,6 +57,12 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* A closed process reads as one summary immediately followed by its answer.
|
||||
Expanded process rows return to the ordinary 16px rhythm. */
|
||||
.flowItem[data-turn-process-answer] {
|
||||
--dsh-chat-flow-gap: 8px;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
|
||||
@@ -30,7 +30,7 @@ interface PagingAnchor {
|
||||
|
||||
/** Find an already-rendered row without interpolating a selector. */
|
||||
function anchorElement(list: HTMLElement, key: string): HTMLElement | null {
|
||||
for (const row of list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')) {
|
||||
for (const row of list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]:not([hidden])')) {
|
||||
if (row.dataset.chatAnchorKey === key) return row
|
||||
}
|
||||
return null
|
||||
@@ -87,7 +87,9 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement |
|
||||
if (row !== null && list.contains(row)) return row
|
||||
}
|
||||
}
|
||||
const rows = list.querySelectorAll<HTMLElement>('[data-chat-flow] > [data-chat-flow-key]:not(:empty)')
|
||||
const rows = list.querySelectorAll<HTMLElement>(
|
||||
'[data-chat-flow] > [data-chat-flow-key]:not(:empty):not([hidden])',
|
||||
)
|
||||
let low = 0
|
||||
let high = rows.length
|
||||
while (low < high) {
|
||||
@@ -154,7 +156,7 @@ function observedRpcIds(
|
||||
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
|
||||
if (turn.status === 'open') latest = turn.start?.time ?? null
|
||||
}
|
||||
return latest
|
||||
}
|
||||
@@ -200,8 +202,8 @@ function TurnStatus({ startTime, t }: {
|
||||
* ordered business Node crosses the keyed renderer seat.
|
||||
*/
|
||||
export function ChatView({
|
||||
useSession, useChat, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt,
|
||||
fileMentions, t,
|
||||
useSession, useChat, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt,
|
||||
fileMentions, useTranscriptView, t,
|
||||
}: ChatViewSlotProps) {
|
||||
const order = useChat(s => s.order)
|
||||
const nodeStore = useChat(s => s.nodes)
|
||||
@@ -219,6 +221,7 @@ export function ChatView({
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
const loadingOlder = useSession(s => s.loadingOlder)
|
||||
const selectedCallId = useStore(s => s.selection?.callId)
|
||||
const compactTranscript = useTranscriptView(mode => mode === 'compact')
|
||||
const inspectCall = useCallback((callId: string) => {
|
||||
openView('trajectory', callId)
|
||||
}, [openView])
|
||||
@@ -278,8 +281,10 @@ export function ChatView({
|
||||
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
const columnRef = useRef<HTMLDivElement | null>(null)
|
||||
const atBottomRef = useRef(true)
|
||||
const [atBottom, setAtBottom] = useState(true)
|
||||
// A saved position starts disarmed; the first layout effect synchronously
|
||||
// restores it and normalizes a floor-clamped position back to following.
|
||||
const [atBottom, setAtBottom] = useState(() => chatScroll.read() === null)
|
||||
const atBottomRef = useRef(atBottom)
|
||||
const [activeTurn, setActiveTurn] = useState<number | null>(
|
||||
() => turnNavigationItems.at(-1)?.turn ?? null,
|
||||
)
|
||||
@@ -591,7 +596,11 @@ export function ChatView({
|
||||
<ChatNodeSeat
|
||||
key={nodeKey}
|
||||
nodeKey={nodeKey}
|
||||
historyIncomplete={hasMore}
|
||||
compactTranscript={compactTranscript}
|
||||
useChat={useChat}
|
||||
useStore={useStore}
|
||||
actions={actions}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
openFile={requestOpenFile}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
.root {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 33px;
|
||||
padding: 0 0 8px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.root:not([data-open]) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 100ms ease;
|
||||
}
|
||||
|
||||
.root[data-open] .chevron {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { memo } from 'react'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatNodeViewProps } from '../contract/slots.ts'
|
||||
import css from './TurnProcessNodeView.module.css'
|
||||
|
||||
/** Turn-level process disclosure controller. */
|
||||
export const TurnProcessNodeView = memo(function TurnProcessNodeView({
|
||||
node, turnProcess, t,
|
||||
}: ChatNodeViewProps<'turn-process'>) {
|
||||
if (turnProcess === undefined) throw new Error('turn-process node requires Turn process owner state')
|
||||
if (!turnProcess.foldable) return null
|
||||
const open = turnProcess.open
|
||||
const labels: string[] = []
|
||||
if (node.data.toolCallCount > 0) {
|
||||
labels.push(t(
|
||||
node.data.toolCallCount === 1
|
||||
? 'message.turnProcess.toolCalls.one'
|
||||
: 'message.turnProcess.toolCalls.other',
|
||||
{ count: node.data.toolCallCount },
|
||||
))
|
||||
}
|
||||
if (node.data.messageCount > 0) {
|
||||
labels.push(t(
|
||||
node.data.messageCount === 1
|
||||
? 'message.turnProcess.messages.one'
|
||||
: 'message.turnProcess.messages.other',
|
||||
{ count: node.data.messageCount },
|
||||
))
|
||||
}
|
||||
if (node.data.subagentCount > 0) {
|
||||
labels.push(t(
|
||||
node.data.subagentCount === 1
|
||||
? 'message.turnProcess.subagents.one'
|
||||
: 'message.turnProcess.subagents.other',
|
||||
{ count: node.data.subagentCount },
|
||||
))
|
||||
}
|
||||
const label = labels.length === 0
|
||||
? t('message.turnProcess.thoughtForAWhile')
|
||||
: labels.join(t('message.turnProcess.separator'))
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.root}
|
||||
data-open={open || undefined}
|
||||
data-turn-process={node.data.turn}
|
||||
data-turn-process-messages={node.data.messageCount}
|
||||
data-turn-process-tool-calls={node.data.toolCallCount}
|
||||
data-turn-process-subagents={node.data.subagentCount}
|
||||
aria-expanded={open}
|
||||
onClick={(event) => {
|
||||
event.currentTarget.focus()
|
||||
turnProcess.setOpen(!open)
|
||||
}}
|
||||
>
|
||||
<span className={css.label}>{label}</span>
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)
|
||||
})
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
|
||||
TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
|
||||
} from './MessageItem.tsx'
|
||||
import { TurnTailNodeView } from './TurnTailNodeView.tsx'
|
||||
import { SystemPromptNodeView } from './SystemPromptRow.tsx'
|
||||
import { TurnProcessNodeView } from './TurnProcessNodeView.tsx'
|
||||
import { TurnTailNodeView } from './TurnTailNodeView.tsx'
|
||||
|
||||
/**
|
||||
* Register this package's business renderers behind the keyed Chat Node seat.
|
||||
@@ -40,6 +41,8 @@ export function registerChatNodeRenderers(ctx: Context): void {
|
||||
{ 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-max-tokens', locale: NS }, TurnMaxTokensNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
|
||||
{ name: 'conversation.chat.node', key: 'turn-process', locale: NS }, TurnProcessNodeView))
|
||||
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'turn-tail',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* Apply searchable hidden state without unmounting a stable subtree.
|
||||
* @param hidden - whether the subtree is currently hidden.
|
||||
* @param reveal - callback for browser find's `beforematch` reveal.
|
||||
* @returns ref for the stable subtree root.
|
||||
*/
|
||||
export function useSearchableHidden(
|
||||
hidden: boolean,
|
||||
reveal: () => void,
|
||||
): RefObject<HTMLDivElement> {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useLayoutEffect(() => {
|
||||
const element = ref.current
|
||||
if (element === null) return
|
||||
if (hidden && element.contains(element.ownerDocument.activeElement)) {
|
||||
reveal()
|
||||
return
|
||||
}
|
||||
if (hidden) element.setAttribute('hidden', 'until-found')
|
||||
else element.removeAttribute('hidden')
|
||||
}, [hidden, reveal])
|
||||
useEffect(() => {
|
||||
const element = ref.current
|
||||
if (element === null) return
|
||||
element.addEventListener('beforematch', reveal)
|
||||
return () => { element.removeEventListener('beforematch', reveal) }
|
||||
}, [reveal])
|
||||
return ref
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { AssistantBlock } from './snapshot.ts'
|
||||
|
||||
/**
|
||||
* Test whether Assistant blocks contain a user-facing reply rather than only
|
||||
* reasoning or Tool-call protocol material.
|
||||
* @param blocks - Assistant content blocks.
|
||||
* @returns whether the blocks contain visible reply content.
|
||||
*/
|
||||
export function hasAssistantReplyContent(blocks: readonly AssistantBlock[]): boolean {
|
||||
return blocks.some((block) => {
|
||||
if (block.kind === 'reasoning' || block.kind === 'tool-call') return false
|
||||
if (block.kind === 'text') return block.text.trim() !== ''
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -97,6 +97,19 @@ export interface TurnTailChatData {
|
||||
readonly tokenUsage?: TurnTokenUsage
|
||||
}
|
||||
|
||||
/** Turn-level process disclosure projected before the finalized answer. */
|
||||
export interface TurnProcessChatData {
|
||||
readonly turn: number
|
||||
readonly controlAnchorSeq: number
|
||||
readonly processStartSeq: number
|
||||
readonly answerAnchorSeq: number | null
|
||||
readonly answerStep: number | null
|
||||
readonly inlineReasoning: boolean
|
||||
readonly messageCount: number
|
||||
readonly toolCallCount: number
|
||||
readonly subagentCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a Tool root has settled.
|
||||
* @param block - Tool root lifecycle value.
|
||||
|
||||
@@ -7,12 +7,15 @@ import type {
|
||||
InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory,
|
||||
SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
import type { ToolCallId, SelectionTarget } from './store.ts'
|
||||
import type { ChatNode, ChatNodeKind } from './chat-nodes.ts'
|
||||
import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } from './snapshot.ts'
|
||||
import type { TurnProcessSpec } from './turn-process.ts'
|
||||
import type { TranscriptViewMode } from '../../chat-settings.ts'
|
||||
|
||||
/** Selector hook over the current Conversation binding's Chat target. */
|
||||
export type UseChat = SnapshotSelectorHook<ChatSnapshot>
|
||||
@@ -65,6 +68,16 @@ export interface ChatNodeOwnerProps {
|
||||
forkAt: (seq: number) => void
|
||||
renderMessageImages: RenderMessageImages
|
||||
fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
|
||||
/** Turn-process state when this Node belongs to a projected Turn. */
|
||||
turnProcess?: TurnProcessOwnerProps | undefined
|
||||
}
|
||||
|
||||
/** Shared presentation state for one Turn-process answer generation. */
|
||||
export interface TurnProcessOwnerProps {
|
||||
readonly spec: TurnProcessSpec
|
||||
readonly foldable: boolean
|
||||
readonly open: boolean
|
||||
setOpen(open: boolean): void
|
||||
}
|
||||
|
||||
/** Full props of one keyed Chat renderer. */
|
||||
@@ -98,6 +111,10 @@ export interface ChatScrollPosition {
|
||||
|
||||
/** Business callbacks injected into the Chat view. */
|
||||
export interface ChatViewInjected {
|
||||
hooks: {
|
||||
/** Persisted completed-Turn transcript presentation. */
|
||||
transcriptView: SnapshotStore<TranscriptViewMode>
|
||||
}
|
||||
openDetails: (target: SelectionTarget) => void
|
||||
openFile: (path: string) => Promise<void>
|
||||
loadOlder: () => void
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Chat-owned selection state shared by the transcript and details panel. */
|
||||
|
||||
import type { TurnProcessGeneration } from './turn-process.ts'
|
||||
|
||||
/** Tool call identity as carried by Chat nodes. */
|
||||
export type ToolCallId = string
|
||||
|
||||
@@ -11,7 +13,14 @@ export interface SelectionTarget {
|
||||
toolName?: string
|
||||
}
|
||||
|
||||
/** One manually expanded Turn answer generation. */
|
||||
export interface TurnProcessViewEntry {
|
||||
readonly turn: number
|
||||
readonly generation: TurnProcessGeneration
|
||||
}
|
||||
|
||||
/** Per-Session state shared only by the Chat view and details surface. */
|
||||
export interface ChatStoreState {
|
||||
selection: SelectionTarget | null
|
||||
turnProcesses: TurnProcessViewEntry[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ChatNode } from './chat-nodes.ts'
|
||||
|
||||
/** Turn-local process window encoded as a reference-stable Location-data scalar. */
|
||||
export type TurnProcessSignature = string
|
||||
|
||||
/** Stable identity of one finalized answer generation, independent of its exact ordering anchor. */
|
||||
export type TurnProcessGeneration = string
|
||||
|
||||
/** Current process range and finalized answer boundary derived from one Turn. */
|
||||
export interface TurnProcessSpec {
|
||||
readonly turn: number
|
||||
/** Stable control-node anchor source, including currently ineligible evidence. */
|
||||
readonly controlAnchorSeq: number
|
||||
readonly processStartSeq: number
|
||||
readonly answerAnchorSeq: number | null
|
||||
readonly answerStep: number | null
|
||||
readonly inlineReasoning: boolean
|
||||
/** Reply-bearing durable Assistant messages before the final answer. */
|
||||
readonly messageCount: number
|
||||
/** Durable non-subagent Tool calls recorded by this Turn. */
|
||||
readonly toolCallCount: number
|
||||
/** Tool calls whose configured name identifies a subagent delegation. */
|
||||
readonly subagentCount: number
|
||||
}
|
||||
|
||||
const TURN_PROCESS_INDEPENDENT_KIND_LIST = [
|
||||
'system-prompt',
|
||||
'user',
|
||||
'steering',
|
||||
'turn-process',
|
||||
'turn-error',
|
||||
'turn-max-tokens',
|
||||
'turn-tail',
|
||||
] as const satisfies readonly ChatNode['kind'][]
|
||||
|
||||
/** Chat Node kinds that remain independent of a Turn's process disclosure. */
|
||||
export const TURN_PROCESS_INDEPENDENT_KINDS: ReadonlySet<string> = new Set(
|
||||
TURN_PROCESS_INDEPENDENT_KIND_LIST,
|
||||
)
|
||||
|
||||
/**
|
||||
* Identify one finalized answer generation without using its ordering anchor.
|
||||
* @param spec - current Turn process specification.
|
||||
* @returns stable identity until the finalized answer Step is withdrawn or replaced.
|
||||
*/
|
||||
export function turnProcessGeneration(spec: TurnProcessSpec): TurnProcessGeneration {
|
||||
return `${String(spec.turn)}|${spec.answerStep === null ? '' : String(spec.answerStep)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode one process specification as a primitive Location-data value.
|
||||
* @param spec - current Turn process specification.
|
||||
* @returns reference-stable scalar for equal specifications.
|
||||
*/
|
||||
export function encodeTurnProcess(spec: TurnProcessSpec): TurnProcessSignature {
|
||||
return [
|
||||
spec.turn,
|
||||
spec.controlAnchorSeq,
|
||||
spec.processStartSeq,
|
||||
spec.answerAnchorSeq ?? '',
|
||||
spec.answerStep ?? '',
|
||||
spec.inlineReasoning ? 1 : 0,
|
||||
spec.messageCount,
|
||||
spec.toolCallCount,
|
||||
spec.subagentCount,
|
||||
].join('|')
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a same-process signature produced by {@link encodeTurnProcess}.
|
||||
* @param signature - encoded Turn process value.
|
||||
* @returns decoded process specification.
|
||||
*/
|
||||
export function decodeTurnProcess(signature: TurnProcessSignature): TurnProcessSpec {
|
||||
const [
|
||||
turn, controlAnchorSeq, processStartSeq, answerAnchorSeq, answerStep, inlineReasoning,
|
||||
messageCount, toolCallCount, subagentCount,
|
||||
] = signature.split('|')
|
||||
return {
|
||||
turn: Number(turn),
|
||||
controlAnchorSeq: Number(controlAnchorSeq),
|
||||
processStartSeq: Number(processStartSeq),
|
||||
answerAnchorSeq: answerAnchorSeq === '' ? null : Number(answerAnchorSeq),
|
||||
answerStep: answerStep === '' ? null : Number(answerStep),
|
||||
inlineReasoning: inlineReasoning === '1',
|
||||
messageCount: Number(messageCount),
|
||||
toolCallCount: Number(toolCallCount),
|
||||
subagentCount: Number(subagentCount),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognize the shipped subagent delegation name and its configured variants.
|
||||
* Control tools use distinct names such as `send_message` and `list_agents`.
|
||||
* @param name - durable Tool-call name.
|
||||
* @returns whether the call creates or forks a subagent.
|
||||
*/
|
||||
export function isSubagentDelegationTool(name: string): boolean {
|
||||
return name === 'subagent' || name.startsWith('subagent_')
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ConversationNode,
|
||||
LegacyConversationSlice, PartialAssistant, RunningToolCall, TurnNavigationItem,
|
||||
} from '../contract/snapshot.ts'
|
||||
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
|
||||
import { sessionRecallLabels } from './event-projection.ts'
|
||||
import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts'
|
||||
|
||||
@@ -192,10 +193,101 @@ function locationCoordinates(location: ConversationLocation): { turn?: number; s
|
||||
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 TurnProcessPresentation {
|
||||
readonly control?: ChatNode<'turn-process'>
|
||||
readonly openingHumanAnchor?: number
|
||||
readonly earliestProcessAnchor?: number
|
||||
}
|
||||
|
||||
function turnProcessPresentations(
|
||||
nodes: readonly ChatConversationViewNode[],
|
||||
): ReadonlyMap<number, TurnProcessPresentation> {
|
||||
const presentations = new Map<number, TurnProcessPresentation>()
|
||||
for (const raw of nodes) {
|
||||
const node = raw as ChatNode
|
||||
if (node.kind === 'turn-process') {
|
||||
presentations.set(node.data.turn, { ...presentations.get(node.data.turn), control: node })
|
||||
}
|
||||
}
|
||||
for (const raw of nodes) {
|
||||
const node = raw as ChatNode
|
||||
const location = node.location
|
||||
if (location.kind !== 'turn' && location.kind !== 'step') continue
|
||||
const current: TurnProcessPresentation = presentations.get(location.turn.turn) ?? {}
|
||||
if ((node.kind === 'user' || node.kind === 'steering')
|
||||
&& node.anchorSeq < (current.control?.data.controlAnchorSeq ?? Number.POSITIVE_INFINITY)) {
|
||||
presentations.set(location.turn.turn, {
|
||||
...current,
|
||||
openingHumanAnchor: Math.min(current.openingHumanAnchor ?? node.anchorSeq, node.anchorSeq),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) continue
|
||||
presentations.set(location.turn.turn, {
|
||||
...current,
|
||||
earliestProcessAnchor: Math.min(current.earliestProcessAnchor ?? node.anchorSeq, node.anchorSeq),
|
||||
})
|
||||
}
|
||||
return presentations
|
||||
}
|
||||
|
||||
interface PresentationPosition {
|
||||
readonly anchor: number
|
||||
readonly rank: number
|
||||
readonly originalAnchor: number
|
||||
}
|
||||
|
||||
function presentationPosition(
|
||||
raw: ChatConversationViewNode,
|
||||
presentations: ReadonlyMap<number, TurnProcessPresentation>,
|
||||
): PresentationPosition {
|
||||
const node = raw as ChatNode
|
||||
const location = node.location
|
||||
if (location.kind !== 'turn' && location.kind !== 'step') {
|
||||
return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq }
|
||||
}
|
||||
const presentation = presentations.get(location.turn.turn)
|
||||
if (presentation === undefined) {
|
||||
return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq }
|
||||
}
|
||||
const openingHumanAnchor = presentation.openingHumanAnchor
|
||||
if (openingHumanAnchor !== undefined
|
||||
&& node.anchorSeq < openingHumanAnchor
|
||||
&& !TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) {
|
||||
return { anchor: openingHumanAnchor, rank: 2, originalAnchor: node.anchorSeq }
|
||||
}
|
||||
if (presentation.control !== undefined && node.key === presentation.control.key) {
|
||||
return openingHumanAnchor === undefined
|
||||
? {
|
||||
anchor: presentation.earliestProcessAnchor ?? node.anchorSeq,
|
||||
rank: -1,
|
||||
originalAnchor: node.anchorSeq,
|
||||
}
|
||||
: { anchor: openingHumanAnchor, rank: 1, originalAnchor: node.anchorSeq }
|
||||
}
|
||||
return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq }
|
||||
}
|
||||
|
||||
/**
|
||||
* Order visible Chat Nodes without changing existing relative order as process
|
||||
* eligibility changes. Opening human input precedes process candidates, while
|
||||
* each synthetic process control sits between them.
|
||||
* @param nodes - currently materialized Chat Nodes.
|
||||
* @returns visible Nodes in presentation order.
|
||||
*/
|
||||
export function orderedVisibleChatNodes(
|
||||
nodes: readonly ChatConversationViewNode[],
|
||||
): ChatConversationViewNode[] {
|
||||
const visible = nodes.filter(node => node.visibility === 'visible')
|
||||
const presentations = turnProcessPresentations(visible)
|
||||
return visible.sort((left, right) => {
|
||||
const leftPosition = presentationPosition(left, presentations)
|
||||
const rightPosition = presentationPosition(right, presentations)
|
||||
return leftPosition.anchor - rightPosition.anchor
|
||||
|| leftPosition.rank - rightPosition.rank
|
||||
|| leftPosition.originalAnchor - rightPosition.originalAnchor
|
||||
|| left.key.localeCompare(right.key)
|
||||
})
|
||||
}
|
||||
|
||||
function referenceMessageSeq(node: ChatConversationViewNode): number | undefined {
|
||||
@@ -556,7 +648,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
|
||||
}): ChatSnapshot {
|
||||
const nodes = this.referenceLabels.replace(input.nodes)
|
||||
this.store.replace(nodes)
|
||||
this.order = orderedVisible(nodes).map(node => node.key)
|
||||
this.order = orderedVisibleChatNodes(nodes).map(node => node.key)
|
||||
this.locations.rebuild(this.order, this.store)
|
||||
this.navigation.rebuild(input.timeline, this.locations, this.store)
|
||||
this.timeline = input.timeline
|
||||
@@ -573,6 +665,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
|
||||
for (const node of upserts) {
|
||||
const previous = this.store.get(node.key)
|
||||
const nodeStructural = previous === undefined
|
||||
|| previous.kind !== node.kind
|
||||
|| previous.anchorSeq !== node.anchorSeq
|
||||
|| previous.visibility !== node.visibility
|
||||
|| locationIdentity(previous.location) !== locationIdentity(node.location)
|
||||
@@ -581,7 +674,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
|
||||
}
|
||||
this.store.upsert(upserts)
|
||||
if (structural) {
|
||||
const next = orderedVisible(this.store.values()).map(node => node.key)
|
||||
const next = orderedVisibleChatNodes(this.store.values()).map(node => node.key)
|
||||
this.order = sameReferences(this.order, next) ? this.order : next
|
||||
this.locations.rebuild(this.order, this.store)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
export const CHAT_SYNTHETIC_SEQ_OFFSETS = {
|
||||
interruptedAssistant: -0.9,
|
||||
interruptedFollowup: -0.8,
|
||||
processControl: -0.1,
|
||||
maxTokensNotice: 0.05,
|
||||
finalizedFollowup: 0.1,
|
||||
} as const
|
||||
|
||||
@@ -11,6 +11,7 @@ import { registerRetryConversationNode } from './retry.ts'
|
||||
import { registerToolConversationNode } from './tool.ts'
|
||||
import { registerTurnErrorConversationNode } from './turn-error.ts'
|
||||
import { registerTurnMaxTokensConversationNode } from './turn-max-tokens.ts'
|
||||
import { registerTurnProcess } from './turn-process.ts'
|
||||
import { registerTurnTailConversationNode } from './turn-tail.ts'
|
||||
|
||||
/**
|
||||
@@ -22,6 +23,7 @@ export function registerConversationNodes(ctx: Context): void {
|
||||
registerMessageConversationNode(ctx)
|
||||
registerRequestPromptConversationNode(ctx)
|
||||
registerAssistantConversationNode(ctx)
|
||||
registerTurnProcess(ctx)
|
||||
registerToolConversationNode(ctx)
|
||||
registerCommandConversationNode(ctx)
|
||||
registerCompactionConversationNode(ctx)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {
|
||||
ConversationMatch, ConversationNodeDefinition, RequestPromptInspector,
|
||||
ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RequestPromptInspector,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
import { chatNode } from './common.ts'
|
||||
|
||||
declare module '../contract/chat-nodes.ts' {
|
||||
@@ -33,6 +34,19 @@ function requestPromptAnchor(
|
||||
: match.location.step.start?.seq ?? match.event.seq
|
||||
}
|
||||
|
||||
/** Keep an already rendered prompt at its page-lifetime presentation anchor. */
|
||||
function stableRequestPromptAnchor(
|
||||
context: ConversationNodeContext<RequestPromptState>,
|
||||
match: ConversationMatch,
|
||||
previous: Readonly<RequestPromptState> | undefined,
|
||||
isInitial: boolean,
|
||||
): number {
|
||||
const current = context.current.get('chat') as ChatNode | null | undefined
|
||||
return current?.kind === 'system-prompt'
|
||||
? current.anchorSeq
|
||||
: requestPromptAnchor(match, previous, isInitial)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request-header prompt Definition for the Chat target.
|
||||
* @param inspect - the shared prompt interpretation, supplied by the
|
||||
@@ -46,7 +60,7 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver
|
||||
match: event => event.type === 'request/header'
|
||||
? { id: String(event.seq), role: 'start' }
|
||||
: null,
|
||||
start: (_context, match, reader) => {
|
||||
start: (context, match, reader) => {
|
||||
if (match.event.type !== 'request/header') {
|
||||
throw new Error('request-prompt start requires request/header')
|
||||
}
|
||||
@@ -57,7 +71,12 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver
|
||||
const inspection = inspect(previous?.prompt, match.event)
|
||||
const change = inspection.change?.kind
|
||||
return {
|
||||
anchorSeq: requestPromptAnchor(match, previous, match.event.data.reason === 'initial'),
|
||||
anchorSeq: stableRequestPromptAnchor(
|
||||
context,
|
||||
match,
|
||||
previous,
|
||||
match.event.data.reason === 'initial',
|
||||
),
|
||||
showsPrompt: previous === undefined
|
||||
|| match.event.data.reason !== 'change'
|
||||
|| match.event.data.startsSeries === true
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ChunkRowEvent } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import type {
|
||||
ConversationLocation, ConversationNodeContext, ConversationNodeDefinition, TurnLocation,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry/types'
|
||||
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
|
||||
import type {} from '@deepseek-ai/dsh-tools/types'
|
||||
import { hasAssistantReplyContent } from '../contract/assistant-content.ts'
|
||||
import type { AssistantChatData, FinalAssistantChatData } from '../contract/chat-nodes.ts'
|
||||
import {
|
||||
decodeTurnProcess, encodeTurnProcess, isSubagentDelegationTool,
|
||||
type TurnProcessSignature, type TurnProcessSpec,
|
||||
} from '../contract/turn-process.ts'
|
||||
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
|
||||
import { toAssistantBlocks } from './event-projection.ts'
|
||||
|
||||
declare module '../contract/chat-nodes.ts' {
|
||||
interface ChatNodeDataMap {
|
||||
/** Turn-level disclosure controlling process rows before the finalized answer. */
|
||||
'turn-process': import('../contract/chat-nodes.ts').TurnProcessChatData
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ConversationTurnDataMap {
|
||||
/** Encoded process range and finalized answer boundary for this Turn. */
|
||||
'turn-process': TurnProcessSignature
|
||||
}
|
||||
}
|
||||
|
||||
interface TurnProcessState {
|
||||
readonly turn: number
|
||||
readonly assistantStartByStep: ReadonlyMap<number, number>
|
||||
readonly messageCountByStep: ReadonlyMap<number, number>
|
||||
readonly otherStartSeq?: number
|
||||
readonly toolCallCount: number
|
||||
readonly subagentCount: number
|
||||
}
|
||||
|
||||
type ConversationEvent = Parameters<ConversationNodeDefinition['match']>[0]
|
||||
|
||||
function isChunkRunEvent(event: ConversationEvent): event is ChunkRowEvent {
|
||||
return event.type === 'chunkrow/text-chunks'
|
||||
|| event.type === 'chunkrow/reasoning-chunks'
|
||||
|| event.type === 'chunkrow/tool-call-chunks'
|
||||
}
|
||||
|
||||
function eventTurn(event: ConversationEvent): number | undefined {
|
||||
const data = event.data as unknown as { turn?: unknown }
|
||||
return typeof data.turn === 'number' ? data.turn : undefined
|
||||
}
|
||||
|
||||
function visibleAssistantEvent(event: ConversationEvent): boolean {
|
||||
if (event.type === 'assistant/chunk') {
|
||||
const chunk = event.data.chunk
|
||||
if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') return chunk.text.trim() !== ''
|
||||
if (chunk.type === 'block-start') {
|
||||
return chunk.blockType !== 'text'
|
||||
&& chunk.blockType !== 'reasoning'
|
||||
&& chunk.blockType !== 'tool-call'
|
||||
}
|
||||
if (chunk.type !== 'block-end') return false
|
||||
const block = chunk.block
|
||||
if (block.type === 'tool-call') return false
|
||||
if (block.type === 'text' || block.type === 'reasoning') return block.text.trim() !== ''
|
||||
return true
|
||||
}
|
||||
return event.type === 'assistant/message'
|
||||
&& isAppendSurfaceEvent(event)
|
||||
&& toAssistantBlocks(event.data.message.content).some((block) => {
|
||||
if (block.kind === 'tool-call') return false
|
||||
if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
type ProcessEvidence =
|
||||
| { readonly kind: 'assistant'; readonly seq: number; readonly step: number }
|
||||
| { readonly kind: 'other'; readonly seq: number }
|
||||
|
||||
function processEvidence(event: ConversationEvent): ProcessEvidence | undefined {
|
||||
if (isChunkRunEvent(event)) {
|
||||
if (event.type === 'chunkrow/tool-call-chunks') return undefined
|
||||
const firstVisible = event.data.texts.findIndex(text => text.trim() !== '')
|
||||
return firstVisible < 0
|
||||
? undefined
|
||||
: { kind: 'assistant', seq: event.seq + firstVisible, step: event.data.step }
|
||||
}
|
||||
if (visibleAssistantEvent(event)) {
|
||||
if (event.type !== 'assistant/chunk' && event.type !== 'assistant/message') return undefined
|
||||
return { kind: 'assistant', seq: event.seq, step: event.data.step }
|
||||
}
|
||||
if (event.type === 'tool/call'
|
||||
|| (event.type === 'tool/result' && isAppendSurfaceEvent(event))
|
||||
|| event.type === 'llm/retry') return { kind: 'other', seq: event.seq }
|
||||
return undefined
|
||||
}
|
||||
|
||||
function turnLocation(context: ConversationNodeContext<TurnProcessState>): TurnLocation | undefined {
|
||||
const location: ConversationLocation | undefined = context.start?.location ?? context.matches.at(-1)?.location
|
||||
return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined
|
||||
}
|
||||
|
||||
function fallbackState(context: ConversationNodeContext<TurnProcessState>): TurnProcessState | undefined {
|
||||
const turn = context.matches.map(match => eventTurn(match.event)).find(candidate => candidate !== undefined)
|
||||
if (turn === undefined) return undefined
|
||||
let state: TurnProcessState = {
|
||||
turn,
|
||||
assistantStartByStep: new Map(),
|
||||
messageCountByStep: new Map(),
|
||||
toolCallCount: 0,
|
||||
subagentCount: 0,
|
||||
}
|
||||
for (const match of context.matches) state = updateProcessState(state, match.event)
|
||||
return state
|
||||
}
|
||||
|
||||
function isFinalAssistant(
|
||||
data: Readonly<AssistantChatData> | undefined,
|
||||
): data is Readonly<FinalAssistantChatData> {
|
||||
return data?.finalNode !== undefined
|
||||
}
|
||||
|
||||
function latestAnswer(turn: TurnLocation): Readonly<FinalAssistantChatData> | null {
|
||||
const latestStep = turn.steps.at(-1)
|
||||
const data: Readonly<AssistantChatData> | undefined = latestStep?.data.get('assistant-step')
|
||||
if (!isFinalAssistant(data) || !hasAssistantReplyContent(data.blocks)) return null
|
||||
return data.blocks.some(block => block.kind === 'tool-call') ? null : data
|
||||
}
|
||||
|
||||
function processSpec(state: TurnProcessState, turn: TurnLocation): TurnProcessSpec | null {
|
||||
const controlAnchorSeq = Math.min(
|
||||
state.otherStartSeq ?? Number.POSITIVE_INFINITY,
|
||||
...state.assistantStartByStep.values(),
|
||||
)
|
||||
if (!Number.isFinite(controlAnchorSeq)) return null
|
||||
const answer = latestAnswer(turn)
|
||||
const counts = {
|
||||
messageCount: answer === null
|
||||
? [...state.messageCountByStep.values()].reduce((total, count) => total + count, 0)
|
||||
: [...state.messageCountByStep]
|
||||
.filter(([step]) => step < answer.step)
|
||||
.reduce((total, [, count]) => total + count, 0),
|
||||
toolCallCount: state.toolCallCount,
|
||||
subagentCount: state.subagentCount,
|
||||
}
|
||||
if (answer === null) {
|
||||
return {
|
||||
turn: turn.turn,
|
||||
controlAnchorSeq,
|
||||
processStartSeq: controlAnchorSeq,
|
||||
answerAnchorSeq: null,
|
||||
answerStep: null,
|
||||
inlineReasoning: false,
|
||||
...counts,
|
||||
}
|
||||
}
|
||||
const inlineReasoning = answer.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '')
|
||||
const earlierAssistantSeq = Math.min(
|
||||
...[...state.assistantStartByStep]
|
||||
.filter(([step]) => step < answer.step)
|
||||
.map(([, seq]) => seq),
|
||||
)
|
||||
const externalProcessSeq = Math.min(
|
||||
state.otherStartSeq ?? Number.POSITIVE_INFINITY,
|
||||
earlierAssistantSeq,
|
||||
)
|
||||
return {
|
||||
turn: turn.turn,
|
||||
controlAnchorSeq,
|
||||
processStartSeq: turn.start?.seq
|
||||
?? (Number.isFinite(externalProcessSeq) ? externalProcessSeq : answer.finalNode.seq),
|
||||
answerAnchorSeq: answer.finalNode.seq,
|
||||
answerStep: answer.step,
|
||||
inlineReasoning,
|
||||
...counts,
|
||||
}
|
||||
}
|
||||
|
||||
function updateProcessState(state: TurnProcessState, event: ConversationEvent): TurnProcessState {
|
||||
let current = state
|
||||
if (event.type === 'assistant/message'
|
||||
&& isAppendSurfaceEvent(event)
|
||||
&& hasAssistantReplyContent(toAssistantBlocks(event.data.message.content))) {
|
||||
const messageCountByStep = new Map(current.messageCountByStep)
|
||||
messageCountByStep.set(event.data.step, (messageCountByStep.get(event.data.step) ?? 0) + 1)
|
||||
current = { ...current, messageCountByStep }
|
||||
}
|
||||
if (event.type === 'tool/call') {
|
||||
const subagent = isSubagentDelegationTool(event.data.name)
|
||||
current = {
|
||||
...current,
|
||||
toolCallCount: current.toolCallCount + (subagent ? 0 : 1),
|
||||
subagentCount: current.subagentCount + (subagent ? 1 : 0),
|
||||
}
|
||||
}
|
||||
const evidence = processEvidence(event)
|
||||
if (evidence === undefined) return current
|
||||
if (evidence.kind === 'other') {
|
||||
return current.otherStartSeq === undefined ? { ...current, otherStartSeq: evidence.seq } : current
|
||||
}
|
||||
if (current.assistantStartByStep.has(evidence.step)) return current
|
||||
const assistantStartByStep = new Map(current.assistantStartByStep)
|
||||
assistantStartByStep.set(evidence.step, evidence.seq)
|
||||
return { ...current, assistantStartByStep }
|
||||
}
|
||||
|
||||
/** Turn-scoped process range and answer-boundary Definition. */
|
||||
export const turnProcessDefinition: ConversationNodeDefinition<TurnProcessState> = {
|
||||
kind: 'turn-process',
|
||||
target: 'chat',
|
||||
match: (event) => {
|
||||
if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
|
||||
const turn = eventTurn(event)
|
||||
if (turn === undefined) return null
|
||||
if (event.type === 'assistant/chunk'
|
||||
|| event.type === 'assistant/message'
|
||||
|| isChunkRunEvent(event)
|
||||
|| event.type === 'tool/call'
|
||||
|| event.type === 'tool/result'
|
||||
|| event.type === 'llm/retry'
|
||||
|| event.type === 'step/start'
|
||||
|| event.type === 'step/end'
|
||||
|| event.type === 'turn/end') {
|
||||
return { id: String(turn), role: 'update' }
|
||||
}
|
||||
return null
|
||||
},
|
||||
start: (_context, match) => {
|
||||
if (match.event.type !== 'turn/start') throw new Error('turn-process start requires turn/start')
|
||||
return {
|
||||
turn: match.event.data.turn,
|
||||
assistantStartByStep: new Map(),
|
||||
messageCountByStep: new Map(),
|
||||
toolCallCount: 0,
|
||||
subagentCount: 0,
|
||||
}
|
||||
},
|
||||
update: (context, match) => updateProcessState(context.state, match.event),
|
||||
publication: (match) => {
|
||||
if (isChunkRunEvent(match.event)) return 'animation-frame'
|
||||
if (match.event.type === 'assistant/chunk') {
|
||||
const type = match.event.data.chunk.type
|
||||
return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'
|
||||
}
|
||||
return 'immediate'
|
||||
},
|
||||
buildLocationData: (context, scope) => {
|
||||
if (scope !== 'turn') return null
|
||||
const state = context.state ?? fallbackState(context)
|
||||
if (state === undefined) return null
|
||||
const turn = turnLocation(context)
|
||||
if (turn === undefined) return null
|
||||
const spec = processSpec(state, turn)
|
||||
return spec === null ? null : {
|
||||
kind: 'turn',
|
||||
turn: turn.turn,
|
||||
key: 'turn-process',
|
||||
value: encodeTurnProcess(spec),
|
||||
}
|
||||
},
|
||||
buildViewNode: (context) => {
|
||||
const turn = turnLocation(context)
|
||||
const signature = turn?.data.get('turn-process')
|
||||
if (turn === undefined || signature === undefined) return null
|
||||
const data = decodeTurnProcess(signature)
|
||||
return chatNode(
|
||||
context,
|
||||
'turn-process',
|
||||
data.controlAnchorSeq + CHAT_SYNTHETIC_SEQ_OFFSETS.processControl,
|
||||
data,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Turn-scoped process disclosure projection.
|
||||
* @param ctx - owning UI Conversation context.
|
||||
*/
|
||||
export function registerTurnProcess(ctx: Context): void {
|
||||
ctx.uiConversation.events.register(turnProcessDefinition)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ 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-max-tokens.ts'
|
||||
export type {} from './conversation-nodes/turn-process.ts'
|
||||
export type {} from './conversation-nodes/turn-tail.ts'
|
||||
|
||||
export type {
|
||||
@@ -23,15 +24,21 @@ export type {
|
||||
export type {
|
||||
AssistantChatData, ChatConversationViewNode, ChatNode, ChatNodeKind,
|
||||
FinalAssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData,
|
||||
TurnTailChatData,
|
||||
TurnProcessChatData, TurnTailChatData,
|
||||
} from './contract/chat-nodes.ts'
|
||||
export type { ToolCallId, ChatStoreState, SelectionTarget } from './contract/store.ts'
|
||||
export type { ChatStoreState, SelectionTarget, ToolCallId, TurnProcessViewEntry } from './contract/store.ts'
|
||||
export type { TranscriptViewRowInjected, TranscriptViewRowProps } from './settings/TranscriptViewRow.tsx'
|
||||
export type { TranscriptViewMode } from '../chat-settings.ts'
|
||||
export type {
|
||||
AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected,
|
||||
ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps,
|
||||
CommandRowOwnerProps, CommandRowProps, DetailsInjected, DetailsSlotProps,
|
||||
DetailsToolOwnerProps, MessageImagesProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData,
|
||||
DetailsToolOwnerProps, MessageImagesProps,
|
||||
TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData,
|
||||
} from './contract/slots.ts'
|
||||
export type {
|
||||
TurnProcessGeneration, TurnProcessSignature, TurnProcessSpec,
|
||||
} from './contract/turn-process.ts'
|
||||
export type { ChatKey } from './locale.ts'
|
||||
export type { ConversationContext, ConversationContextOriginKind } from './model/conversation-context.ts'
|
||||
export type {
|
||||
|
||||
@@ -32,6 +32,10 @@ export const zh = {
|
||||
'chat.turnNavigation.label': '轮次导航',
|
||||
'chat.turnNavigation.jump': '跳转到第 {turn} 轮',
|
||||
'chat.turnNavigation.turn': '第 {turn} 轮',
|
||||
'settings.transcript.title': '对话显示',
|
||||
'settings.transcript.description': '控制已完成轮次的过程内容',
|
||||
'settings.transcript.normal': 'Normal',
|
||||
'settings.transcript.compact': 'Compact',
|
||||
'fileOpen.title': '无法打开文件',
|
||||
'fileOpen.unknown': '无法打开此文件',
|
||||
'fileOpen.folderTitle': '无法打开文件夹',
|
||||
@@ -61,6 +65,14 @@ export const zh = {
|
||||
'message.think': '思考',
|
||||
'message.unknownSurface': '未知 surface 事件:{type}',
|
||||
'message.unknownBlock': '未知内容块',
|
||||
'message.turnProcess.toolCalls.one': '{count} 次工具调用',
|
||||
'message.turnProcess.toolCalls.other': '{count} 次工具调用',
|
||||
'message.turnProcess.messages.one': '{count} 条消息',
|
||||
'message.turnProcess.messages.other': '{count} 条消息',
|
||||
'message.turnProcess.subagents.one': '{count} 个 subagent',
|
||||
'message.turnProcess.subagents.other': '{count} 个 subagent',
|
||||
'message.turnProcess.thoughtForAWhile': '已思考',
|
||||
'message.turnProcess.separator': ' · ',
|
||||
'message.stopped': '已停止',
|
||||
'message.branch': '在新对话中分支',
|
||||
'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支',
|
||||
@@ -133,6 +145,10 @@ export const en = {
|
||||
'chat.turnNavigation.label': 'Turn navigation',
|
||||
'chat.turnNavigation.jump': 'Jump to turn {turn}',
|
||||
'chat.turnNavigation.turn': 'Turn {turn}',
|
||||
'settings.transcript.title': 'Conversation display',
|
||||
'settings.transcript.description': 'Controls process content in completed turns',
|
||||
'settings.transcript.normal': 'Normal',
|
||||
'settings.transcript.compact': 'Compact',
|
||||
'fileOpen.title': 'Couldn’t open file',
|
||||
'fileOpen.unknown': 'Couldn’t open this file',
|
||||
'fileOpen.folderTitle': 'Couldn’t open folder',
|
||||
@@ -162,6 +178,14 @@ export const en = {
|
||||
'message.think': 'Think',
|
||||
'message.unknownSurface': 'Unknown surface event: {type}',
|
||||
'message.unknownBlock': 'Unknown content block',
|
||||
'message.turnProcess.toolCalls.one': '{count} tool call',
|
||||
'message.turnProcess.toolCalls.other': '{count} tool calls',
|
||||
'message.turnProcess.messages.one': '{count} message',
|
||||
'message.turnProcess.messages.other': '{count} messages',
|
||||
'message.turnProcess.subagents.one': '{count} subagent',
|
||||
'message.turnProcess.subagents.other': '{count} subagents',
|
||||
'message.turnProcess.thoughtForAWhile': 'Thought for a while',
|
||||
'message.turnProcess.separator': ' · ',
|
||||
'message.stopped': 'Stopped',
|
||||
'message.branch': 'Branch into a new conversation',
|
||||
'message.branchUnavailable': 'Available only on the last message of a completed turn',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/* Completed-Turn transcript preference row: label plus selector pill. */
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.rowText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-right: 48px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.selector {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selector:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/** General Settings row for completed-Turn transcript presentation. */
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { TranscriptViewMode } from '../../chat-settings.ts'
|
||||
import type { ChatKey } from '../locale.ts'
|
||||
import css from './TranscriptViewRow.module.css'
|
||||
|
||||
/** Registration-side transcript preference face. */
|
||||
export interface TranscriptViewRowInjected {
|
||||
hooks: {
|
||||
/** Persisted transcript preference bound as useTranscriptView. */
|
||||
transcriptView: SnapshotStore<TranscriptViewMode>
|
||||
}
|
||||
/** Change the completed-Turn transcript presentation. */
|
||||
setTranscriptView: (mode: TranscriptViewMode) => void
|
||||
}
|
||||
|
||||
/** Full Settings-row props. */
|
||||
export type TranscriptViewRowProps =
|
||||
PropsRuntime<'settings.general.item'>
|
||||
& PropsLocale<'chat'>
|
||||
& InjectFace<TranscriptViewRowInjected>
|
||||
|
||||
const OPTIONS: readonly { id: TranscriptViewMode; label: ChatKey }[] = [
|
||||
{ id: 'normal', label: 'settings.transcript.normal' },
|
||||
{ id: 'compact', label: 'settings.transcript.compact' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Render the completed-Turn transcript mode selector.
|
||||
* @param props - composed Settings slot props.
|
||||
* @returns the preference row.
|
||||
*/
|
||||
export function TranscriptViewRow({ useTranscriptView, setTranscriptView, t }: TranscriptViewRowProps) {
|
||||
const mode = useTranscriptView(value => value)
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedLabel = mode === 'normal'
|
||||
? 'settings.transcript.normal'
|
||||
: 'settings.transcript.compact'
|
||||
const closeMenu = () => { setOpen(false) }
|
||||
const selectMode = (id: string) => {
|
||||
closeMenu()
|
||||
setTranscriptView(id as TranscriptViewMode)
|
||||
}
|
||||
const selector = (
|
||||
<button
|
||||
type="button"
|
||||
className={css.selector}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
{t(selectedLabel)}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css.row}>
|
||||
<div className={css.rowText}>
|
||||
<div className={css.title}>{t('settings.transcript.title')}</div>
|
||||
<div className={css.desc}>{t('settings.transcript.description')}</div>
|
||||
</div>
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={closeMenu}
|
||||
items={OPTIONS.map(option => ({ id: option.id, label: t(option.label) }))}
|
||||
selectedId={mode}
|
||||
onSelect={selectMode}
|
||||
align="end"
|
||||
portal
|
||||
anchor={selector}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,29 @@
|
||||
/** Per-Session Chat selection store shared by the transcript and details panel. */
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ChatStoreState, SelectionTarget } from './contract/store.ts'
|
||||
import type { TurnProcessGeneration } from './contract/turn-process.ts'
|
||||
import type { ChatStoreState, SelectionTarget, TurnProcessViewEntry } from './contract/store.ts'
|
||||
|
||||
type ChatActions = {
|
||||
select: (draft: ChatStoreState, target: SelectionTarget | null) => void
|
||||
setTurnProcessOpen: (
|
||||
draft: ChatStoreState,
|
||||
turn: number,
|
||||
generation: TurnProcessGeneration,
|
||||
open: boolean,
|
||||
) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve any stored generation for one Turn.
|
||||
* @param state - Chat store snapshot.
|
||||
* @param turn - owning Turn.
|
||||
* @returns the Turn's stored entry, when present.
|
||||
*/
|
||||
export function storedTurnProcessEntry(
|
||||
state: Readonly<ChatStoreState>,
|
||||
turn: number,
|
||||
): Readonly<TurnProcessViewEntry> | undefined {
|
||||
return state.turnProcesses.find(entry => entry.turn === turn)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -12,9 +32,19 @@ type ChatActions = {
|
||||
*/
|
||||
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
|
||||
return defineStore({
|
||||
init: (): ChatStoreState => ({ selection: null }),
|
||||
init: (): ChatStoreState => ({ selection: null, turnProcesses: [] }),
|
||||
actions: {
|
||||
select: (draft, target: SelectionTarget | null) => { draft.selection = target },
|
||||
setTurnProcessOpen: (draft, turn, generation, open) => {
|
||||
const index = draft.turnProcesses.findIndex(entry => entry.turn === turn)
|
||||
if (!open) {
|
||||
if (index >= 0) draft.turnProcesses.splice(index, 1)
|
||||
return
|
||||
}
|
||||
const next = { turn, generation } satisfies TurnProcessViewEntry
|
||||
if (index < 0) draft.turnProcesses.push(next)
|
||||
else draft.turnProcesses[index] = next
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Host-backed completed-Turn transcript presentation policy. */
|
||||
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import {
|
||||
DEFAULT_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD,
|
||||
type ChatSettings, type TranscriptViewMode,
|
||||
} from '../chat-settings.ts'
|
||||
|
||||
/** Live transcript preference consumed by Chat and its Settings row. */
|
||||
export class TranscriptViewPolicy {
|
||||
/** Reactive current mode; defaults to Compact before Host settings arrive. */
|
||||
readonly mode: SnapshotStore<TranscriptViewMode> = createSnapshotStore(DEFAULT_TRANSCRIPT_VIEW_MODE)
|
||||
|
||||
/**
|
||||
* @param host - durable Chat settings scope.
|
||||
*/
|
||||
constructor(private readonly host: SettingsScope<ChatSettings>) {
|
||||
host.subscribe(() => { this.adopt() })
|
||||
this.adopt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish and persist one explicit user choice.
|
||||
* @param mode - Normal or Compact transcript presentation.
|
||||
*/
|
||||
setMode(mode: TranscriptViewMode): void {
|
||||
if (this.mode.getSnapshot() === mode) return
|
||||
this.mode.set(mode)
|
||||
void this.host.set(TRANSCRIPT_VIEW_FIELD, mode)
|
||||
}
|
||||
|
||||
/** Adopt the latest accepted Host section without writing it back. */
|
||||
private adopt(): void {
|
||||
const section = this.host.getSnapshot().value
|
||||
if (section === undefined || this.mode.getSnapshot() === section.transcriptView) return
|
||||
this.mode.set(section.transcriptView)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,20 @@
|
||||
/** Host loader entry for the browser-only Chat UI target. */
|
||||
/** Host registration for browser Chat preferences. */
|
||||
|
||||
/** Provides no Host-side behavior. */
|
||||
export function apply(): void {}
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { CHAT_SETTINGS_NAMESPACE, ChatSettingsSchema } from './chat-settings.ts'
|
||||
|
||||
export {
|
||||
CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD,
|
||||
TRANSCRIPT_VIEW_MODES, type ChatSettings, type TranscriptViewMode,
|
||||
} from './chat-settings.ts'
|
||||
|
||||
/** Register the durable Chat settings section when a provider exists. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.inject(['settings'], (settingsCtx) => {
|
||||
settingsCtx.settings.register(
|
||||
settingsNamespace(CHAT_SETTINGS_NAMESPACE),
|
||||
ChatSettingsSchema,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,9 +61,9 @@ describe('ApprovalCommand', () => {
|
||||
})
|
||||
|
||||
describe('ui-chat package entries', () => {
|
||||
it('keeps the Host half inert and registers the invariant companion', async () => {
|
||||
expect(() => { nodeApply() }).not.toThrow()
|
||||
it('keeps the Host half optional and registers the invariant companion', async () => {
|
||||
const ctx = new Context()
|
||||
expect(() => { nodeApply(ctx) }).not.toThrow()
|
||||
await ctx.plugin(InvariantRegistry, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(ChatInvariant).await()).resolves.toBeDefined()
|
||||
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
apply as applyChat, EMPTY_CHAT_SNAPSHOT, inject as injectChat,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type {
|
||||
ChatNodeTurnDataInjected, ChatSnapshot, UseChat,
|
||||
ChatNodeTurnDataInjected, ChatSnapshot, TranscriptViewRowInjected, UseChat,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ConversationTurnDataMap {
|
||||
@@ -30,7 +31,12 @@ const SID = 'session-1' as SessionId
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
const chatSettings = stubSettingsScope<ChatSettings>()
|
||||
runtime.ctx.provide('settingsScope', {
|
||||
bind: ({ namespace }: { namespace: string }) => namespace === CHAT_SETTINGS_NAMESPACE
|
||||
? chatSettings.scope
|
||||
: stubSettingsScope().scope,
|
||||
} as never)
|
||||
runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() } as never)
|
||||
runtime.ctx.provide('uiWorkspace', {
|
||||
connectWorkspace: vi.fn(async () => SID),
|
||||
@@ -53,7 +59,7 @@ async function bench() {
|
||||
const chat = await runtime.mount({ inject: [...injectChat], apply: applyChat })
|
||||
const sourceDescriptor = provide.mock.calls[0]?.[0]
|
||||
if (sourceDescriptor === undefined) throw new Error('ui-chat did not provide its standard source')
|
||||
return { runtime, conversation, chat, sourceDescriptor }
|
||||
return { runtime, conversation, chat, chatSettings, sourceDescriptor }
|
||||
}
|
||||
|
||||
function storeOf(runtime: SlotTestRuntime, key: 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') {
|
||||
@@ -70,10 +76,30 @@ describe('Chat apply wiring', () => {
|
||||
.toMatchObject({ kind: 'keyed', scope: 'session' })
|
||||
expect(b.runtime.slots.entries('conversation.composer.dock').map(row => row.options.id))
|
||||
.toEqual(['stats'])
|
||||
expect(b.runtime.slots.entries('settings.general.item').map(row => row.options.id))
|
||||
.toEqual(['transcript-view', 'composer-enter'])
|
||||
expect(b.runtime.slots.entries('details')).toHaveLength(1)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('mirrors the Host transcript preference into its Settings row', async () => {
|
||||
const b = await bench()
|
||||
const row = b.runtime.slots.entries('settings.general.item')
|
||||
.find(entry => entry.options.id === 'transcript-view')!
|
||||
const face = (row.inject as unknown as () => TranscriptViewRowInjected)()
|
||||
|
||||
expect(face.hooks.transcriptView.getSnapshot()).toBe('compact')
|
||||
face.setTranscriptView('normal')
|
||||
expect(face.hooks.transcriptView.getSnapshot()).toBe('normal')
|
||||
expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'normal')
|
||||
|
||||
b.chatSettings.publish({
|
||||
status: 'ready', value: { transcriptView: 'compact' }, revision: 1, writable: true,
|
||||
})
|
||||
expect(face.hooks.transcriptView.getSnapshot()).toBe('compact')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('shares one Chat store while keeping it distinct from Conversation state', async () => {
|
||||
const b = await bench()
|
||||
const conversationStore = storeOf(b.runtime, 'conversation.session')
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SettingsProvider, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, apply,
|
||||
} from '../src/index.ts'
|
||||
|
||||
class MemorySettings extends SettingsProvider {
|
||||
readonly writable = true
|
||||
protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
|
||||
protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-chat Host settings', () => {
|
||||
it('registers, validates, and disposes the transcript-view namespace', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(MemorySettings).await()
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
const ns = settingsNamespace(CHAT_SETTINGS_NAMESPACE)
|
||||
|
||||
expect(ctx.settings.get(ns)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE })
|
||||
await ctx.settings.update(ns, { transcriptView: 'normal' })
|
||||
expect(ctx.settings.get(ns)).toEqual({ transcriptView: 'normal' })
|
||||
await expect(ctx.settings.update(ns, { transcriptView: 'dense' })).rejects.toThrow()
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
|
||||
})
|
||||
|
||||
it('loads without a settings provider', async () => {
|
||||
const ctx = new Context()
|
||||
await expect(ctx.plugin({ apply }).await()).resolves.toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode,
|
||||
ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, LegacyConversationSlice,
|
||||
PartialAssistant, RunningToolCall, ToolCallBlock, TurnNavigationItem,
|
||||
AssistantChatData, AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode,
|
||||
ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, FinalAssistantChatData,
|
||||
LegacyConversationSlice, PartialAssistant, RunningToolCall, ToolCallBlock, TurnNavigationItem,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type {
|
||||
ConversationLocationDataStore, ConversationTurnDataMap, TurnLocation,
|
||||
@@ -10,6 +10,12 @@ import { deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts'
|
||||
import {
|
||||
sameTurnNavigationItem, turnNavigationItem,
|
||||
} from '../src/client/conversation-nodes/turn-navigation.ts'
|
||||
import { orderedVisibleChatNodes } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
|
||||
import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts'
|
||||
import {
|
||||
encodeTurnProcess, isSubagentDelegationTool, TURN_PROCESS_INDEPENDENT_KINDS,
|
||||
type TurnProcessSpec,
|
||||
} from '../src/client/contract/turn-process.ts'
|
||||
|
||||
const EMPTY: readonly never[] = []
|
||||
|
||||
@@ -17,17 +23,45 @@ function sameValues<T>(left: readonly T[], right: readonly T[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function sameFixtureLocation(
|
||||
left: ChatConversationViewNode['location'],
|
||||
right: ChatConversationViewNode['location'],
|
||||
): boolean {
|
||||
if (left.kind !== right.kind) return false
|
||||
if (left.kind === 'session' || left.kind === 'unresolved') return true
|
||||
if (right.kind === 'session' || right.kind === 'unresolved') return false
|
||||
if (left.turn.turn !== right.turn.turn
|
||||
|| left.turn.status !== right.turn.status
|
||||
|| left.turn.start !== right.turn.start
|
||||
|| left.turn.end !== right.turn.end
|
||||
|| left.turn.data !== right.turn.data) return false
|
||||
if (left.kind === 'turn' || right.kind === 'turn') return left.kind === right.kind
|
||||
return left.step.step === right.step.step
|
||||
&& left.step.status === right.step.status
|
||||
&& left.step.start === right.step.start
|
||||
&& left.step.end === right.step.end
|
||||
&& left.step.data === right.step.data
|
||||
}
|
||||
|
||||
function nodeSource(node: ChatConversationViewNode): unknown {
|
||||
if (node.kind === 'assistant-step') {
|
||||
const data = node.data as ReturnType<typeof assistantData>
|
||||
const data = node.data as AssistantChatData
|
||||
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
|
||||
if (node.kind === 'turn-process') {
|
||||
const data = node.data as TurnProcessSpec
|
||||
return encodeTurnProcess(data)
|
||||
}
|
||||
return node.data
|
||||
}
|
||||
|
||||
function toolCallName(call: ToolCallBlock): string | null {
|
||||
return 'name' in call ? call.name : call.call?.name ?? null
|
||||
}
|
||||
|
||||
class FixtureNodeStore implements ChatNodeStore {
|
||||
private byKey = new Map<string, ChatConversationViewNode>()
|
||||
private list: readonly ChatConversationViewNode[] = EMPTY
|
||||
@@ -47,6 +81,7 @@ class FixtureNodeStore implements ChatNodeStore {
|
||||
const node = previous !== undefined
|
||||
&& previous.kind === candidate.kind
|
||||
&& previous.anchorSeq === candidate.anchorSeq
|
||||
&& sameFixtureLocation(previous.location, candidate.location)
|
||||
&& previous.visibility === candidate.visibility
|
||||
&& nodeSource(previous) === nodeSource(candidate)
|
||||
? previous
|
||||
@@ -97,7 +132,7 @@ class FixtureTurnDataStore implements ConversationLocationDataStore<Conversation
|
||||
}
|
||||
}
|
||||
|
||||
function assistantData(node: AssistantMessageNode) {
|
||||
function assistantData(node: AssistantMessageNode): FinalAssistantChatData {
|
||||
return {
|
||||
status: node.interrupted === true ? 'interrupted' as const : 'settled' as const,
|
||||
turn: node.turn,
|
||||
@@ -111,8 +146,10 @@ function assistantData(node: AssistantMessageNode) {
|
||||
function settledNode(
|
||||
node: ConversationNode,
|
||||
turns: ReadonlyMap<number, TurnLocation>,
|
||||
inferredTurn?: number,
|
||||
): ChatConversationViewNode {
|
||||
const turn = 'turn' in node && typeof node.turn === 'number' ? turns.get(node.turn) : undefined
|
||||
const ownTurn = 'turn' in node && typeof node.turn === 'number' ? node.turn : inferredTurn
|
||||
const turn = ownTurn === undefined ? undefined : turns.get(ownTurn)
|
||||
const base = {
|
||||
key: `fixture:${node.kind}:${node.seq}`,
|
||||
id: String(node.seq),
|
||||
@@ -161,7 +198,8 @@ export function chatSnapshotFixture(input: {
|
||||
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()
|
||||
const previousData = previous?.timeline.turns.get(turn)?.data
|
||||
const data = previousData instanceof FixtureTurnDataStore ? previousData : new FixtureTurnDataStore()
|
||||
turnData.set(turn, data)
|
||||
turns.set(turn, {
|
||||
turn,
|
||||
@@ -177,7 +215,7 @@ export function chatSnapshotFixture(input: {
|
||||
})
|
||||
}
|
||||
const linkedCompactions = new Set<CompactionSummaryNode>()
|
||||
const nodes = legacy.nodes.flatMap((node): ChatConversationViewNode[] => {
|
||||
const nodes = legacy.nodes.flatMap((node, index): ChatConversationViewNode[] => {
|
||||
if (node.kind === 'command' && node.name === 'compact') {
|
||||
const sourceSeq = node.outcome?.kind === 'success' ? node.outcome.sourceEventSeq : undefined
|
||||
const candidates = sourceSeq === undefined
|
||||
@@ -198,7 +236,12 @@ export function chatSnapshotFixture(input: {
|
||||
}
|
||||
}
|
||||
if (node.kind === 'compaction' && linkedCompactions.has(node)) return []
|
||||
return [settledNode(node, turns)]
|
||||
const inferredTurn = node.kind === 'tool-result'
|
||||
? legacy.nodes.slice(0, index).findLast(
|
||||
(candidate): candidate is AssistantMessageNode => candidate.kind === 'assistant',
|
||||
)?.turn
|
||||
: undefined
|
||||
return [settledNode(node, turns, inferredTurn)]
|
||||
})
|
||||
if (legacy.partial !== null) {
|
||||
const turn = turns.get(legacy.partial.turn)
|
||||
@@ -232,6 +275,75 @@ export function chatSnapshotFixture(input: {
|
||||
data: { root: call },
|
||||
})
|
||||
}
|
||||
for (const [turnNumber, dataStore] of turnData) {
|
||||
const inTurn = nodes.filter((candidate) => {
|
||||
const location = candidate.location
|
||||
return (location.kind === 'turn' || location.kind === 'step') && location.turn.turn === turnNumber
|
||||
})
|
||||
const assistants = inTurn
|
||||
.filter(candidate => candidate.kind === 'assistant-step')
|
||||
.map(candidate => candidate.data as AssistantChatData)
|
||||
const toolCalls = inTurn
|
||||
.filter(candidate => candidate.kind === 'tool-call')
|
||||
.map(candidate => (candidate.data as { readonly root: ToolCallBlock }).root)
|
||||
const latestStep = Math.max(
|
||||
0,
|
||||
...assistants.map(candidate => candidate.step),
|
||||
...inTurn.flatMap((candidate) => {
|
||||
if (candidate.kind !== 'tool-call') return []
|
||||
const root = (candidate.data as { root: ToolCallBlock }).root as ToolCallBlock & { step?: unknown }
|
||||
const step: unknown = root.step
|
||||
return typeof step === 'number' ? [step] : []
|
||||
}),
|
||||
)
|
||||
const answer = assistants.findLast((candidate): candidate is FinalAssistantChatData =>
|
||||
candidate.step === latestStep
|
||||
&& candidate.finalNode !== undefined
|
||||
&& hasAssistantReplyContent(candidate.blocks)
|
||||
&& !candidate.blocks.some(block => block.kind === 'tool-call'))
|
||||
const controlAnchor = inTurn.find(candidate => candidate.kind === 'assistant-step'
|
||||
|| candidate.kind === 'tool-call'
|
||||
|| candidate.kind === 'model-retry')
|
||||
if (controlAnchor === undefined) continue
|
||||
const processStart = inTurn.find(candidate => !TURN_PROCESS_INDEPENDENT_KINDS.has(candidate.kind))
|
||||
?? controlAnchor
|
||||
const inlineReasoning = answer?.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') === true
|
||||
const spec: TurnProcessSpec = {
|
||||
turn: turnNumber,
|
||||
controlAnchorSeq: controlAnchor.anchorSeq,
|
||||
processStartSeq: processStart.anchorSeq,
|
||||
answerAnchorSeq: answer?.finalNode.seq ?? null,
|
||||
answerStep: answer?.step ?? null,
|
||||
inlineReasoning: answer !== undefined && inlineReasoning,
|
||||
messageCount: answer === undefined
|
||||
? assistants.filter(candidate => hasAssistantReplyContent(candidate.blocks)).length
|
||||
: assistants.filter(candidate => candidate.step < answer.step
|
||||
&& hasAssistantReplyContent(candidate.blocks)).length,
|
||||
toolCallCount: toolCalls.filter((call) => {
|
||||
const name = toolCallName(call)
|
||||
return name === null || !isSubagentDelegationTool(name)
|
||||
}).length,
|
||||
subagentCount: toolCalls.filter((call) => {
|
||||
const name = toolCallName(call)
|
||||
return name !== null && isSubagentDelegationTool(name)
|
||||
}).length,
|
||||
}
|
||||
dataStore.set('turn-process', encodeTurnProcess(spec))
|
||||
const turn = turns.get(turnNumber)
|
||||
if (turn !== undefined) {
|
||||
nodes.push({
|
||||
key: `fixture:turn-process:${String(turnNumber)}`,
|
||||
id: String(turnNumber),
|
||||
target: 'chat',
|
||||
kind: 'turn-process',
|
||||
anchorSeq: spec.controlAnchorSeq - 0.1,
|
||||
location: { kind: 'turn', turn },
|
||||
visibility: 'visible',
|
||||
data: spec,
|
||||
})
|
||||
}
|
||||
}
|
||||
nodes.sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key))
|
||||
for (const [turnNumber, endSeq] of legacy.turnEnds) {
|
||||
const turn = turns.get(turnNumber)
|
||||
const dataStore = turnData.get(turnNumber)
|
||||
@@ -271,10 +383,12 @@ export function chatSnapshotFixture(input: {
|
||||
data: tailData,
|
||||
})
|
||||
}
|
||||
nodes.sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key))
|
||||
const ordered = orderedVisibleChatNodes(nodes)
|
||||
const store = previous?.nodes instanceof FixtureNodeStore ? previous.nodes : new FixtureNodeStore()
|
||||
store.replace(nodes)
|
||||
store.replace(ordered)
|
||||
const byKey = new Map(store.values().map(node => [node.key, node]))
|
||||
const nextOrder = nodes.map(node => node.key)
|
||||
const nextOrder = ordered.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()) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createChatStore } from '../src/client/stores.ts'
|
||||
describe('createChatStore', () => {
|
||||
it('starts without a selected Chat target', () => {
|
||||
const store = createChatStore().create()
|
||||
expect(store.store.getSnapshot()).toEqual({ selection: null })
|
||||
expect(store.store.getSnapshot()).toEqual({ selection: null, turnProcesses: [] })
|
||||
})
|
||||
|
||||
it('selects and clears one Chat details target', () => {
|
||||
@@ -23,4 +23,27 @@ describe('createChatStore', () => {
|
||||
first.actions.select({ turnSeq: 1 })
|
||||
expect(second.store.getSnapshot().selection).toBeNull()
|
||||
})
|
||||
|
||||
it('stores only manually expanded Turn-process generations', () => {
|
||||
const store = createChatStore().create()
|
||||
store.actions.setTurnProcessOpen(2, '2|3', true)
|
||||
expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 2, generation: '2|3' }])
|
||||
|
||||
store.actions.setTurnProcessOpen(2, '2|4', true)
|
||||
expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 2, generation: '2|4' }])
|
||||
|
||||
store.actions.setTurnProcessOpen(2, '2|4', false)
|
||||
expect(store.store.getSnapshot().turnProcesses).toEqual([])
|
||||
})
|
||||
|
||||
it('closes only the requested Turn-process entry', () => {
|
||||
const store = createChatStore().create()
|
||||
store.actions.setTurnProcessOpen(2, '2|3', true)
|
||||
store.actions.setTurnProcessOpen(3, '3|4', true)
|
||||
|
||||
store.actions.setTurnProcessOpen(2, '2|3', false)
|
||||
store.actions.setTurnProcessOpen(9, '9|10', false)
|
||||
|
||||
expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 3, generation: '3|4' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,10 +5,10 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi
|
||||
import { useEffect } from 'react'
|
||||
import type {
|
||||
AssistantMessageNode, ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatSnapshot,
|
||||
ChatViewSlotProps, CommandNode, CompactionSummaryNode, ConversationNode,
|
||||
LegacyConversationSlice, ModelRetryNode, RunningToolCall, SelectionTarget,
|
||||
ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode,
|
||||
UseChatNodeTurnData, UserMessageNode,
|
||||
ChatViewSlotProps, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode,
|
||||
LegacyConversationSlice, ModelRetryNode, RunningToolCall, SelectionTarget, SteeringMessageNode,
|
||||
ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UseChatNodeTurnData,
|
||||
TranscriptViewMode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type {
|
||||
SessionListState, SessionSnapshot,
|
||||
@@ -23,6 +23,7 @@ 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 { ChatNodeSeat } from '../src/client/chat/ChatNodeSeat.tsx'
|
||||
import { zh } from '../src/client/locale.ts'
|
||||
import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx'
|
||||
import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx'
|
||||
@@ -31,7 +32,11 @@ import {
|
||||
TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
|
||||
} from '../src/client/chat/MessageItem.tsx'
|
||||
import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx'
|
||||
import { TurnProcessNodeView } from '../src/client/chat/TurnProcessNodeView.tsx'
|
||||
import { SystemPromptNodeView } from '../src/client/chat/SystemPromptRow.tsx'
|
||||
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
|
||||
import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
|
||||
import { encodeTurnProcess } from '../src/client/contract/turn-process.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
|
||||
|
||||
afterEach(() => {
|
||||
@@ -88,6 +93,7 @@ function makeSessionSource(init: Partial<SessionSnapshot> = {}) {
|
||||
}
|
||||
|
||||
type ChatSlice = Partial<LegacyConversationSlice>
|
||||
type HarnessUpdate = ChatSlice & Partial<SessionSnapshot> & { readonly chat?: ChatSnapshot }
|
||||
|
||||
/** Scripted Chat target source, independent from Session lifecycle state. */
|
||||
function makeChatSource(init: ChatSlice = {}, snapshot?: ChatSnapshot) {
|
||||
@@ -98,6 +104,10 @@ function makeChatSource(init: ChatSlice = {}, snapshot?: ChatSnapshot) {
|
||||
snap = chatSnapshotFixture({ ...snap.legacy, ...next }, snap)
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
replace: (next: ChatSnapshot) => {
|
||||
snap = next
|
||||
for (const fn of [...subs]) fn()
|
||||
},
|
||||
source: {
|
||||
getSnapshot: () => snap,
|
||||
subscribe: (fn: () => void) => {
|
||||
@@ -121,8 +131,20 @@ const userInTurn = (seq: number, text: string, turn: number): ConversationNode =
|
||||
// accepts the extra coordinate so component tests can build the same view.
|
||||
turn,
|
||||
} as unknown as ConversationNode)
|
||||
const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
|
||||
const assistant = (seq: number, text: string, turn = 1, step = 1): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'text', text }],
|
||||
})
|
||||
const reasoningAssistant = (seq: number, text: string, turn = 1, step = 1): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'reasoning', text }],
|
||||
})
|
||||
const context = (seq: number, text: string, turn?: number): ContextMessageNode & { turn?: number } => ({
|
||||
kind: 'context', seq, time: seq * 1_000, content: [{ type: 'text', text }], source: null,
|
||||
provenance: { role: 'inject', label: null }, form: null,
|
||||
...(turn === undefined ? {} : { turn }),
|
||||
})
|
||||
const steering = (seq: number, text: string, turn: number): SteeringMessageNode & { turn: number } => ({
|
||||
kind: 'steering', messageId: `steering-${String(seq)}` as SteeringMessageNode['messageId'],
|
||||
seq, time: seq * 1_000, turn, content: [{ type: 'text', text }], source: null,
|
||||
})
|
||||
const retry = (seq: number): ModelRetryNode => ({
|
||||
kind: 'model-retry', retryId: 'chat-view-retry' as ModelRetryNode['retryId'],
|
||||
@@ -178,12 +200,23 @@ function emptyWorkspaces() {
|
||||
}
|
||||
|
||||
function makeHarness(
|
||||
chatSlice: ChatSlice = {},
|
||||
sessionInit: Partial<SessionSnapshot> = {},
|
||||
init: HarnessUpdate = {},
|
||||
sessionOverrides: Partial<SessionSnapshot> = {},
|
||||
chatSnapshot?: ChatSnapshot,
|
||||
) {
|
||||
const session = makeSessionSource(sessionInit)
|
||||
const chatSource = makeChatSource(chatSlice, chatSnapshot)
|
||||
const {
|
||||
chat: initialChat, nodes, partial, runningCalls, turnTimings, turnEnds,
|
||||
...sessionInit
|
||||
} = init
|
||||
const chatSlice: ChatSlice = {
|
||||
...(nodes === undefined ? {} : { nodes }),
|
||||
...(partial === undefined ? {} : { partial }),
|
||||
...(runningCalls === undefined ? {} : { runningCalls }),
|
||||
...(turnTimings === undefined ? {} : { turnTimings }),
|
||||
...(turnEnds === undefined ? {} : { turnEnds }),
|
||||
}
|
||||
const session = makeSessionSource({ ...sessionInit, ...sessionOverrides })
|
||||
const chatSource = makeChatSource(chatSlice, initialChat ?? chatSnapshot)
|
||||
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
||||
const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
|
||||
const loadOlder = vi.fn()
|
||||
@@ -197,6 +230,7 @@ function makeHarness(
|
||||
const forkAt = vi.fn()
|
||||
// Rows and the harness must observe the same chat-store instance.
|
||||
const chat = createChatStore().create()
|
||||
const transcriptView = createSnapshotStore<TranscriptViewMode>('compact')
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
const toolOwners: Array<{
|
||||
callId: string
|
||||
@@ -212,10 +246,12 @@ function makeHarness(
|
||||
React.ComponentProps<typeof TurnTailNodeView>['renderSlotChain']
|
||||
const renderTurnTailSlot = (() => null) as unknown as
|
||||
React.ComponentProps<typeof TurnTailNodeView>['renderSlot']
|
||||
const renderSlot = ((key: string, owner: object, opts?: {
|
||||
let nodeSlotOverride: React.ComponentProps<typeof ChatNodeSeat>['renderSlot'] | undefined
|
||||
const renderNodeSlot = ((key: string, owner: object, opts?: {
|
||||
fallback?: React.ReactNode
|
||||
hookContext?: unknown
|
||||
}) => {
|
||||
if (nodeSlotOverride !== undefined) return nodeSlotOverride(key as never, owner as never, opts as never)
|
||||
if (key !== 'conversation.chat.node') return opts?.fallback ?? null
|
||||
const nodeOwner = owner as RoutedChatNodeOwner
|
||||
const nodeKey = opts?.hookContext as string | undefined
|
||||
@@ -254,6 +290,10 @@ function makeHarness(
|
||||
return <TurnErrorNodeView {...nodeProps<'turn-error'>()} />
|
||||
case 'turn-max-tokens':
|
||||
return <TurnMaxTokensNodeView {...nodeProps<'turn-max-tokens'>()} />
|
||||
case 'turn-process':
|
||||
return <TurnProcessNodeView {...nodeProps<'turn-process'>()} />
|
||||
case 'system-prompt':
|
||||
return <SystemPromptNodeView {...nodeProps<'system-prompt'>()} />
|
||||
case 'turn-tail':
|
||||
return (
|
||||
<TurnTailNodeView
|
||||
@@ -290,7 +330,8 @@ function makeHarness(
|
||||
default:
|
||||
return opts?.fallback ?? null
|
||||
}
|
||||
}) as unknown as ChatViewSlotProps['renderSlot']
|
||||
}) as unknown as React.ComponentProps<typeof ChatNodeSeat>['renderSlot']
|
||||
const renderSlot = renderNodeSlot
|
||||
// SessionProvider seat arrives with the session-scope child declaration;
|
||||
// ChatView never invokes it (pass-through stub).
|
||||
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children}</>
|
||||
@@ -316,6 +357,7 @@ function makeHarness(
|
||||
},
|
||||
useStore: bindSnapshotSelector(chat),
|
||||
actions: chat.actions,
|
||||
useTranscriptView: bindSnapshotSelector(transcriptView),
|
||||
renderSlot,
|
||||
SessionProvider: SessionProviderStub,
|
||||
viewRequest: null,
|
||||
@@ -331,11 +373,33 @@ function makeHarness(
|
||||
fileMentions: () => undefined,
|
||||
t,
|
||||
}
|
||||
const set = (next: HarnessUpdate): void => {
|
||||
const {
|
||||
chat: explicitChat, nodes, partial, runningCalls, turnTimings, turnEnds,
|
||||
...sessionUpdate
|
||||
} = next
|
||||
if (explicitChat !== undefined) chatSource.replace(explicitChat)
|
||||
else if (nodes !== undefined || partial !== undefined || runningCalls !== undefined
|
||||
|| turnTimings !== undefined || turnEnds !== undefined) {
|
||||
chatSource.set({
|
||||
...(nodes === undefined ? {} : { nodes }),
|
||||
...(partial === undefined ? {} : { partial }),
|
||||
...(runningCalls === undefined ? {} : { runningCalls }),
|
||||
...(turnTimings === undefined ? {} : { turnTimings }),
|
||||
...(turnEnds === undefined ? {} : { turnEnds }),
|
||||
})
|
||||
}
|
||||
session.set(sessionUpdate)
|
||||
}
|
||||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||||
return {
|
||||
setSession: session.set, setChat: chatSource.set, ChatView, props,
|
||||
set, setSession: session.set, setChat: chatSource.set, ChatView, props,
|
||||
openDetails, openFile, loadOlder, openView,
|
||||
chatScroll, forkAt, setSelection, toolOwners,
|
||||
setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) },
|
||||
setNodeRenderer: (renderer: React.ComponentProps<typeof ChatNodeSeat>['renderSlot']) => {
|
||||
nodeSlotOverride = renderer
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +410,34 @@ function readerScroll(element: HTMLElement, top: number): void {
|
||||
fireEvent.scroll(element)
|
||||
}
|
||||
|
||||
function turnProcessControl(container: HTMLElement): HTMLButtonElement | null {
|
||||
return container.querySelector<HTMLButtonElement>('[data-turn-process]')
|
||||
}
|
||||
|
||||
function withSystemPrompt(snapshot: ChatSnapshot, text = '# System'): ChatSnapshot {
|
||||
const turn = snapshot.timeline.turns.get(1)
|
||||
if (turn === undefined) throw new Error('fixture lacks Turn 1')
|
||||
const prompt: ChatNode<'system-prompt'> = {
|
||||
key: 'fixture:system-prompt:1',
|
||||
id: '1',
|
||||
target: 'chat',
|
||||
kind: 'system-prompt',
|
||||
anchorSeq: 1,
|
||||
location: { kind: 'turn', turn },
|
||||
visibility: 'visible',
|
||||
data: { text },
|
||||
}
|
||||
return new ChatSnapshotBuilder().replace({
|
||||
nodes: [prompt, ...snapshot.nodes.values()],
|
||||
timeline: snapshot.timeline,
|
||||
})
|
||||
}
|
||||
|
||||
function renderedFlowKinds(container: HTMLElement): Array<string | undefined> {
|
||||
return [...container.querySelectorAll<HTMLElement>('[data-chat-flow-kind]')]
|
||||
.map(row => row.dataset.chatFlowKind)
|
||||
}
|
||||
|
||||
function installScrollMetrics(element: HTMLElement, initialHeight: number, clientHeight: number) {
|
||||
let scrollHeight = initialHeight
|
||||
let scrollTop = 0
|
||||
@@ -650,6 +742,7 @@ describe('ChatView', () => {
|
||||
kind: row.getAttribute('data-chat-flow-kind'),
|
||||
}))).toEqual([
|
||||
{ key: 'fixture:user:1', kind: 'user' },
|
||||
{ key: 'fixture:turn-process:1', kind: 'turn-process' },
|
||||
{ key: 'fixture:assistant:2', kind: 'assistant-step' },
|
||||
{ key: 'fixture:tool:a', kind: 'tool-call' },
|
||||
{ key: 'fixture:tool:b', kind: 'tool-call' },
|
||||
@@ -658,7 +751,7 @@ describe('ChatView', () => {
|
||||
.toEqual(['a', 'b'])
|
||||
expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key')))
|
||||
.toEqual([
|
||||
'fixture:user:1', 'fixture:assistant:2',
|
||||
'fixture:user:1', 'fixture:turn-process:1', 'fixture:assistant:2',
|
||||
'fixture:tool:a', 'call:a', 'fixture:tool:b', 'call:b',
|
||||
])
|
||||
})
|
||||
@@ -927,9 +1020,9 @@ describe('ChatView', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
user(1, 'hi'),
|
||||
assistant(2, 'mid-turn text'),
|
||||
assistant(2, 'mid-turn text', 1, 1),
|
||||
toolResult(3, 'a'),
|
||||
assistant(4, 'final answer'),
|
||||
assistant(4, 'final answer', 1, 2),
|
||||
user(5, 'next'),
|
||||
assistant(6, 'second turn', 2),
|
||||
],
|
||||
@@ -943,6 +1036,464 @@ describe('ChatView', () => {
|
||||
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual([null, null])
|
||||
})
|
||||
|
||||
it('folds Think and Tool rows before the final answer without unmounting them', () => {
|
||||
const first = {
|
||||
...assistant(2, 'earlier reply', 1, 1),
|
||||
blocks: [
|
||||
{ kind: 'reasoning' as const, text: 'inspect the repository' },
|
||||
{ kind: 'text' as const, text: 'earlier reply' },
|
||||
],
|
||||
}
|
||||
const second = assistant(5, 'final answer', 1, 2)
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
user(1, 'question'),
|
||||
first,
|
||||
toolResult(3, 'a'),
|
||||
toolResult(4, 'b', 'subagent'),
|
||||
second,
|
||||
],
|
||||
turnTimings: new Map([[1, { startTime: 1_000, endTime: 5_000 }]]),
|
||||
turnEnds: new Map([[1, 6]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const toggle = view.getByRole('button', { name: '1 次工具调用 · 1 条消息 · 1 个 subagent' })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('1')
|
||||
expect(toggle.getAttribute('data-turn-process-messages')).toBe('1')
|
||||
expect(toggle.getAttribute('data-turn-process-subagents')).toBe('1')
|
||||
const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
|
||||
expect(members).toHaveLength(3)
|
||||
expect(members.map(member => member.getAttribute('hidden')))
|
||||
.toEqual(['until-found', 'until-found', 'until-found'])
|
||||
expect(members[0]?.textContent).toContain('inspect the repository')
|
||||
expect(members[1]?.textContent).toContain('bash:a')
|
||||
expect(members[2]?.textContent).toContain('subagent:b')
|
||||
expect(view.getByText('final answer')).toBeTruthy()
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(members.map(member => member.getAttribute('hidden')))
|
||||
.toEqual(['until-found', 'until-found', 'until-found'])
|
||||
fireEvent(members[1]!, new Event('beforematch'))
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
|
||||
|
||||
act(() => { h.set({ nodes: [user(1, 'question'), first] }) })
|
||||
expect(view.getByRole('button', { name: '已思考' }).getAttribute('aria-expanded')).toBe('false')
|
||||
expect(members[0]?.getAttribute('hidden')).toBeNull()
|
||||
act(() => { h.set({
|
||||
nodes: [user(1, 'question'), first, toolResult(3, 'a'), toolResult(4, 'b', 'subagent'), second],
|
||||
}) })
|
||||
const renewedToggle = view.getByRole('button', { name: '1 次工具调用 · 1 条消息 · 1 个 subagent' })
|
||||
expect(renewedToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(members[0]?.getAttribute('hidden')).toBeNull()
|
||||
})
|
||||
|
||||
it('folds injected Context in place with the rest of the Turn process', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
user(1, 'question'),
|
||||
context(2, 'runtime policy changed', 1),
|
||||
reasoningAssistant(3, 'inspect the repository', 1, 1),
|
||||
toolResult(4, 'a'),
|
||||
assistant(5, 'final answer', 1, 2),
|
||||
],
|
||||
turnEnds: new Map([[1, 6]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
|
||||
const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
|
||||
|
||||
expect(members).toHaveLength(3)
|
||||
expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step', 'tool-call'])
|
||||
expect(contextRow).not.toBeNull()
|
||||
expect(contextRow?.getAttribute('hidden')).toBe('until-found')
|
||||
fireEvent(contextRow!, new Event('beforematch'))
|
||||
expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
|
||||
})
|
||||
|
||||
it('keeps the first System prompt above User and outside Process through completion and expansion', () => {
|
||||
const initial = withSystemPrompt(chatSnapshotFixture({
|
||||
nodes: [userInTurn(2, 'question', 1), context(3, 'runtime policy', 1)],
|
||||
}))
|
||||
const running = withSystemPrompt(chatSnapshotFixture({
|
||||
nodes: [
|
||||
userInTurn(2, 'question', 1),
|
||||
context(3, 'runtime policy', 1),
|
||||
reasoningAssistant(4, 'inspect', 1, 1),
|
||||
],
|
||||
}))
|
||||
const completed = withSystemPrompt(chatSnapshotFixture({
|
||||
nodes: [
|
||||
userInTurn(2, 'question', 1),
|
||||
context(3, 'runtime policy', 1),
|
||||
reasoningAssistant(4, 'inspect', 1, 1),
|
||||
assistant(6, 'final answer', 1, 2),
|
||||
],
|
||||
turnEnds: new Map([[1, 7]]),
|
||||
}))
|
||||
const h = makeHarness({ chat: initial }, { running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const promptRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="system-prompt"]')!
|
||||
|
||||
expect(renderedFlowKinds(view.container)).toEqual(['system-prompt', 'user', 'context'])
|
||||
expect(promptRow.getAttribute('hidden')).toBeNull()
|
||||
expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false)
|
||||
|
||||
act(() => { h.set({ chat: running, running: true }) })
|
||||
expect(renderedFlowKinds(view.container)).toEqual([
|
||||
'system-prompt', 'user', 'turn-process', 'context', 'assistant-step',
|
||||
])
|
||||
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBe(promptRow)
|
||||
expect(promptRow.getAttribute('hidden')).toBeNull()
|
||||
|
||||
act(() => { h.set({ chat: completed, running: false }) })
|
||||
const toggle = turnProcessControl(view.container)!
|
||||
const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
|
||||
expect(renderedFlowKinds(view.container)).toEqual([
|
||||
'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
|
||||
])
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(promptRow.getAttribute('hidden')).toBeNull()
|
||||
expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false)
|
||||
expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step'])
|
||||
expect(members.map(member => member.getAttribute('hidden'))).toEqual(['until-found', 'until-found'])
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(renderedFlowKinds(view.container)).toEqual([
|
||||
'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
|
||||
])
|
||||
expect(promptRow.getAttribute('hidden')).toBeNull()
|
||||
expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null])
|
||||
})
|
||||
|
||||
it('folds Context under the fallback title when every summary count is zero', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'question'), context(2, 'runtime policy', 1), assistant(3, 'final answer', 1, 1)],
|
||||
turnEnds: new Map([[1, 4]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const toggle = view.getByRole('button', { name: '已思考' })
|
||||
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
|
||||
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('0')
|
||||
expect(toggle.getAttribute('data-turn-process-messages')).toBe('0')
|
||||
expect(toggle.getAttribute('data-turn-process-subagents')).toBe('0')
|
||||
expect(contextRow?.getAttribute('hidden')).toBe('until-found')
|
||||
fireEvent.click(toggle)
|
||||
expect(contextRow?.getAttribute('hidden')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps ordinary spacing when steering separates the process control from its answer', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
user(1, 'question'),
|
||||
reasoningAssistant(2, 'inspect', 1, 1),
|
||||
steering(3, 'also mention safety', 1),
|
||||
assistant(4, 'final answer', 1, 2),
|
||||
],
|
||||
turnEnds: new Map([[1, 5]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const answer = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="assistant-step"]:not([hidden])')
|
||||
|
||||
expect(view.getByText('also mention safety')).toBeTruthy()
|
||||
expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps ordinary spacing when steering precedes the first process evidence', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
steering(1, 'question', 1),
|
||||
steering(2, 'also mention safety', 1),
|
||||
reasoningAssistant(3, 'inspect', 1, 1),
|
||||
assistant(4, 'final answer', 1, 2),
|
||||
],
|
||||
turnEnds: new Map([[1, 5]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const answer = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="assistant-step"]:not([hidden])')
|
||||
|
||||
expect(view.getByText('also mention safety')).toBeTruthy()
|
||||
expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a live Turn expanded and folds it once at turn/end', () => {
|
||||
const process = assistant(2, 'inspect', 1, 1)
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'question'), process],
|
||||
partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'streaming answer' }] },
|
||||
running: true,
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(turnProcessControl(view.container)).toBeNull()
|
||||
const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
nodes: [user(1, 'question'), process, assistant(4, 'settled answer', 1, 2)],
|
||||
partial: null,
|
||||
running: false,
|
||||
turnEnds: new Map([[1, 5]]),
|
||||
})
|
||||
})
|
||||
const toggle = turnProcessControl(view.container)!
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(processRow.getAttribute('hidden')).toBe('until-found')
|
||||
})
|
||||
|
||||
it('switches completed Turns between the persisted Normal and Compact modes', () => {
|
||||
const process = assistant(2, 'inspect', 1, 1)
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'question'), process, assistant(4, 'final answer', 1, 2)],
|
||||
turnEnds: new Map([[1, 5]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
|
||||
|
||||
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(processRow.getAttribute('hidden')).toBe('until-found')
|
||||
|
||||
act(() => { h.setTranscriptView('normal') })
|
||||
expect(turnProcessControl(view.container)).toBeNull()
|
||||
expect(processRow.getAttribute('hidden')).toBeNull()
|
||||
|
||||
act(() => { h.setTranscriptView('compact') })
|
||||
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(processRow.getAttribute('hidden')).toBe('until-found')
|
||||
})
|
||||
|
||||
it('folds final-step reasoning under the fallback title when every summary count is zero', () => {
|
||||
const final = {
|
||||
...assistant(3, 'final answer', 1, 1),
|
||||
blocks: [
|
||||
{ kind: 'reasoning' as const, text: 'private analysis' },
|
||||
{ kind: 'text' as const, text: 'final answer' },
|
||||
],
|
||||
}
|
||||
const h = makeHarness({ nodes: [user(1, 'question'), final], turnEnds: new Map([[1, 4]]) })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const toggle = view.getByRole('button', { name: '已思考' })
|
||||
const reasoning = view.container.querySelector<HTMLElement>('[data-turn-process-inline]')
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(reasoning?.getAttribute('hidden')).toBe('until-found')
|
||||
expect(view.getByText('final answer')).toBeTruthy()
|
||||
fireEvent.click(toggle)
|
||||
expect(view.getByText('private analysis')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('folds a completed Turn even while the reader is away from the tail', () => {
|
||||
const first = assistant(2, 'first answer', 1, 1)
|
||||
const h = makeHarness({ nodes: [user(1, 'question'), first], running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
const firstRow = view.getByText('first answer').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
|
||||
readerScroll(scroller, 100)
|
||||
|
||||
act(() => { h.set({
|
||||
nodes: [user(1, 'question'), first, assistant(4, 'new answer', 1, 2)],
|
||||
turnEnds: new Map([[1, 5]]),
|
||||
}) })
|
||||
const toggle = turnProcessControl(view.container)!
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(firstRow.getAttribute('hidden')).toBe('until-found')
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('folds when the process controller first appears off-tail', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'question'), context(2, 'runtime policy', 1)],
|
||||
running: true,
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
|
||||
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
|
||||
readerScroll(scroller, 100)
|
||||
|
||||
act(() => { h.set({
|
||||
nodes: [
|
||||
user(1, 'question'),
|
||||
context(2, 'runtime policy', 1),
|
||||
assistant(3, 'final answer', 1, 1),
|
||||
],
|
||||
running: false,
|
||||
turnEnds: new Map([[1, 4]]),
|
||||
}) })
|
||||
const toggle = turnProcessControl(view.container)!
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(contextRow?.getAttribute('hidden')).toBe('until-found')
|
||||
expect(view.getByLabelText('回到底部')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps a focused process row visible when a live Turn completes', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'question'), context(2, 'runtime policy', 1)],
|
||||
running: true,
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const contextToggle = view.getByRole('button', { name: '上下文注入' })
|
||||
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
|
||||
contextToggle.focus()
|
||||
expect(document.activeElement).toBe(contextToggle)
|
||||
|
||||
act(() => { h.set({
|
||||
nodes: [
|
||||
user(1, 'question'),
|
||||
context(2, 'runtime policy', 1),
|
||||
assistant(3, 'final answer', 1, 1),
|
||||
],
|
||||
running: false,
|
||||
turnEnds: new Map([[1, 4]]),
|
||||
}) })
|
||||
const processToggle = turnProcessControl(view.container)!
|
||||
expect(processToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(contextRow?.getAttribute('hidden')).toBeNull()
|
||||
expect(document.activeElement).toBe(contextToggle)
|
||||
|
||||
fireEvent.click(processToggle)
|
||||
expect(document.activeElement).toBe(processToggle)
|
||||
expect(processToggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(contextRow?.getAttribute('hidden')).toBe('until-found')
|
||||
})
|
||||
|
||||
it('keeps a foldable closed Turn fully visible while history is partial', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
user(1, 'question'),
|
||||
context(2, 'runtime policy', 1),
|
||||
assistant(3, 'working', 1, 1),
|
||||
assistant(4, 'final answer', 1, 2),
|
||||
],
|
||||
turnEnds: new Map([[1, 5]]),
|
||||
hasMore: true,
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
|
||||
|
||||
expect(turnProcessControl(view.container)).toBeNull()
|
||||
expect(contextRow?.getAttribute('hidden')).toBeNull()
|
||||
expect(contextRow?.hasAttribute('data-turn-process-member')).toBe(false)
|
||||
|
||||
act(() => { h.set({ hasMore: false }) })
|
||||
const toggle = turnProcessControl(view.container)!
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(contextRow?.getAttribute('hidden')).toBe('until-found')
|
||||
})
|
||||
|
||||
it('withholds process controls for partial history and folds final-page groups', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(9, 'visible question'), assistant(10, 'visible answer', 2)],
|
||||
hasMore: true,
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(turnProcessControl(view.container)).toBeNull()
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
nodes: [
|
||||
user(1, 'older question'),
|
||||
assistant(2, 'older first answer', 1, 1),
|
||||
assistant(4, 'older final answer', 1, 2),
|
||||
user(9, 'visible question'),
|
||||
assistant(10, 'visible answer', 2),
|
||||
],
|
||||
turnEnds: new Map([[1, 5]]),
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
|
||||
const toggle = turnProcessControl(view.container)!
|
||||
const member = view.container.querySelector<HTMLElement>('[data-turn-process-member]')
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(member?.getAttribute('hidden')).toBe('until-found')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(member?.getAttribute('hidden')).toBeNull()
|
||||
})
|
||||
|
||||
it('refreshes process layout without reordering when the final page only changes Turn data', () => {
|
||||
const source = chatSnapshotFixture({
|
||||
nodes: [
|
||||
user(1, 'question'),
|
||||
context(2, 'runtime policy', 1),
|
||||
assistant(3, 'working', 1, 1),
|
||||
assistant(4, 'final answer', 1, 2),
|
||||
],
|
||||
turnEnds: new Map([[1, 5]]),
|
||||
})
|
||||
const process = source.nodes.values()
|
||||
.find((candidate): candidate is ChatNode<'turn-process'> => candidate.kind === 'turn-process')
|
||||
if (process === undefined
|
||||
|| (process.location.kind !== 'turn' && process.location.kind !== 'step')
|
||||
|| process.data.answerAnchorSeq === null) throw new Error('fixture lacks a completed Turn process')
|
||||
const turnData = process.location.turn.data as typeof process.location.turn.data & {
|
||||
set(key: 'turn-process', value: ReturnType<typeof encodeTurnProcess>): void
|
||||
}
|
||||
const partialSpec = { ...process.data, processStartSeq: process.data.answerAnchorSeq }
|
||||
turnData.set('turn-process', encodeTurnProcess(partialSpec))
|
||||
const partialProcess = { ...process, data: partialSpec }
|
||||
const builder = new ChatSnapshotBuilder()
|
||||
const partial = builder.replace({
|
||||
nodes: source.nodes.values().map(node => node.key === process.key ? partialProcess : node),
|
||||
timeline: source.timeline,
|
||||
})
|
||||
const h = makeHarness({ chat: partial, hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(turnProcessControl(view.container)).toBeNull()
|
||||
|
||||
const beforeKeys = partial.locations.getTurn(1)
|
||||
const completeSpec = { ...partialSpec, processStartSeq: 2 }
|
||||
turnData.set('turn-process', encodeTurnProcess(completeSpec))
|
||||
const complete = builder.apply({
|
||||
upserts: [{ ...partialProcess, data: completeSpec }],
|
||||
timeline: source.timeline,
|
||||
})
|
||||
expect(complete.order).toBe(partial.order)
|
||||
expect(complete.nodes).toBe(partial.nodes)
|
||||
expect(complete.locations.getTurn(1)).not.toBe(beforeKeys)
|
||||
expect(complete.order.map(key => complete.nodes.get(key)?.kind)).toEqual([
|
||||
'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
|
||||
])
|
||||
|
||||
act(() => { h.set({ chat: complete, hasMore: false }) })
|
||||
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('keeps a manual expansion when the reader returns from another view', () => {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-conversation-scroll', '')
|
||||
Object.defineProperty(host, 'scrollHeight', { value: 2_000, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
|
||||
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
|
||||
document.body.appendChild(host)
|
||||
try {
|
||||
const first = assistant(2, 'first answer', 1, 1)
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'question'), first, assistant(4, 'new answer', 1, 2)],
|
||||
turnEnds: new Map([[1, 5]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
fireEvent.click(turnProcessControl(view.container)!)
|
||||
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
view.rerender(<div />)
|
||||
view.rerender(<h.ChatView {...h.props} />)
|
||||
expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('true')
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('withholds assistant IconActions while the turn is still running', () => {
|
||||
const h = makeHarness({
|
||||
runningCalls: [runningCall('a')],
|
||||
@@ -973,8 +1524,8 @@ describe('ChatView', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
user(1, 'hi'), // time 1_000
|
||||
assistant(2, 'mid-turn text'),
|
||||
assistant(16, 'final answer'),
|
||||
assistant(2, 'mid-turn text', 1, 1),
|
||||
assistant(16, 'final answer', 1, 2),
|
||||
toolResult(18, 'trailing'),
|
||||
],
|
||||
turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
|
||||
@@ -982,7 +1533,7 @@ describe('ChatView', () => {
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// The exact turn/end includes trailing tool activity after the final text.
|
||||
expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
|
||||
expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒')
|
||||
})
|
||||
|
||||
it('the settled footer appends first-step ttft and turn decode throughput', () => {
|
||||
@@ -1003,7 +1554,7 @@ describe('ChatView', () => {
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// First-step ttft (1.2s) plus 100 tokens over 5s of decode.
|
||||
expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
|
||||
expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒')
|
||||
expect(view.getAllByText(/首 token 1\.2秒/)).toHaveLength(1)
|
||||
expect(view.getAllByText(/20 tok\/s/)).toHaveLength(1)
|
||||
})
|
||||
@@ -1126,8 +1677,8 @@ describe('ChatView', () => {
|
||||
h.setChat({
|
||||
nodes: [
|
||||
user(1, markdown),
|
||||
assistant(2, markdown),
|
||||
{ ...assistant(3, markdown), interrupted: true },
|
||||
assistant(2, markdown, 1, 1),
|
||||
{ ...assistant(3, markdown, 1, 2), interrupted: true },
|
||||
],
|
||||
})
|
||||
})
|
||||
@@ -1160,12 +1711,12 @@ 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) => {
|
||||
h.setNodeRenderer(((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" />
|
||||
})
|
||||
}) as React.ComponentProps<typeof ChatNodeSeat>['renderSlot'])
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByTestId('counting-row')).toBeTruthy()
|
||||
const afterMount = rowRenders
|
||||
@@ -1219,7 +1770,7 @@ describe('ChatView', () => {
|
||||
return key === 'conversation.chat.node' && routed.node.kind === 'tool-call'
|
||||
? <StatefulToolNode node={routed.node} />
|
||||
: opts?.fallback ?? null
|
||||
}) as ChatViewSlotProps['renderSlot']
|
||||
}) as React.ComponentProps<typeof ChatNodeSeat>['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"]')
|
||||
@@ -1270,10 +1821,10 @@ describe('ChatView', () => {
|
||||
const block = toolResult(3, 'a')
|
||||
const h = makeHarness({ nodes: [block] })
|
||||
const calls: { key: string; owner: object; entryKey?: string }[] = []
|
||||
h.props.renderSlot = ((key: string, owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
|
||||
h.setNodeRenderer(((key: string, owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
|
||||
calls.push({ key, owner, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
|
||||
return opts?.fallback ?? null
|
||||
})
|
||||
}) as React.ComponentProps<typeof ChatNodeSeat>['renderSlot'])
|
||||
render(<h.ChatView {...h.props} />)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts'
|
||||
import { decodeTurnProcess } from '../src/client/contract/turn-process.ts'
|
||||
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'
|
||||
@@ -29,6 +31,7 @@ import { toolDefinition } from '../src/client/conversation-nodes/tool.ts'
|
||||
import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts'
|
||||
import { turnMaxTokensDefinition } from '../src/client/conversation-nodes/turn-max-tokens.ts'
|
||||
import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts'
|
||||
import { turnProcessDefinition } from '../src/client/conversation-nodes/turn-process.ts'
|
||||
import type {
|
||||
AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData,
|
||||
} from '../src/client/contract/chat-nodes.ts'
|
||||
@@ -39,6 +42,7 @@ const DEFINITIONS: readonly ConversationNodeDefinition[] = [
|
||||
messageDefinition,
|
||||
requestPromptDefinition(inspectRequestPrompt),
|
||||
assistantDefinition,
|
||||
turnProcessDefinition,
|
||||
toolDefinition,
|
||||
commandDefinition,
|
||||
compactionDefinition,
|
||||
@@ -221,6 +225,350 @@ describe('built-in conversation node Definitions', () => {
|
||||
expect(items[0]?.prompt.length).toBe(160)
|
||||
})
|
||||
|
||||
it('classifies reply content separately from reasoning and Tool protocol blocks', () => {
|
||||
expect(hasAssistantReplyContent([{ kind: 'text', text: ' ' }])).toBe(false)
|
||||
expect(hasAssistantReplyContent([{ kind: 'reasoning', text: 'thinking' }])).toBe(false)
|
||||
expect(hasAssistantReplyContent([{ kind: 'tool-call', callId: 'c', name: 'read', argsRaw: '{}' }])).toBe(false)
|
||||
expect(hasAssistantReplyContent([{ kind: 'text', text: 'answer' }])).toBe(true)
|
||||
expect(hasAssistantReplyContent([{ kind: 'image', attachment: {} as never }])).toBe(true)
|
||||
expect(hasAssistantReplyContent([{ kind: 'other', block: { type: 'future' } }])).toBe(true)
|
||||
})
|
||||
|
||||
it('projects one reversible process window before the finalized answer', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'user/message', {
|
||||
...textMessage('context-1', 'workspace context'),
|
||||
turn: 1,
|
||||
step: 1,
|
||||
source: { kind: 'plugin', plugin: 'context' },
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(4, 'assistant/chunk', {
|
||||
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
|
||||
}),
|
||||
at(5, 'assistant/chunk', {
|
||||
turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'checking' },
|
||||
}),
|
||||
at(6, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', name: 'read', argumentsDelta: '{}' },
|
||||
}),
|
||||
])
|
||||
const process = () => {
|
||||
const signature = snapshot(value).timeline.turns.get(1)?.data.get('turn-process')
|
||||
return signature === undefined ? undefined : decodeTurnProcess(signature)
|
||||
}
|
||||
expect(process()).toMatchObject({ processStartSeq: 4, answerAnchorSeq: null, answerStep: null })
|
||||
expect(node(snapshot(value), 'turn-process')?.data).toMatchObject({ answerAnchorSeq: null })
|
||||
|
||||
value.append(at(7, 'tool/call', {
|
||||
turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}',
|
||||
}))
|
||||
value.append(at(8, 'tool/result', {
|
||||
turn: 1, step: 1, message: toolResult('call-1', 'done'),
|
||||
}, { surfaceOp: 'append' }))
|
||||
value.append(at(9, 'step/end', { turn: 1, step: 1 }))
|
||||
value.append(at(10, 'step/start', { turn: 1, step: 2 }))
|
||||
value.append(at(11, 'assistant/chunk', {
|
||||
turn: 1, step: 2, chunk: { type: 'reasoning-delta', index: 0, text: 'final thinking' },
|
||||
}))
|
||||
value.append(at(12, 'assistant/chunk', {
|
||||
turn: 1, step: 2, chunk: { type: 'text-delta', index: 1, text: 'final reply' },
|
||||
}))
|
||||
value.flush()
|
||||
expect(process()).toMatchObject({
|
||||
processStartSeq: 4,
|
||||
answerAnchorSeq: null,
|
||||
answerStep: null,
|
||||
inlineReasoning: false,
|
||||
})
|
||||
|
||||
value.append(at(13, 'llm/retry', {
|
||||
retryId: 'retry-tail', turn: 1, step: 2, provider: 'fake', mode: 'normal',
|
||||
policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
|
||||
failure: { code: 'TRANSPORT', message: 'temporary' },
|
||||
}))
|
||||
value.flush()
|
||||
expect(process()).toMatchObject({ answerAnchorSeq: null, answerStep: null })
|
||||
|
||||
value.append(at(14, 'assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'replacement reply' },
|
||||
}))
|
||||
value.flush()
|
||||
expect(process()).toMatchObject({ answerAnchorSeq: null, answerStep: null })
|
||||
|
||||
value.append(at(15, 'step/end', { turn: 1, step: 2 }))
|
||||
value.append(at(16, 'turn/end', {
|
||||
turn: 1,
|
||||
reason: { kind: 'aborted', reason: { kind: 'user' } },
|
||||
}))
|
||||
value.flush()
|
||||
expect(process()).toMatchObject({ answerAnchorSeq: 14.1, answerStep: 2 })
|
||||
|
||||
const recovered = assembler([
|
||||
at(20, 'turn/start', { turn: 2 }),
|
||||
at(21, 'step/start', { turn: 2, step: 1 }),
|
||||
at(22, 'assistant/message', {
|
||||
turn: 2, step: 1, message: assistantMessage('recovered-1', 'settled reply'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(23, 'step/end', { turn: 2, step: 1 }),
|
||||
at(24, 'step/start', { turn: 2, step: 2 }),
|
||||
at(25, 'assistant/chunk', {
|
||||
turn: 2, step: 2, chunk: { type: 'text-delta', index: 0, text: 'crash partial' },
|
||||
}),
|
||||
at(26, 'turn/end', { turn: 2, reason: { kind: 'interrupted' } }),
|
||||
])
|
||||
const recoveredSignature = snapshot(recovered).timeline.turns.get(2)?.data.get('turn-process')
|
||||
expect(recoveredSignature === undefined ? undefined : decodeTurnProcess(recoveredSignature))
|
||||
.toMatchObject({ answerStep: 2, answerAnchorSeq: 25.1 })
|
||||
|
||||
const partialWindow = assembler([
|
||||
at(30, 'assistant/chunk', {
|
||||
turn: 3, step: 4, chunk: { type: 'text-delta', index: 0, text: 'loaded tail' },
|
||||
}),
|
||||
at(31, 'step/end', { turn: 3, step: 4 }),
|
||||
], true)
|
||||
const partialSignature = snapshot(partialWindow).timeline.turns.get(3)?.data.get('turn-process')
|
||||
expect(partialSignature === undefined ? undefined : decodeTurnProcess(partialSignature))
|
||||
.toMatchObject({ processStartSeq: 30.1, answerAnchorSeq: 30.1, answerStep: 4 })
|
||||
})
|
||||
|
||||
it('counts Assistant messages, Tool calls, and subagent delegations per Turn', () => {
|
||||
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('message-1', 'checking'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(4, 'tool/call', {
|
||||
turn: 1, step: 1, callId: 'call-read', name: 'read', arguments: '{}',
|
||||
}),
|
||||
at(5, 'tool/result', {
|
||||
turn: 1, step: 1, message: toolResult('call-read', 'read done'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(6, 'tool/call', {
|
||||
turn: 1, step: 1, callId: 'call-subagent', name: 'subagent_fork', arguments: '{}',
|
||||
}),
|
||||
at(7, 'tool/result', {
|
||||
turn: 1, step: 1, message: toolResult('call-subagent', 'delegation done'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(8, 'step/end', { turn: 1, step: 1 }),
|
||||
at(9, 'step/start', { turn: 1, step: 2 }),
|
||||
at(10, 'assistant/message', {
|
||||
turn: 1, step: 2, message: assistantMessage('message-2', 'final answer'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(11, 'step/end', { turn: 1, step: 2 }),
|
||||
at(12, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
])
|
||||
const signature = snapshot(value).timeline.turns.get(1)?.data.get('turn-process')
|
||||
expect(signature === undefined ? undefined : decodeTurnProcess(signature)).toMatchObject({
|
||||
messageCount: 1,
|
||||
toolCallCount: 1,
|
||||
subagentCount: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('orders the opening User before its process control and later steering', () => {
|
||||
const steering = textMessage('steer-1', 'change direction')
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'user/message', {
|
||||
...textMessage('context-1', 'runtime context'),
|
||||
source: { kind: 'plugin', plugin: 'context' },
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(3, 'user/message', textMessage('user-1', 'question'), { surfaceOp: 'append' }),
|
||||
at(4, 'step/start', { turn: 1, step: 1 }),
|
||||
])
|
||||
const opening = snapshot(value)
|
||||
expect(opening.order.map(key => opening.nodes.get(key)?.kind)).toEqual([
|
||||
'user', 'context',
|
||||
])
|
||||
|
||||
value.append(at(5, 'assistant/chunk', {
|
||||
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
|
||||
}))
|
||||
value.flush()
|
||||
const running = snapshot(value)
|
||||
expect(running.order.map(key => running.nodes.get(key)?.kind)).toEqual([
|
||||
'user', 'turn-process', 'context', 'assistant-step',
|
||||
])
|
||||
|
||||
value.append(at(6, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
}))
|
||||
value.append(at(7, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
}))
|
||||
value.append(at(8, 'user/message', steering, { surfaceOp: 'append' }))
|
||||
value.append(at(9, 'step/end', { turn: 1, step: 1 }))
|
||||
value.append(at(10, 'step/start', { turn: 1, step: 2 }))
|
||||
value.append(at(11, 'assistant/message', {
|
||||
turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
|
||||
}, { surfaceOp: 'append' }))
|
||||
value.append(at(12, 'step/end', { turn: 1, step: 2 }))
|
||||
value.append(at(13, 'turn/end', { turn: 1, reason: { kind: 'completed' } }))
|
||||
value.flush()
|
||||
const current = snapshot(value)
|
||||
|
||||
expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
|
||||
'user', 'turn-process', 'context', 'steering', 'assistant-step', 'assistant-step', 'turn-tail',
|
||||
])
|
||||
})
|
||||
|
||||
it('orders a command-started Turn first steering before its process control', () => {
|
||||
const steering = textMessage('command-task', 'plan this change')
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
}),
|
||||
at(3, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
}),
|
||||
at(4, 'user/message', steering, { surfaceOp: 'append' }),
|
||||
at(5, 'step/start', { turn: 1, step: 1 }),
|
||||
at(6, 'assistant/chunk', {
|
||||
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
|
||||
}),
|
||||
at(7, 'step/end', { turn: 1, step: 1 }),
|
||||
at(8, 'step/start', { turn: 1, step: 2 }),
|
||||
at(9, 'assistant/message', {
|
||||
turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(10, 'step/end', { turn: 1, step: 2 }),
|
||||
at(11, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
])
|
||||
const current = snapshot(value)
|
||||
|
||||
expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
|
||||
'steering', 'turn-process', 'assistant-step', 'assistant-step', 'turn-tail',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a first human message after process evidence at its event position', () => {
|
||||
const steering = textMessage('late-steering', 'change direction')
|
||||
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: 'call-1', name: 'read', arguments: '{}',
|
||||
}),
|
||||
at(4, 'tool/result', {
|
||||
turn: 1, step: 1, message: toolResult('call-1', 'done'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(5, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, inserted: [steering],
|
||||
}),
|
||||
at(6, 'agent/inbox/spliced', {
|
||||
target: 'next-step', start: 0, removedCount: 1, inserted: [],
|
||||
}),
|
||||
at(7, 'user/message', steering, { surfaceOp: 'append' }),
|
||||
at(8, 'step/end', { turn: 1, step: 1 }),
|
||||
at(9, 'step/start', { turn: 1, step: 2 }),
|
||||
at(10, 'assistant/message', {
|
||||
turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(11, 'step/end', { turn: 1, step: 2 }),
|
||||
at(12, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
|
||||
])
|
||||
const current = snapshot(value)
|
||||
|
||||
expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
|
||||
'turn-process', 'tool-call', 'steering', 'assistant-step', 'turn-tail',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps Process before pre-User Context as answer eligibility changes', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'user/message', {
|
||||
...textMessage('context-1', 'runtime context'),
|
||||
source: { kind: 'plugin', plugin: 'context' },
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(3, 'step/start', { turn: 1, step: 1 }),
|
||||
at(4, 'assistant/chunk', {
|
||||
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
|
||||
}),
|
||||
])
|
||||
const running = snapshot(value)
|
||||
expect(running.order.map(key => running.nodes.get(key)?.kind)).toEqual([
|
||||
'turn-process', 'context', 'assistant-step',
|
||||
])
|
||||
|
||||
value.append(at(5, 'step/end', { turn: 1, step: 1 }))
|
||||
value.append(at(6, 'step/start', { turn: 1, step: 2 }))
|
||||
value.append(at(7, 'assistant/message', {
|
||||
turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
|
||||
}, { surfaceOp: 'append' }))
|
||||
value.flush()
|
||||
const answered = snapshot(value)
|
||||
expect(answered.order.map(key => answered.nodes.get(key)?.kind)).toEqual([
|
||||
'turn-process', 'context', 'assistant-step', 'assistant-step',
|
||||
])
|
||||
|
||||
value.append(at(8, 'llm/retry', {
|
||||
retryId: 'retry-tail', turn: 1, step: 2, provider: 'fake', mode: 'normal',
|
||||
policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
|
||||
failure: { code: 'TRANSPORT', message: 'temporary' },
|
||||
}))
|
||||
value.flush()
|
||||
const retried = snapshot(value)
|
||||
expect(retried.order.map(key => retried.nodes.get(key)?.kind)).toEqual([
|
||||
'turn-process', 'context', 'assistant-step', 'model-retry',
|
||||
])
|
||||
})
|
||||
|
||||
it('establishes the answer boundary only when a streamed answer finalizes', () => {
|
||||
const value = assembler([
|
||||
at(40, 'turn/start', { turn: 4 }),
|
||||
at(41, 'step/start', { turn: 4, step: 1 }),
|
||||
at(42, 'assistant/chunk', {
|
||||
turn: 4, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
|
||||
}),
|
||||
at(43, 'assistant/chunk', {
|
||||
turn: 4, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answer' },
|
||||
}),
|
||||
])
|
||||
const read = () => {
|
||||
const signature = snapshot(value).timeline.turns.get(4)?.data.get('turn-process')
|
||||
if (signature === undefined) throw new Error('turn-process signature is unavailable')
|
||||
return decodeTurnProcess(signature)
|
||||
}
|
||||
const streaming = read()
|
||||
value.append(at(44, 'assistant/message', {
|
||||
turn: 4, step: 1, message: assistantMessage('settled-4', 'answer'),
|
||||
}, { surfaceOp: 'append' }))
|
||||
value.flush()
|
||||
const settled = read()
|
||||
|
||||
expect(streaming).toMatchObject({ answerAnchorSeq: null, answerStep: null })
|
||||
expect(settled.answerAnchorSeq).toBe(44)
|
||||
expect(settled.answerStep).toBe(1)
|
||||
})
|
||||
|
||||
it('anchors a streamed non-text answer from its block start', () => {
|
||||
const value = assembler([
|
||||
at(50, 'turn/start', { turn: 5 }),
|
||||
at(51, 'step/start', { turn: 5, step: 1 }),
|
||||
at(52, 'assistant/chunk', {
|
||||
turn: 5, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'image' },
|
||||
}),
|
||||
])
|
||||
const current = snapshot(value)
|
||||
const process = node(current, 'turn-process')
|
||||
const answer = node(current, 'assistant-step')
|
||||
const signature = current.timeline.turns.get(5)?.data.get('turn-process')
|
||||
|
||||
expect(process?.anchorSeq).toBe(51.9)
|
||||
expect(answer?.anchorSeq).toBe(52)
|
||||
expect(signature === undefined ? undefined : decodeTurnProcess(signature))
|
||||
.toMatchObject({ answerAnchorSeq: null, answerStep: null })
|
||||
})
|
||||
|
||||
it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
@@ -638,10 +986,10 @@ describe('built-in conversation node Definitions', () => {
|
||||
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).toHaveLength(before.order.length + 4)
|
||||
expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
|
||||
'user', 'assistant-step', 'turn-tail',
|
||||
'user', 'assistant-step', 'turn-tail',
|
||||
'user', 'turn-process', 'assistant-step', 'turn-tail',
|
||||
'user', 'turn-process', 'assistant-step', 'turn-tail',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -671,7 +1019,7 @@ describe('built-in conversation node Definitions', () => {
|
||||
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',
|
||||
'user', 'turn-process', 'assistant-step', 'turn-tail', 'user',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -929,6 +1277,51 @@ describe('built-in conversation node Definitions', () => {
|
||||
expect(node(current, 'system-prompt')?.anchorSeq).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the initial system prompt before the opening User as Turn process state changes', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
at(2, 'step/start', { turn: 1, step: 1 }),
|
||||
at(3, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }),
|
||||
at(4, 'user/message', {
|
||||
...textMessage('runtime-context', 'runtime facts'),
|
||||
source: { kind: 'plugin', plugin: 'context' },
|
||||
}, { surfaceOp: 'append' }),
|
||||
at(5, 'request/header', {
|
||||
reason: 'initial',
|
||||
header: { config: { provider: 'fake', model: 'fake' }, system: '# System' },
|
||||
}),
|
||||
])
|
||||
const kinds = () => {
|
||||
const current = snapshot(value)
|
||||
return current.order.map(key => current.nodes.get(key)?.kind)
|
||||
}
|
||||
const promptKey = node(snapshot(value), 'system-prompt')?.key
|
||||
|
||||
expect(kinds()).toEqual(['system-prompt', 'user', 'context'])
|
||||
|
||||
value.append(at(6, 'assistant/chunk', {
|
||||
turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
|
||||
}))
|
||||
value.flush()
|
||||
expect(kinds()).toEqual([
|
||||
'system-prompt', 'user', 'turn-process', 'context', 'assistant-step',
|
||||
])
|
||||
|
||||
value.append(at(7, 'step/end', { turn: 1, step: 1 }))
|
||||
value.append(at(8, 'step/start', { turn: 1, step: 2 }))
|
||||
value.append(at(9, 'assistant/message', {
|
||||
turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
|
||||
}, { surfaceOp: 'append' }))
|
||||
value.append(at(10, 'step/end', { turn: 1, step: 2 }))
|
||||
value.append(at(11, 'turn/end', { turn: 1, reason: { kind: 'completed' } }))
|
||||
value.flush()
|
||||
|
||||
expect(kinds()).toEqual([
|
||||
'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
|
||||
])
|
||||
expect(node(snapshot(value), 'system-prompt')?.key).toBe(promptKey)
|
||||
})
|
||||
|
||||
it('keeps an append-only later user turn in the existing system-prompt series', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
@@ -953,7 +1346,7 @@ describe('built-in conversation node Definitions', () => {
|
||||
expect(ordered.map(candidate => candidate.kind)).toEqual(['system-prompt', 'user', 'user'])
|
||||
})
|
||||
|
||||
it('keeps windowed non-initial headers at their event until prepend supplies the preceding header', () => {
|
||||
it('keeps a windowed System prompt in place when prepend supplies the preceding header', () => {
|
||||
const reasons = ['change', 'resume', 'series'] as const
|
||||
for (const reason of reasons) {
|
||||
const windowedSystem = reason === 'series' ? '# Original' : '# Windowed'
|
||||
@@ -967,7 +1360,13 @@ describe('built-in conversation node Definitions', () => {
|
||||
}),
|
||||
], true)
|
||||
|
||||
expect(node(snapshot(windowed), 'system-prompt')?.anchorSeq).toBe(8)
|
||||
const before = snapshot(windowed)
|
||||
const prompt = node(before, 'system-prompt')
|
||||
const user = node(before, 'user')
|
||||
if (prompt === undefined || user === undefined) throw new Error('windowed prompt fixture is incomplete')
|
||||
const stableOrder = [user.key, prompt.key]
|
||||
expect(prompt.anchorSeq).toBe(8)
|
||||
expect(before.order.filter(key => stableOrder.includes(key))).toEqual(stableOrder)
|
||||
|
||||
windowed.prepend([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
@@ -985,7 +1384,9 @@ describe('built-in conversation node Definitions', () => {
|
||||
const candidate = restored.nodes.get(key)
|
||||
return candidate?.kind === 'system-prompt' ? [candidate] : []
|
||||
})
|
||||
expect(prompts.map(prompt => prompt.anchorSeq)).toEqual([1, 5])
|
||||
expect(prompts.map(candidate => candidate.anchorSeq)).toEqual([1, 8])
|
||||
expect(restored.nodes.get(prompt.key)?.anchorSeq).toBe(8)
|
||||
expect(restored.order.filter(key => stableOrder.includes(key))).toEqual(stableOrder)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('Chat selection survives on its store seat', () => {
|
||||
await b.runtime.sessions.add({ id: 's1' })
|
||||
const reborn = storeFor(b, 'conversation.view', sid('s1'))
|
||||
expect(reborn).not.toBe(doomed)
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null })
|
||||
expect(reborn.store.getSnapshot()).toEqual({ selection: null, turnProcesses: [] })
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { ChatSettings } from '../src/chat-settings.ts'
|
||||
import { TranscriptViewPolicy } from '../src/client/transcript-view.ts'
|
||||
|
||||
describe('TranscriptViewPolicy', () => {
|
||||
it('defaults to Compact and publishes explicit choices before persistence settles', () => {
|
||||
const host = stubSettingsScope<ChatSettings>()
|
||||
const observed: string[] = []
|
||||
let current = (): string => 'unconstructed'
|
||||
const scope: typeof host.scope = {
|
||||
...host.scope,
|
||||
set: (field, value) => {
|
||||
observed.push(`${field}=${String(value)}:${current()}`)
|
||||
return host.scope.set(field, value)
|
||||
},
|
||||
}
|
||||
const policy = new TranscriptViewPolicy(scope)
|
||||
current = () => policy.mode.getSnapshot()
|
||||
|
||||
expect(policy.mode.getSnapshot()).toBe('compact')
|
||||
policy.setMode('normal')
|
||||
expect(policy.mode.getSnapshot()).toBe('normal')
|
||||
expect(observed).toEqual(['transcriptView=normal:normal'])
|
||||
expect(host.set).toHaveBeenCalledWith('transcriptView', 'normal')
|
||||
})
|
||||
|
||||
it('adopts Host state and ignores identical writes', () => {
|
||||
const host = stubSettingsScope<ChatSettings>()
|
||||
const policy = new TranscriptViewPolicy(host.scope)
|
||||
|
||||
host.publish({ status: 'ready', value: { transcriptView: 'normal' }, revision: 1, writable: true })
|
||||
expect(policy.mode.getSnapshot()).toBe('normal')
|
||||
policy.setMode('normal')
|
||||
expect(host.set).not.toHaveBeenCalled()
|
||||
|
||||
host.publish({ value: { transcriptView: 'compact' }, revision: 2 })
|
||||
expect(policy.mode.getSnapshot()).toBe('compact')
|
||||
})
|
||||
|
||||
it('adopts an accepted section standing at construction', () => {
|
||||
const host = stubSettingsScope<ChatSettings>()
|
||||
host.publish({ status: 'ready', value: { transcriptView: 'normal' }, revision: 1, writable: true })
|
||||
expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('normal')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx'
|
||||
import { en } from '../src/client/locale.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function emptySessions() {
|
||||
return bindSnapshotSelector(createSnapshotStore<SessionListState>({
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
function emptyWorkspaces() {
|
||||
return bindSnapshotSelector(createSnapshotStore<WorkspaceSnapshot>({
|
||||
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
||||
}))
|
||||
}
|
||||
|
||||
function noPendingInteraction() {
|
||||
return bindSnapshotSelector(createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()))
|
||||
}
|
||||
|
||||
function mount(mode: 'normal' | 'compact' = 'compact') {
|
||||
const source = createSnapshotStore(mode)
|
||||
const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) })
|
||||
const props: TranscriptViewRowProps = {
|
||||
useSessions: emptySessions(),
|
||||
useSessionPendingInteraction: noPendingInteraction(),
|
||||
useWorkspaces: emptyWorkspaces(),
|
||||
useTranscriptView: bindSnapshotSelector(source),
|
||||
setTranscriptView,
|
||||
t: makeTranslate(en),
|
||||
}
|
||||
render(<TranscriptViewRow {...props} />)
|
||||
return { setTranscriptView }
|
||||
}
|
||||
|
||||
describe('TranscriptViewRow', () => {
|
||||
it('explains the preference and shows Compact by default', () => {
|
||||
mount()
|
||||
expect(screen.getByText('Conversation display')).toBeDefined()
|
||||
expect(screen.getByText('Controls process content in completed turns')).toBeDefined()
|
||||
expect(screen.getByRole('button', { name: /Compact/ }).getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('selects Normal and follows the mirrored value', () => {
|
||||
const b = mount()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Compact/ }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Normal' }))
|
||||
expect(b.setTranscriptView).toHaveBeenCalledWith('normal')
|
||||
const trigger = screen.getByRole('button', { name: /Normal/ })
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByRole('menuitem', { name: 'Compact' })).toBeDefined()
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(screen.queryByRole('menuitem', { name: 'Compact' })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -56,6 +56,9 @@
|
||||
{
|
||||
"path": "../../session/session-stats"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
@@ -80,6 +83,9 @@
|
||||
{
|
||||
"path": "../ui-session"
|
||||
},
|
||||
{
|
||||
"path": "../ui-settings"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
ChatSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
@@ -103,6 +103,7 @@ async function bench(snapshot: ChatSnapshot) {
|
||||
const chat = createSnapshotStore(snapshot)
|
||||
const events = new ConversationEventRegistry(ctx)
|
||||
const views = new ConversationViewRegistry(ctx)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
ctx.provide('uiConversation', {
|
||||
events,
|
||||
views,
|
||||
|
||||
@@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:185',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:202',
|
||||
},
|
||||
{
|
||||
key: 'conversation.chat.commandview',
|
||||
@@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:173',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:190',
|
||||
},
|
||||
{
|
||||
key: 'conversation.chat.node',
|
||||
@@ -266,11 +266,12 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
},
|
||||
],
|
||||
ownerProps: [
|
||||
'/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n selectedCallId?: ToolCallId | undefined\n cwd?: string | undefined\n openFile: (path: string) => void\n inspectCall: (callId: ToolCallId) => void\n forkAt: (seq: number) => void\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n}',
|
||||
'/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n selectedCallId?: ToolCallId | undefined\n cwd?: string | undefined\n openFile: (path: string) => void\n inspectCall: (callId: ToolCallId) => void\n forkAt: (seq: number) => void\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}',
|
||||
],
|
||||
ownerPropsReferences: [
|
||||
'MarkdownFileMentions',
|
||||
'RenderMessageImages',
|
||||
'TurnProcessOwnerProps',
|
||||
'TurnTailOwnerProps',
|
||||
],
|
||||
standardProps: [
|
||||
@@ -287,7 +288,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
'useProjection: UseProjection',
|
||||
'useTrajectory: UseTrajectory',
|
||||
],
|
||||
keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-tail, unknown, user, workflow-run',
|
||||
keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, unknown, user, workflow-run',
|
||||
hookContext: 'string',
|
||||
slotInject: 'ChatNodeTurnDataInjected',
|
||||
declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted',
|
||||
@@ -303,6 +304,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
'client-ui-chat RetryNodeView key \'model-retry\'',
|
||||
'client-ui-chat TurnErrorNodeView key \'turn-error\'',
|
||||
'client-ui-chat TurnMaxTokensNodeView key \'turn-max-tokens\'',
|
||||
'client-ui-chat TurnProcessNodeView key \'turn-process\'',
|
||||
'client-ui-chat TurnTailNodeView key \'turn-tail\'',
|
||||
'client-ui-chat UnknownNodeView key \'unknown\'',
|
||||
'client-ui-goal GoalCommandInputView key \'command-input\'',
|
||||
@@ -311,7 +313,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:154',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:171',
|
||||
},
|
||||
{
|
||||
key: 'conversation.chat.turnTail',
|
||||
@@ -356,7 +358,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:179',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:196',
|
||||
},
|
||||
{
|
||||
key: 'conversation.composer',
|
||||
@@ -535,7 +537,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:191',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:208',
|
||||
},
|
||||
{
|
||||
key: 'conversation.hero.agentPreset',
|
||||
@@ -1025,7 +1027,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:167',
|
||||
source: 'packages/client/ui-chat/src/client/contract/slots.ts:184',
|
||||
},
|
||||
{
|
||||
key: 'conversation.session',
|
||||
@@ -1525,6 +1527,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
occupants: [
|
||||
'client-locale LanguageRow id \'language\'',
|
||||
'client-ui-agent-preset AgentPresetRow id \'agent-preset\'',
|
||||
'client-ui-chat TranscriptViewRow id \'transcript-view\'',
|
||||
'client-ui-conversation EnterBehaviorRow id \'composer-enter\'',
|
||||
'client-ui-permission-presets PermissionRow id \'permission\'',
|
||||
'client-ui-theme AppearanceRow id \'appearance\'',
|
||||
|
||||
Reference in New Issue
Block a user