Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress

# Conflicts:
#	examples/acp-agent/tests/snapshots/todo-write/session.jsonl
#	packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
Chinesezjc
2026-08-06 13:04:33 +08:00
184 changed files with 2851 additions and 411 deletions

View File

@@ -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/runtime/README.md
README.md: f95e06162bca132a9aa83e0b84e875a81d2f8fc6
README.zh.md: 4b1a9dda3bbe02ef9241a8a797a07e5285e6daae
README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27
README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d

View File

@@ -34,7 +34,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
## The human transcript
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's).
`ConversationSnapshot.nodes` is the human transcript, not the model surface. `TranscriptAdapter` projects the raw window in log order — every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint — and never consults surface order. `SteeringHistory` replays the durable `agent/inbox/spliced` records in that window: a user-origin message claimed from `next-step` becomes a `SteeringMessageNode` when its matching `user/message` lands, a `next-turn` claim stays a user node, and non-user next-step input stays context. `ConversationSnapshot.turnEnds` maps each completed turn in that window to its `turn/end` seq, retaining turn completion independently from the transcript so presentation can require a real boundary before enabling an action. A landed compaction therefore keeps the conversation it shadowed on the model side: the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary. A checkpoint is a `user/message` carrying the compaction seam's plugin source that **replaced** a surface range; an appending plugin-sourced `user/message` is injected context, not a compaction. Each context node also carries a `provenance` view: `contextProvenance()` reads the durable source alone to decide whether the row is an `inject` or a cross-session `recall`, and to name its producer from the instruction paths, referenced session titles, or plugin id that source already records. The client holds no table of plugin ids, so a renamed or newly mounted producer stays identifiable without a client release and a resumed or foreign log projects exactly like a live one; a source with no readable kind degrades to an unnamed injection. Beside it, `contextForm()` reads the producer-declared `ContextForm` — the second, independent axis: `kind` says who produced the context, `form` says what shape of information it is, so several producers may share one form. A form this UI version does not present projects as null and renders opaque. The adapter's plugin literal is pinned to the seam's own declaration by a type-only import of the cordis-free [`dsh-compact/checkpoint`](../../compact/compact/README.md) leaf, so renaming it there fails `tsc` here; a **value** import of the package would fail the client purity gate, and the package **root** is unreachable even as a type (it reaches `dsh-session`'s root, whose `Context` merge collides the host `sessions` with this program's).
Because the projection is log-ordered, the node array is seq-monotonic by construction: log-only `command/run` / `command/done` nodes splice in by seq, `Session` merges interrupted frozen nodes by their fractional seqs, and a window whose checkpoint cites a shadowed range outside it renders the marker with nothing logged. The marker's summary text comes from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes the row non-expandable rather than empty, and a later page that supplies it resolves the text. Performance contract: one append materializes at most one node and copies the projection only when it adds that node; an event that changes no node keeps the previous array reference (a chunk storm costs nothing), and unchanged nodes keep their object identity.

View File

@@ -34,7 +34,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 面向人的 transcript文本记录
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口——每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩compaction检查点贡献一个 `CompactionSummaryNode` 标记——且从不查询 surface 顺序。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
`ConversationSnapshot.nodes` 是面向人的 transcript不是模型 surface。`TranscriptAdapter` 按日志顺序投影原始窗口每个 append 来源的 surface 事件(`isAppendSurfaceEvent`落在它自己的日志位置上每次落地的压缩compaction检查点还会贡献一个 `CompactionSummaryNode` 标记;适配器从不查询 surface 顺序。`SteeringHistory` 会重放该窗口中的持久 `agent/inbox/spliced` 记录:用户来源的消息从 `next-step` 被领取,并以相同身份落成 `user/message` 时,会投影为 `SteeringMessageNode`;从 `next-turn` 领取的消息仍是用户节点,非用户来源的 next-step 输入仍是上下文。`ConversationSnapshot.turnEnds` 把该窗口中的每个已完成轮次映射到其 `turn/end` seq它独立于 transcript 保留轮次完成状态,使呈现层能够在启用操作前要求存在真实边界。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话:标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不标记任何边界。检查点是携带压缩 seam 插件来源、且**替换**了一段 surface 范围的 `user/message`;一条 append 的插件来源 `user/message` 是注入上下文,不是压缩。每个上下文节点还携带一份 `provenance` 视图:`contextProvenance()` 只读取持久来源,据此判定该行是 `inject`(注入)还是跨会话的 `recall`(召回),并用该来源已经记录的指令文件路径、被引用会话标题或插件 id 命名其生产者。客户端不保存任何插件 id 表,因此重命名或新挂载的生产者无需客户端发版即可保持可辨识,恢复的会话日志与外部日志的投影结果和实时会话完全一致;没有可读 kind 的来源则降级为无名注入。与之并列的 `contextForm()` 读取生产方声明的 `ContextForm`,这是相互独立的第二根轴:`kind` 说明上下文由谁产生,`form` 说明它是何种形态的信息,因此多个生产方可以共用一种形态。本 UI 版本不呈现的形态投影为 null按 opaque 渲染。适配器的插件字面量通过对无 cordis 的 [`dsh-compact/checkpoint`](../../compact/compact/README.md) 叶子做仅类型导入,钉在压缩 seam 自己的声明上:在那里改名会让此处 `tsc` 失败而对该包package做**值**导入会被客户端纯度门禁拒绝,包的**根**即便作为类型也无法到达(它会到达 `dsh-session` 的根,其 `Context` 合并会让 host 的 `sessions` 与本程序的冲突)。
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。

View File

@@ -48,11 +48,14 @@ export type {
AssistantTiming, CodeSubCall, CommandNode, CompactionSummaryNode, ComposerPhase,
ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage,
RunningToolCall,
TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
SteeringMessageNode, TodoItem, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {
ConversationContext, ConversationContextOriginKind,
} from './sessions/conversation-context.ts'
export type {
ContextProvenanceView, ContextRole, KnownContextForm,
} from './sessions/context-provenance.ts'
export type {
ConversationPromptSnapshot, RequestInspectionSnapshot, RequestPromptChange, RequestView,
} from './sessions/request-inspection.ts'

View File

@@ -11,6 +11,8 @@ import type {
PartialAssistant, RunningToolCall,
} from '../sessions/conversation.ts'
import { toAssistantBlocks } from '../sessions/conversation.ts'
import { contextForm, contextProvenance } from '../sessions/context-provenance.ts'
import { SteeringHistory } from '../sessions/steering-history.ts'
import type {
ConversationContext, ConversationContextOriginKind,
} from '../sessions/conversation-context.ts'
@@ -126,6 +128,7 @@ function materializeNode(
resultView: ToolResultView | null,
assistantTiming: AssistantTiming | undefined,
requestConfig: AssistantRequestConfig | undefined,
steering: boolean,
): ConversationNode {
switch (event.type) {
case 'user/message':
@@ -133,6 +136,15 @@ function materializeNode(
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
provenance: contextProvenance(event.data.source),
form: contextForm(event.data.source),
}
}
if (steering) {
return {
kind: 'steering', messageId: event.data.id,
seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
return {
@@ -332,6 +344,11 @@ export function projectConversationHistory(
entries: readonly HistoryEntry[],
): ConversationHistoryProjection {
const events = entries.map(entry => entry.event)
const steeringHistory = new SteeringHistory()
const steeringSeqs = new Set<number>()
for (const event of events) {
if (steeringHistory.apply(event)) steeringSeqs.add(event.seq)
}
const baseSeq = events[0]?.seq ?? 0
const eventsBySeq = new Map(events.map(event => [event.seq, event]))
const callIndex = new Map<string, CallIndexEntry>()
@@ -392,6 +409,7 @@ export function projectConversationHistory(
resultViews.get(seq) ?? null,
assistantTimings.get(seq),
assistantRequestConfigs.get(seq),
steeringSeqs.has(seq),
)
nodeCache.set(seq, node)
return node

View File

@@ -0,0 +1,116 @@
// Context provenance projection: the role and the human-facing producer name
// of one logged non-user `user/message`, read from its durable `source` alone.
// The client keeps no table of known plugin ids — a renamed or newly mounted
// producer must never need a client release to stay identifiable, and a resumed
// or foreign log must project the same way as a live one.
/**
* Which model-facing role a logged non-user message plays.
*
* `recall` marks material lifted out of another session's log; `inject` marks
* every other producer-supplied context. Mid-turn steering is the third role
* the transcript distinguishes, but it has its own event and node kind
* (`steering/message` / `SteeringMessageNode`) and never reaches here.
*/
export type ContextRole = 'inject' | 'recall'
/** Role and producer name presented for one logged non-user message. */
export interface ContextProvenanceView {
/** The role this context plays in the model-facing conversation. */
role: ContextRole
/**
* Producer name for the row header, taken from the durable source: the
* instruction paths, the referenced session titles, the plugin id, or the
* bare source kind for a producer this UI version does not know. Null only
* when the source carries no readable kind at all.
*/
label: string | null
}
/** One durable source narrowed to the readable-record shape; null for anything else. */
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
/** A record field read as a non-empty string, or null. */
function readString(record: Record<string, unknown>, key: string): string | null {
const value = record[key]
return typeof value === 'string' && value.length > 0 ? value : null
}
/** Distinct non-empty `field` values of an array-valued source member, in first-seen order. */
function collect(source: Record<string, unknown>, member: string, field: string): string[] {
const list = source[member]
if (!Array.isArray(list)) return []
const seen: string[] = []
for (const entry of list) {
const record = asRecord(entry)
const value = record === null ? null : readString(record, field)
if (value !== null && !seen.includes(value)) seen.push(value)
}
return seen
}
/** A collected name list rendered as one label; null when the list is empty. */
function joined(names: string[]): string | null {
return names.length > 0 ? names.join(', ') : null
}
/**
* Project one durable message source onto its transcript role and producer name.
*
* The source arrives over the wire as opaque JSON (`MessageSource` is
* merge-extensible, so no client-side union can be exhaustive), and a durable
* log may predate or postdate this UI; every unreadable shape therefore
* degrades to `inject` with whatever name the record still carries.
* @param source - the logged `user/message` source, exactly as recorded.
* @returns the role and producer name to present for this context.
*/
export function contextProvenance(source: unknown): ContextProvenanceView {
const record = asRecord(source)
const kind = record === null ? null : readString(record, 'kind')
if (record === null || kind === null) return { role: 'inject', label: null }
switch (kind) {
// Cross-session snapshots are the one durable source that carries another
// session's material; its references name the sessions they were read from.
case 'session-reference':
return { role: 'recall', label: joined(collect(record, 'references', 'label')) ?? kind }
// Workspace instructions name the files they were reconciled from, which
// identifies the producer far better than the plugin id would.
case 'workspace-instructions':
return { role: 'inject', label: joined(collect(record, 'changes', 'path')) ?? kind }
case 'plugin':
return { role: 'inject', label: readString(record, 'plugin') ?? kind }
// Documented default arm of the merge-extensible source map: an unknown
// producer still identifies itself by its own durable kind.
default:
return { role: 'inject', label: kind }
}
}
/**
* Context forms this UI version renders with a dedicated presentation. The
* durable vocabulary (`ContextForm` in `dsh-llm`) may already be wider — an
* unrecognized or absent value degrades to the opaque presentation rather than
* dropping the row, so a log written by a newer or foreign producer still
* renders.
*/
const KNOWN_FORMS = ['instructions', 'catalog', 'snapshot', 'notice', 'relay', 'recall'] as const
/** One durable context form this UI version knows how to present. */
export type KnownContextForm = typeof KNOWN_FORMS[number]
/**
* Read the producer-declared form off one durable message source.
* @param source - the logged `user/message` source, exactly as recorded.
* @returns the form when this UI version presents it, otherwise null (opaque).
*/
export function contextForm(source: unknown): KnownContextForm | null {
const record = asRecord(source)
const form = record === null ? null : readString(record, 'form')
return form !== null && (KNOWN_FORMS as readonly string[]).includes(form)
? form as KnownContextForm
: null
}

View File

@@ -12,6 +12,7 @@ import type {
RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts'
export type { TodoItem }
/** Request configuration recorded for one provider call. */
@@ -102,6 +103,18 @@ export interface AssistantMessageNode {
interrupted?: true
}
/** A human message admitted from the next-step inbox while a turn was running. */
export interface SteeringMessageNode {
kind: 'steering'
/** Stable message identity shared with its pre-admission inbox occurrence. */
messageId: MessageId
seq: number
/** Unix epoch ms from the source session event. */
time: number
content: readonly ContentBlock[]
source: unknown
}
/** A context/system injection surfaced in the flow. */
export interface ContextMessageNode {
kind: 'context'
@@ -110,6 +123,10 @@ export interface ContextMessageNode {
time: number
content: readonly ContentBlock[]
source: unknown
/** Role and producer name projected from `source` ({@link contextProvenance}). */
provenance: ContextProvenanceView
/** Producer-declared information form ({@link contextForm}); null presents as opaque. */
form: KnownContextForm | null
}
/** Durable notice that a closed failed step is waiting for a model-request retry. */
@@ -223,6 +240,7 @@ export interface CommandNode {
export type ConversationNode =
| UserMessageNode
| AssistantMessageNode
| SteeringMessageNode
| ContextMessageNode
| ModelRetryNode
| TurnErrorNode

View File

@@ -0,0 +1,65 @@
/** Reconstruct durable steering identity from the event-sourced agent inbox. */
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
type InboxTarget = 'next-turn' | 'next-step'
/** Minimal pending identity retained while replaying durable inbox splices. */
interface PendingIdentity {
readonly id: string
}
/** Client-side structural view of the host-owned inbox event. */
interface InboxSplice {
readonly target: InboxTarget
readonly start: number
readonly removedCount?: number
readonly inserted: readonly PendingIdentity[]
readonly outcome?: 'canceled'
}
/**
* Incrementally identifies `user/message` events claimed from the next-step
* inbox. The agent loop records all admitted input as `user/message`; the
* preceding `agent/inbox/spliced` events preserve whether it came from the
* queued-turn list or the next-step list.
*/
export class SteeringHistory {
private readonly inbox: Record<InboxTarget, PendingIdentity[]> = {
'next-turn': [],
'next-step': [],
}
private readonly claimedNextStep = new Set<string>()
/** Clear all replay state before rebuilding a history window. */
reset(): void {
this.inbox['next-turn'] = []
this.inbox['next-step'] = []
this.claimedNextStep.clear()
}
/**
* Apply one event and report whether it is a durable human steering message.
* @param event - next raw session event in sequence order.
* @returns true only for a user-origin message previously claimed from `next-step`.
*/
apply(event: SessionEvent): boolean {
if ((event.type as string) === 'agent/inbox/spliced') {
this.applySplice(event.data as unknown as InboxSplice)
return false
}
if (event.type !== 'user/message') return false
const id = event.data.id
if (!this.claimedNextStep.delete(id)) return false
return event.data.source.kind === 'user'
}
/** Replay one host-validated inbox splice. */
private applySplice({ target, start, removedCount = 0, inserted, outcome }: InboxSplice): void {
const removed = this.inbox[target].splice(start, removedCount, ...inserted)
for (const identity of inserted) this.claimedNextStep.delete(identity.id)
if (target !== 'next-step' || outcome === 'canceled') return
for (const identity of removed) this.claimedNextStep.add(identity.id)
}
}

View File

@@ -22,6 +22,8 @@ import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpo
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
import { toAssistantBlocks } from './conversation.ts'
import { contextForm, contextProvenance } from './context-provenance.ts'
import { SteeringHistory } from './steering-history.ts'
import type { AssistantStepMetadata } from './assistant-timing.ts'
import { indexAssistantStepTiming, settledAssistantTiming } from './assistant-timing.ts'
@@ -46,11 +48,12 @@ interface CallIndexEntry {
callView: ToolCallView | null
}
/** One event -> UI node (pure function; the eight-variant ConversationNode union). */
/** One event -> UI node (pure function; the ten-variant ConversationNode union). */
function materializeNode(
event: SessionEvent,
callIndex: ReadonlyMap<string, CallIndexEntry>,
resultView: ToolResultView | null,
steering: boolean,
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
): ConversationNode {
switch (event.type) {
@@ -62,6 +65,15 @@ function materializeNode(
return {
kind: 'context', seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
provenance: contextProvenance(event.data.source),
form: contextForm(event.data.source),
}
}
if (steering) {
return {
kind: 'steering', messageId: event.data.id,
seq: event.seq, time: event.time,
content: event.data.content, source: event.data.source,
}
}
return {
@@ -178,6 +190,8 @@ export class TranscriptAdapter {
private stepTimings = new Map<string, AssistantStepMetadata>()
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
private resultViews = new Map<number, ToolResultView>()
/** Durable inbox replay used to distinguish next-step human input from queued prompts. */
private readonly steeringHistory = new SteeringHistory()
/**
* Command lifecycle nodes by commandId (insertion = run order). The
* `command/run`/`command/done` pair is log-only, so it is not a surface
@@ -206,6 +220,8 @@ export class TranscriptAdapter {
this.callIdx = new Map()
this.resultViews.clear()
this.commandIdx = new Map()
this.steeringHistory.reset()
const steeringSeqs = new Set<number>()
this.stepTimings = new Map()
for (let i = 0; i < events.length; i++) {
const event = events[i]
@@ -214,13 +230,14 @@ export class TranscriptAdapter {
this.eventIndex.set(event.seq, event)
this.indexCall(event, views?.[i])
this.indexCommand(event)
if (this.steeringHistory.apply(event)) steeringSeqs.add(event.seq)
indexAssistantStepTiming(this.stepTimings, event)
}
// Indexes first, then project: a tool/result materializes against the
// complete call index, and a checkpoint against the complete event index.
const projected: ConversationNode[] = []
for (const event of events) {
if (isTranscriptEvent(event)) projected.push(this.materialize(event))
if (isTranscriptEvent(event)) projected.push(this.materialize(event, steeringSeqs.has(event.seq)))
}
this.projected = projected
}
@@ -237,10 +254,11 @@ export class TranscriptAdapter {
append(event: SessionEvent, view?: ToolEventView): void {
this.eventIndex.set(event.seq, event)
this.indexCall(event, view)
const steering = this.steeringHistory.apply(event)
indexAssistantStepTiming(this.stepTimings, event)
if (this.indexCommand(event)) this.rev++
if (!isTranscriptEvent(event)) return
this.projected = [...this.projected, this.materialize(event)]
this.projected = [...this.projected, this.materialize(event, steering)]
this.rev++
}
@@ -273,10 +291,16 @@ export class TranscriptAdapter {
}
/** Materialize one transcript event against the complete current indexes. */
private materialize(event: SessionEvent): ConversationNode {
private materialize(event: SessionEvent, steering: boolean): ConversationNode {
return isCompactCheckpoint(event)
? materializeCompaction(event, this.eventIndex)
: materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null, this.stepTimings)
: materializeNode(
event,
this.callIdx,
this.resultViews.get(event.seq) ?? null,
steering,
this.stepTimings,
)
}
/**

View File

@@ -1,4 +1,4 @@
import { createMessage } from '@deepseek-ai/dsh-llm'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { describe, expect, it } from 'vitest'
import { projectConversationHistory } from '../src/client/session-history/history-fold.ts'
@@ -10,6 +10,49 @@ const at = (seq: number, event: Record<string, unknown>): SessionEvent =>
({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent
describe('projectConversationHistory', () => {
it('names an injected context node from its durable source, like the live adapter', () => {
// The fold declares its own node mapping (jscpd:ignore in the source), so
// the provenance projection is pinned on both sides independently.
const injected = at(0, {
type: 'user/message',
surfaceOp: 'append',
data: createUserMessage({
content: [{ type: 'text', text: '<available_skills>…</available_skills>' }],
// A plugin source, because the client program does not see the host
// packages that merge richer source kinds; those arms are pinned in
// context-provenance.spec.ts.
source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' },
}),
})
const { contexts } = projectConversationHistory([{ event: injected }])
expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{
kind: 'context',
seq: 0,
provenance: { role: 'inject', label: 'dsh-tool-skill' },
form: 'catalog',
}])
})
it('projects next-step human input as durable steering', () => {
const steering = createUserMessage({
content: [{ type: 'text', text: 'change course' }],
source: { kind: 'user' },
})
const events = [
at(0, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [steering],
} }),
at(1, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }),
at(2, { type: 'user/message', surfaceOp: 'append', data: steering }),
]
const projection = projectConversationHistory(events.map(event => ({ event })))
expect(projection.eventNodes).toMatchObject([{
kind: 'steering', messageId: steering.id, seq: 2,
}])
})
it('projects a high-sequence history window without synthesizing its unloaded prefix', () => {
const baseSeq = 400_000
const events = [

View File

@@ -85,22 +85,85 @@ describe('TranscriptAdapter', () => {
it('materializes every append-origin variant with field mapping', () => {
const adapter = new TranscriptAdapter()
const steering = createUserMessage({
content: [{ type: 'text', text: '插话' }],
source: { kind: 'user' },
})
adapter.reset([
ev.user(0, '用户'),
ev.assistant(1, 0, '助手'),
at(2, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
at(2, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [steering],
} }),
at(3, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }),
at(4, { type: 'user/message', surfaceOp: 'append', data: steering }),
at(5, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '上下文' }], source: { kind: 'plugin', plugin: 'p' },
}) }),
ev.toolCall(3, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(4, 0, 'c1', '结果'),
ev.toolCall(6, 0, 'c1', 'echo', '{"x":1}'),
ev.toolResult(7, 0, 'c1', '结果'),
])
const nodes = adapter.nodes()
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'context', 'tool-result'])
expect(nodes.map(n => n.kind)).toEqual(['user', 'assistant', 'steering', 'context', 'tool-result'])
expect(nodes.find(n => n.kind === 'steering')).toMatchObject({ messageId: steering.id })
expect(nodes.find(n => n.kind === 'tool-result')).toMatchObject({
callId: 'c1', call: { name: 'echo', argsRaw: '{"x":1}' }, isError: false,
})
})
it('identifies steering on the live append path', () => {
const adapter = new TranscriptAdapter()
const steering = createUserMessage({
content: [{ type: 'text', text: 'live steer' }],
source: { kind: 'user' },
})
adapter.reset([])
adapter.append(at(0, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [steering],
} }))
adapter.append(at(1, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }))
adapter.append(at(2, { type: 'user/message', surfaceOp: 'append', data: steering }))
expect(adapter.nodes()).toMatchObject([{ kind: 'steering', messageId: steering.id }])
})
it('does not mark queued, canceled, or non-user next-step messages as steering', () => {
const adapter = new TranscriptAdapter()
const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } })
const canceled = createUserMessage({ content: [{ type: 'text', text: 'canceled' }], source: { kind: 'user' } })
const context = createUserMessage({
content: [{ type: 'text', text: 'context' }],
source: { kind: 'plugin', plugin: 'test' },
})
adapter.reset([
at(0, { type: 'agent/inbox/spliced', data: {
target: 'next-turn', start: 0, inserted: [queued],
} }),
at(1, { type: 'agent/inbox/spliced', data: {
target: 'next-turn', start: 0, removedCount: 1, inserted: [],
} }),
at(2, { type: 'user/message', surfaceOp: 'append', data: queued }),
at(3, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [canceled],
} }),
at(4, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
} }),
at(5, { type: 'user/message', surfaceOp: 'append', data: canceled }),
at(6, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, inserted: [context],
} }),
at(7, { type: 'agent/inbox/spliced', data: {
target: 'next-step', start: 0, removedCount: 1, inserted: [],
} }),
at(8, { type: 'user/message', surfaceOp: 'append', data: context }),
])
expect(adapter.nodes().map(node => node.kind)).toEqual(['user', 'user', 'context'])
})
it('skips events core does not call surface-eligible, marker or not', () => {
// The transcript is the append-origin surface, so log-only events (a chunk,
// a turn boundary, a compact/* provenance record) and a future type core
@@ -198,10 +261,15 @@ describe('TranscriptAdapter', () => {
adapter.reset([
at(0, { type: 'user/message', surfaceOp: 'append', data: createUserMessage({
content: [{ type: 'text', text: '注入的上下文' }],
source: { kind: 'plugin', plugin: 'compact' },
source: { kind: 'plugin', plugin: 'compact', form: 'instructions' },
}) }),
])
expect(adapter.nodes()).toMatchObject([{ kind: 'context', seq: 0 }])
expect(adapter.nodes()).toMatchObject([{
kind: 'context',
seq: 0,
provenance: { role: 'inject', label: 'compact' },
form: 'instructions',
}])
})
it('ignores a foreign plugin s replacement user/message', () => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: e02c569bb0efe9219844d86e029f499ce406929d
README.zh.md: 884dcfccd7b1af7a674a126b14bc2c3b08b3d3b0
README.md: b4cbfc9c9fde730ff44fc63bd87194b37d518919
README.zh.md: f11fb1133655ce8ee73507bc926a38e1e5274e4e

View File

@@ -14,7 +14,7 @@ Approvals take over the composer through the chain this package declares: `Appro
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([disclosure decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).

View File

@@ -12,7 +12,7 @@
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动并以内联 JSON 展示 `content``source`且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)
已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill技能目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动且不会合成工具状态、摘要或键控 toolview 分发([展开项决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区即按真实换行展示面向模型的文本并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering中途引导气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识
Think 行默认保持折叠并在不展开思维链的情况下暴露实时推理reasoning吞吐当推理块是流式输出尾部时摘要从结算后的首行切换到最新的非空行其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
@@ -36,7 +36,7 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注入,不依赖 `ConversationService``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 经 `toolviews/plan-summary.ts``planSummary` 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`,以及「其余活跃项的数量」`+<n>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。部署允许并行工作时,可以有多个条目同时处于 `in_progress`,因此 `planSummary` 给出第一个活跃条目并计数其余,且刻意不把两者拼成一个字符串:行会对摘要文本做省略号截断,把数量接在任务名末尾时,窄行最先裁掉的正是这个数量。该行把数量交给 `ToolRow``summarySuffix`——共享行在被截断文本旁的不收缩位(出错的行会丢弃它,因为其折叠摘要是失败首行)。`TodoDock``order: 0` 占用 `'conversation.input.dock'` 列表 slot位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加它自行计算的、以 `·` 连接的各状态计数(本地化,形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略;状态图标为 figma 的勾选/进行中/虚线未开始一组),因此它无需一个可被截断的任务名即可报告并行数量。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
`QueueDock``order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded``aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering(中途引导)操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
`QueueDock``order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded``aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering 操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。

View File

@@ -0,0 +1,161 @@
/* Expanded context bodies: one code-block surface shared by every form, so the
disclosure keeps the Figma 10:2482 geometry whichever form renders inside. */
.text {
margin: 0;
color: var(--dsw-alias-label-secondary);
font: inherit;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* Provenance beneath the text: dimmer than the content it describes. */
.fields {
display: flex;
flex-direction: column;
gap: 2px;
margin: 8px 0 0;
padding-top: 8px;
border-top: 1px solid var(--dsw-alias-line-secondary);
}
.field {
display: flex;
gap: 8px;
min-width: 0;
}
.fieldKey {
flex: none;
min-width: 96px;
color: var(--dsw-alias-label-caption);
}
.fieldValue {
flex: 1 1 auto;
min-width: 0;
margin: 0;
color: var(--dsw-alias-label-tertiary);
overflow-wrap: anywhere;
}
/* instructions: the reconciled files, above their text. */
.files {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin: 0 0 8px;
padding: 0;
list-style: none;
}
.file {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.filePath {
color: var(--dsw-alias-label-secondary);
overflow-wrap: anywhere;
}
.fileAction {
color: var(--dsw-alias-label-caption);
}
/* catalog: a replacement notice above one row per published entry. */
.catalogNotice {
margin: 0 0 6px;
color: var(--dsw-alias-label-caption);
}
.entries {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.entry {
display: flex;
gap: 8px;
min-width: 0;
}
.entryName {
flex: none;
color: var(--dsw-alias-label-secondary);
}
.entryDescription {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis;
white-space: nowrap;
}
/* snapshot: one titled block per contributing subsystem. */
.sections {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
}
.section {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sectionName {
color: var(--dsw-alias-label-caption);
}
.sectionText {
margin: 0;
color: var(--dsw-alias-label-secondary);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* relay: who sent this, above what they said. */
.relaySender {
margin: 0 0 6px;
color: var(--dsw-alias-label-caption);
overflow-wrap: anywhere;
}
/* recall: one row per source session, with how much of it survived. */
.recalls {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0 0 8px;
padding: 0;
list-style: none;
}
.recall {
display: flex;
gap: 8px;
min-width: 0;
}
.recallLabel {
color: var(--dsw-alias-label-secondary);
overflow-wrap: anywhere;
}
.recallCounts {
flex: none;
color: var(--dsw-alias-label-caption);
}

View File

@@ -0,0 +1,591 @@
// Expanded bodies for the context disclosure, one per durable context form.
// The producer declares the form; this module only chooses a presentation for
// it. Every form falls back to OpaqueBody, which is the documented default for
// an absent, unknown, or malformed form — a resumed or foreign log must render
// even when this UI version has never seen its producer.
import type { ReactNode } from 'react'
import type { ContextMessageNode, KnownContextForm } from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import css from './ContextBody.module.css'
/** Model-facing text stays bounded at the disclosure, not at the producer. */
const MAX_CHARS = 20_000
/** Rows a list body materializes before summarizing the remainder. */
const MAX_ENTRIES = 200
type Translate = ChatViewSlotProps['t']
/** One durable source narrowed to the readable-record shape; null for anything else. */
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
/** One run of the model-facing content: adjacent text, or one unknown block. */
type ContentRun = { text: string } | { block: unknown }
/**
* The content blocks as runs, IN THE ORDER the model received them.
*
* Adjacent text blocks join with no separator, matching how provider adapters
* flatten them — inserting a line break would show the reader a line the model
* never saw. An unknown block breaks the run and keeps its own fallback rather
* than being hoisted past the text around it or vanishing; the block union is
* merge-extensible, so a foreign log may interleave shapes this build does not
* know.
*/
function contentRuns(content: ContextMessageNode['content']): ContentRun[] {
const runs: ContentRun[] = []
for (const block of content) {
if (block.type !== 'text') {
runs.push({ block })
continue
}
const last = runs[runs.length - 1]
if (last !== undefined && 'text' in last) last.text += block.text
else runs.push({ text: block.text })
}
return runs
}
/** Only the blocks this UI version does not know, for bodies that replace the text. */
function unknownBlocks(content: ContextMessageNode['content']): unknown[] {
return contentRuns(content).flatMap(run => 'block' in run ? [run.block] : [])
}
/** The model-facing text, truncated to the display bound. */
function boundedText(text: string, t: Translate): string {
return text.length > MAX_CHARS
? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}`
: text
}
/**
* One source field rendered as a value row; nested shapes stay compact JSON.
* Bounded on its own, because provenance is as unbounded as the text: an unknown
* producer may record an arbitrarily large string or array.
*/
function fieldValue(value: unknown, t: Translate): string {
const text = typeof value === 'string'
? value
: typeof value === 'number' || typeof value === 'boolean' ? String(value) : JSON.stringify(value)
return boundedText(text, t)
}
/**
* Provenance fields as a key/value list. `kind` is always omitted because the
* row header already names the producer. `form` is omitted only when a
* dedicated body rendered for it — then the presentation the reader is looking
* at IS that value. On the opaque fallback the declaration is kept, because
* that is the one place a form this version cannot present would otherwise
* disappear from the UI entirely.
*/
function SourceFields({ source, formRendered, t }: {
source: unknown
formRendered: boolean
t: Translate
}): ReactNode {
const record = asRecord(source)
if (record === null) return null
const hidden = formRendered ? ['kind', 'form'] : ['kind']
const rows = Object.entries(record).filter(([key]) => !hidden.includes(key))
if (rows.length === 0) return null
return (
<dl className={css.fields} data-context-fields>
{rows.map(([key, value]) => (
<div key={key} className={css.field}>
<dt className={css.fieldKey}>{key}</dt>
<dd className={css.fieldValue}>{fieldValue(value, t)}</dd>
</div>
))}
</dl>
)
}
/**
* Content blocks this UI version does not know, kept visible rather than
* dropped: the block union is merge-extensible, so a newer or foreign log may
* carry a shape this build has no presentation for.
* @param props - The unrecognized blocks and the locale seat.
* @returns One generic JSON block per unknown entry.
*/
function UnknownBlocks({ blocks, t }: { blocks: readonly unknown[]; t: Translate }): ReactNode {
return (
<>
{blocks.map((block, index) => (
<JsonBlock
key={index}
label={t('message.unknownBlock')}
payload={block}
truncatedLabel={total => t('json.truncated', { total })}
/>
))}
</>
)
}
/**
* The model-facing content of one context, shared by every form that shows it:
* the text with its real line breaks, then any block this UI version does not
* know, which keeps its own fallback rather than vanishing.
* @param props - Durable content and the locale seat.
* @returns The content blocks as the model received them.
*/
function ModelFacingContent({ content, t }: {
content: ContextMessageNode['content']
t: Translate
}): ReactNode {
return (
<>
{contentRuns(content).map((run, index) => ('text' in run
? run.text !== '' && (
<pre key={index} className={css.text} data-context-text>{boundedText(run.text, t)}</pre>
)
: (
<JsonBlock
key={index}
label={t('message.unknownBlock')}
payload={run.block}
truncatedLabel={total => t('json.truncated', { total })}
/>
)))}
</>
)
}
/**
* Default presentation: the model-facing text as text, with its real line
* breaks, and the remaining provenance beneath it. This is what every form
* this UI version does not recognize renders as.
* @param props - Durable content, its source, and the locale seat.
* @returns The opaque context body.
*/
export function OpaqueBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
return (
<>
<ModelFacingContent content={content} t={t} />
<SourceFields source={source} formRendered={false} t={t} />
</>
)
}
/** One reconciled instruction file, as the durable source records it. */
interface InstructionChange {
action: 'set' | 'replace' | 'remove'
path: string
digest?: string
}
/**
* Instruction changes read off the source, or null when the record is not a
* usable instruction list.
*
* The read is all-or-nothing: silently dropping one unreadable entry would show
* a confident, incomplete file list for a log this version cannot fully read.
* Paths are deduplicated in first-seen order, matching how the header label is
* derived from the same array.
*/
function instructionChanges(source: unknown): InstructionChange[] | null {
const record = asRecord(source)
const list = record === null ? undefined : record['changes']
if (!Array.isArray(list)) return null
const changes: InstructionChange[] = []
const seen = new Set<string>()
for (const entry of list as readonly unknown[]) {
const change = asRecord(entry)
if (change === null) return null
const path = change['path']
if (typeof path !== 'string' || path === '') return null
const action = change['action']
// The action decides which word the row shows, so an unrecognized one is
// not a readable change — it would be presented as loaded or updated.
if (action !== 'set' && action !== 'replace' && action !== 'remove') return null
const digest = change['digest']
if (seen.has(path)) continue
seen.add(path)
changes.push({ action, path, ...typeof digest === 'string' ? { digest } : {} })
}
return changes.length === 0 ? null : changes
}
/**
* Locale key for one reconciled file. The baseline loads a file; a later delta
* distinguishes a newly reconciled path from a rewritten one, which `set` and
* `replace` already separate at the producer.
* @param action - the durable change action.
* @param baseline - whether this context is the startup/resume baseline.
* @returns the key naming what happened to that file.
*/
function instructionAction(
action: InstructionChange['action'],
baseline: boolean,
): 'message.context.instructions.removed' | 'message.context.instructions.loaded'
| 'message.context.instructions.added' | 'message.context.instructions.updated' {
if (action === 'remove') return 'message.context.instructions.removed'
if (baseline) return 'message.context.instructions.loaded'
return action === 'set' ? 'message.context.instructions.added' : 'message.context.instructions.updated'
}
/**
* `instructions` form: the files this context reconciled, then their text.
*
* The text keeps its `<system-reminder>` framing verbatim — the framing is part
* of what the model read, so hiding it would misreport the request.
* @param props - Durable content, its source, and the locale seat.
* @returns The instructions context body, or the opaque body when the change
* list is unreadable.
*/
export function InstructionsBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const changes = instructionChanges(source)
if (changes === null) return <OpaqueBody content={content} source={source} t={t} />
const baseline = asRecord(source)?.['baseline'] === true
return (
<>
<ul className={css.files} data-context-files>
{changes.map(change => (
<li key={change.path} className={css.file} title={change.digest}>
<span className={css.filePath}>{change.path}</span>
<span className={css.fileAction}>
{t(instructionAction(change.action, baseline))}
</span>
</li>
))}
</ul>
<ModelFacingContent content={content} t={t} />
</>
)
}
/** One catalog entry, as the durable source records it. */
interface CatalogEntry {
name: string
description: string
}
/**
* Catalog entries read off the source, or null when the record is not a usable
* catalog. All-or-nothing for the same reason as the instruction list: this body
* replaces the model-facing text, so a partial list would hide the only complete
* account of what the model read.
*/
function catalogEntries(source: unknown): CatalogEntry[] | null {
const record = asRecord(source)
const list = record === null ? undefined : record['entries']
if (!Array.isArray(list)) return null
const entries: CatalogEntry[] = []
for (const item of list as readonly unknown[]) {
const entry = asRecord(item)
if (entry === null) return null
const name = entry['name']
const description = entry['description']
if (typeof name !== 'string' || name === '' || typeof description !== 'string') return null
entries.push({ name, description })
}
// An empty list is a real catalog: a replacement with no entries retires
// every earlier name. Only an unreadable shape falls back.
return entries
}
/**
* `catalog` form: the published entries as a list, read from the source rather
* than re-parsed out of the model-facing prose.
*
* A catalog whose source carries no usable entries falls through to the opaque
* body, so an older or hand-edited log still shows its text.
* @param props - Durable content, its source, and the locale seat.
* @returns The catalog context body, or the opaque body when the entry list is
* unreadable.
*/
export function CatalogBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const entries = catalogEntries(source)
if (entries === null) return <OpaqueBody content={content} source={source} t={t} />
const update = asRecord(source)?.['update'] === true
// Entry count is unbounded (a provider may publish any number of skills), and
// the scrollport bounds height, not node count — so the list bounds itself.
const shown = entries.slice(0, MAX_ENTRIES)
const rest = unknownBlocks(content)
return (
<>
{update && <p className={css.catalogNotice} data-context-catalog-update>{t('message.context.catalog.replaced')}</p>}
<ul className={css.entries} data-context-entries>
{shown.map((entry, index) => (
// Index key: a hand-edited or foreign log may repeat a name, and a
// duplicate React key would drop a row the model did see.
<li key={index} className={css.entry}>
<code className={css.entryName}>{entry.name}</code>
<span className={css.entryDescription}>{entry.description}</span>
</li>
))}
</ul>
{shown.length < entries.length && (
<p className={css.catalogNotice} data-context-entries-truncated>
{t('message.context.catalog.more', { count: entries.length - shown.length })}
</p>
)}
{/* The block union is merge-extensible: a catalog message carrying an
unknown block still shows it rather than dropping model-visible content. */}
<UnknownBlocks blocks={rest} t={t} />
</>
)
}
/** One named contribution to a runtime snapshot, as the durable source records it. */
interface SnapshotSection {
name: string
text: string
}
/** Snapshot sections read off the source, or null when the record is unusable. */
function snapshotSections(source: unknown): SnapshotSection[] | null {
const record = asRecord(source)
const list = record === null ? undefined : record['sections']
if (!Array.isArray(list)) return null
const sections: SnapshotSection[] = []
for (const item of list as readonly unknown[]) {
const section = asRecord(item)
if (section === null) return null
const name = section['name']
const text = section['text']
if (typeof name !== 'string' || name === '' || typeof text !== 'string') return null
sections.push({ name, text })
}
return sections.length === 0 ? null : sections
}
/**
* `snapshot` form: the named contributions this snapshot assembled, in order.
*
* The sections are the same bytes the model read, split at the boundaries the
* producer assembled them on, so a reader sees which subsystem contributed
* which state instead of one undifferentiated wall.
*
* One sentence of the model-facing text is NOT in any section: the producer's
* framing line declaring that this snapshot supersedes earlier ones. Unlike the
* `<system-reminder>` wrapper an instruction context carries — which wraps
* content and cannot be separated from it — that line states the form's own
* semantics, so the body states them as a caption instead of reprinting the
* joined prose beside the sections it was split from.
* @param props - Durable content, its source, and the locale seat.
* @returns The snapshot context body, or the opaque body when unreadable.
*/
export function SnapshotBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const sections = snapshotSections(source)
/* v8 ignore next -- contextBody reads the sections before choosing this body. */
if (sections === null) return <OpaqueBody content={content} source={source} t={t} />
return (
<>
<p className={css.catalogNotice} data-context-snapshot-supersedes>
{t('message.context.snapshot.supersedes')}
</p>
<dl className={css.sections} data-context-sections>
{sections.map((section, index) => (
<div key={index} className={css.section}>
<dt className={css.sectionName}>{section.name}</dt>
<dd className={css.sectionText}>{boundedText(section.text, t)}</dd>
</div>
))}
</dl>
</>
)
}
/**
* `notice` form: what just happened, with the model-facing text beneath it.
*
* The one-line account also rides the collapsed row ({@link contextBody}), so a
* notice is usually readable without expanding at all.
* @param props - Durable content, its source, and the locale seat.
* @returns The notice context body.
*/
export function NoticeBody({ content, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
return <ModelFacingContent content={content} t={t} />
}
/**
* `relay` form: which agent sent this, then what it said.
*
* The sender is an opaque session id; it is shown as provenance rather than a
* label, because this client cannot resolve it to a title.
* @param props - Durable content, its source, and the locale seat.
* @returns The relay context body.
*/
export function RelayBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const sender = relaySender(source)
/* v8 ignore next -- contextBody resolves the sender before choosing this body. */
if (sender === null) return <OpaqueBody content={content} source={source} t={t} />
return (
<>
<p className={css.relaySender} data-context-relay-sender>
{t('message.context.relay.from', { session: sender })}
</p>
<ModelFacingContent content={content} t={t} />
</>
)
}
/** The sending agent's session id, or null when the record does not name one. */
function relaySender(source: unknown): string | null {
const sender = asRecord(source)?.['senderSessionId']
return typeof sender === 'string' && sender !== '' ? sender : null
}
/** One recalled session, as the durable source records it. */
interface RecalledSession {
label: string
retained: number
omitted: number
truncated: boolean
}
/** Recalled sessions read off the source, or null when the record is unusable. */
function recalledSessions(source: unknown): RecalledSession[] | null {
const record = asRecord(source)
const list = record === null ? undefined : record['references']
if (!Array.isArray(list)) return null
const sessions: RecalledSession[] = []
for (const item of list as readonly unknown[]) {
const reference = asRecord(item)
if (reference === null) return null
const label = reference['label']
const retained = reference['retainedMessages']
const omitted = reference['omittedMessages']
const truncated = reference['truncated']
// Completeness is the fact this card exists to report, so a reference that
// cannot state it is not a readable recall — showing the label alone would
// present a confident card over unknown loss.
if (typeof label !== 'string' || label === ''
|| typeof retained !== 'number' || typeof omitted !== 'number'
|| typeof truncated !== 'boolean') return null
sessions.push({ label, retained, omitted, truncated })
}
return sessions.length === 0 ? null : sessions
}
/**
* `recall` form: which sessions this material came from and how much of each
* survived the read, then the material itself.
*
* Completeness is the fact a reader needs first: recalled context is bounded on
* the way in, so a card that hid the omitted count would overstate what the
* model received.
* @param props - Durable content, its source, and the locale seat.
* @returns The recall context body, or the opaque body when unreadable.
*/
export function RecallBody({ content, source, t }: {
content: ContextMessageNode['content']
source: unknown
t: Translate
}): ReactNode {
const sessions = recalledSessions(source)
if (sessions === null) return <OpaqueBody content={content} source={source} t={t} />
return (
<>
<ul className={css.recalls} data-context-recalls>
{sessions.map((session, index) => (
<li key={index} className={css.recall}>
<span className={css.recallLabel}>{session.label}</span>
<span className={css.recallCounts}>
{t('message.context.recall.counts', {
retained: session.retained,
omitted: session.omitted,
})}
</span>
{session.truncated && (
<span className={css.recallCounts}>{t('message.context.recall.truncated')}</span>
)}
</li>
))}
</ul>
<ModelFacingContent content={content} t={t} />
</>
)
}
/** The one-line account a `notice` puts on its collapsed row, when it records one. */
function noticeSummary(source: unknown): string | null {
const summary = asRecord(source)?.['summary']
return typeof summary === 'string' && summary !== '' ? summary : null
}
/**
* Choose the body for one context node.
*
* Returns the form the body actually rendered as, which is not always the
* declared one: a declared form whose fields are unreadable falls back to
* opaque, and the caller labels the row with what it really shows.
* `summary` is the collapsed row's one-line account, which only a `notice`
* records: its whole point is being readable without expanding.
* @param form - the producer-declared form projected onto the node.
* @param props - durable content, its source, and the locale seat.
* @returns the rendered form (null for opaque), its collapsed summary, and its body.
*/
export function contextBody(
form: ContextMessageNode['form'],
props: { content: ContextMessageNode['content']; source: unknown; t: Translate },
): { rendered: KnownContextForm | null; summary: string | null; body: ReactNode } {
const opaque = { rendered: null, summary: null, body: <OpaqueBody {...props} /> }
switch (form) {
case 'instructions':
return instructionChanges(props.source) === null
? opaque
: { rendered: 'instructions', summary: null, body: <InstructionsBody {...props} /> }
case 'catalog':
return catalogEntries(props.source) === null
? opaque
: { rendered: 'catalog', summary: null, body: <CatalogBody {...props} /> }
case 'snapshot':
return snapshotSections(props.source) === null
? opaque
: { rendered: 'snapshot', summary: null, body: <SnapshotBody {...props} /> }
case 'notice': {
const summary = noticeSummary(props.source)
return summary === null
? opaque
: { rendered: 'notice', summary, body: <NoticeBody {...props} /> }
}
case 'relay':
return relaySender(props.source) === null
? opaque
: { rendered: 'relay', summary: null, body: <RelayBody {...props} /> }
case 'recall':
return recalledSessions(props.source) === null
? opaque
: { rendered: 'recall', summary: null, body: <RecallBody {...props} /> }
case null:
return opaque
/* v8 ignore next 4 -- closed-union backstop; the compiler rejects a new
KnownContextForm here rather than letting it degrade to opaque silently. */
default: {
const unreachable: never = form
throw new Error(`unreachable context form: ${String(unreachable)}`)
}
}
}

View File

@@ -12,6 +12,40 @@
color: var(--dsw-alias-label-secondary);
}
/* Separator and producer name beside the role title: ToolRow's summary geometry,
so the two disclosure rows keep one 24px rhythm and one separator shape. */
.sep {
flex: none;
width: 2px;
height: 2px;
margin: 0 8px;
border-radius: 1px;
background: var(--dsw-alias-label-caption);
}
.source {
flex: none;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* A notice's one-line account: the reason it rarely needs expanding. */
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-size: 14px;
line-height: 24px;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
box-sizing: border-box;
width: calc(100% - 22px);
@@ -23,7 +57,6 @@
border-radius: 8px;
background: var(--dsw-alias-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
/* Figma 10:2482 code text: the form bodies inherit it from the scrollport. */
font: 400 11px/16px var(--ds-font-family-code);
white-space: pre-wrap;
overflow-wrap: anywhere;
}

View File

@@ -1,84 +1,70 @@
import { useMemo, useState } from 'react'
import { useState } from 'react'
import type { ContextMessageNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow } from './DisclosureRow.tsx'
import { contextBody } from './ContextBody.tsx'
import css from './ContextInjectionRow.module.css'
const MAX_CHARS = 20_000
function inlineJson(payload: unknown): string {
const raw = JSON.stringify(payload)
let formatted = ''
let quoted = false
let escaped = false
for (let index = 0; index < raw.length; index++) {
const char = raw.charAt(index)
if (quoted) {
formatted += char
if (escaped) escaped = false
else if (char === '\\') escaped = true
else if (char === '"') quoted = false
continue
}
if (char === '"') {
quoted = true
formatted += char
continue
}
if (char === '{' || char === '[') {
formatted += char
const close = char === '{' ? '}' : ']'
if (raw[index + 1] !== close) formatted += ' '
continue
}
if (char === '}' || char === ']') {
const open = char === '}' ? '{' : '['
if (raw[index - 1] !== open) formatted += ' '
formatted += char
continue
}
formatted += char === ':' || char === ',' ? `${char} ` : char
}
return formatted
}
/** Props for the logged non-user message presentation. */
export interface ContextInjectionRowProps {
content: ContextMessageNode['content']
source: ContextMessageNode['source']
/** Role and producer name projected from the durable source. */
provenance: ContextMessageNode['provenance']
/** Producer-declared information form; null renders the opaque body. */
form: ContextMessageNode['form']
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
/**
* Render logged context with the Tool calls disclosure chrome from Figma.
* @param props - Durable content and source provenance.
* @returns A collapsed context row with a bounded JSON body.
*
* The header names the role the context plays and, beside it, the producer the
* durable source identifies, so a reader can tell an injected skill catalog
* from a workspace instruction file or a recalled session without expanding.
* The expanded body follows the producer-declared form; an absent or unknown
* form renders the opaque body.
* @param props - Durable content, its projected provenance and form, and the locale seat.
* @returns A collapsed context row with a bounded, form-specific body.
*/
export function ContextInjectionRow({ content, source, t }: ContextInjectionRowProps) {
export function ContextInjectionRow({ content, source, provenance, form, t }: ContextInjectionRowProps) {
const [open, setOpen] = useState(false)
const body = useMemo(() => {
if (!open) return ''
const text = inlineJson({ content, source })
return text.length > MAX_CHARS
? `${text.slice(0, MAX_CHARS)}\n${t('json.truncated', { total: text.length })}`
: text
}, [content, open, source, t])
// Resolved rather than declared: a form whose fields are unreadable renders
// the opaque body, and the marker must say what the row actually shows.
const { rendered, summary, body } = contextBody(form, { content, source, t })
return (
<DisclosureRow
className={css.root}
icon={<IconBrowseOutline16 size={14} />}
chevronClassName={css.chevron}
title={t('message.contextInjection')}
title={t(provenance.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')}
collapsedContent={provenance.label === null ? undefined : (
/* ToolRow's separator shape: an aria-hidden dot, so the accessible name
stays the two readable parts and the two disclosure rows expose one
name shape. A source that names no producer drops the dot with it. */
<>
<span className={css.sep} aria-hidden />
<span className={css.source} data-context-source>{provenance.label}</span>
{summary !== null && (
<>
<span className={css.sep} aria-hidden />
<span className={css.summary} data-context-summary>{summary}</span>
</>
)}
</>
)}
keepContentWhenOpen
open={open}
expandable
expandOnRowClick
onToggle={() => { setOpen(value => !value) }}
>
<pre className={css.body} data-context-injection-body>{body}</pre>
<div className={css.body} data-context-injection-body data-context-form={rendered ?? undefined}>
{body}
</div>
</DisclosureRow>
)
}

View File

@@ -8,6 +8,15 @@
gap: 6px;
}
/* Steering caption above the bubble: mid-turn interjections carry the same
bubble as a turn-opening prompt, so the transcript names which one this is. */
.steeringMark {
padding-right: 4px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 16px;
}
.bubble {
/* 525px cap inside the 736 column; percentage keeps narrow windows sane. */
max-width: min(525px, 82%);

View File

@@ -1,12 +1,13 @@
// MessageItem: simple chat nodes — user bubbles
// (right-aligned, with clock + copy / branch IconActions), pending steering
// (copy only), context injection, compaction marker, retry disclosure, and
// unknown-surface JSON rows.
// MessageItem: simple chat nodes — user and consumed-steering bubbles
// (right-aligned, with clock + copy / branch IconActions; steering adds the
// interjection caption that names it), pending steering (caption + copy only),
// context injection, compaction marker, retry disclosure, and unknown-surface
// JSON rows.
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type {
CompactionSummaryNode, ContextMessageNode, ModelRetryNode,
CompactionSummaryNode, ContextMessageNode, ModelRetryNode, SteeringMessageNode,
TurnErrorNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -19,6 +20,7 @@ import css from './MessageItem.module.css'
export interface MessageItemProps {
node:
| UserMessageNode
| SteeringMessageNode
| ContextMessageNode
| CompactionSummaryNode
| ModelRetryNode
@@ -170,19 +172,22 @@ function projectUserText(text: string): ReactNode {
/** Right-aligned bubble shared by user and steering rows. */
function UserStyleBubble({
content, actions, pending = false, t,
content, actions, pending = false, steering = false, t,
}: {
content: readonly unknown[]
/** Optional IconActions (or similar) below the bubble; receives the joined text. */
actions?: (text: string) => ReactNode
/** Whether this is the Host-authoritative pre-admission steering projection. */
pending?: boolean
/** Marks the bubble as mid-turn steering rather than a turn-opening prompt. */
steering?: boolean
t: ChatViewSlotProps['t']
}): ReactNode {
const { text, rest } = contentText(content)
const truncated = (total: number): string => t('json.truncated', { total })
return (
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
{steering && <span className={css.steeringMark} data-steering-mark>{t('message.steering')}</span>}
<div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
@@ -206,6 +211,7 @@ export function PendingSteeringBubble({ content, t }: {
<UserStyleBubble
content={content}
pending
steering
t={t}
actions={text => (
<MessageIconActions
@@ -226,9 +232,11 @@ export const MessageItem = memo(function MessageItem({
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
case 'user':
case 'steering':
return (
<UserStyleBubble
content={node.content}
steering={node.kind === 'steering'}
t={t}
actions={text => (
<MessageIconActions
@@ -245,7 +253,13 @@ export const MessageItem = memo(function MessageItem({
)
case 'context':
return (
<ContextInjectionRow content={node.content} source={node.source} t={t} />
<ContextInjectionRow
content={node.content}
source={node.source}
provenance={node.provenance}
form={node.form}
t={t}
/>
)
case 'compaction':
return <CompactionItem node={node} t={t} />

View File

@@ -86,7 +86,7 @@ export function messageBranchSeqs(
tail = candidate
nodeIndex++
}
if (tail?.kind === 'user'
if (tail?.kind === 'user' || tail?.kind === 'steering'
|| (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) {
result.add(tail.seq)
}

View File

@@ -66,6 +66,18 @@ export const zh = {
'chat.toBottom': '回到底部',
'message.extraBlock': '附加内容块',
'message.contextInjection': '上下文注入',
'message.contextRecall': '跨会话召回',
'message.context.instructions.loaded': '已载入',
'message.context.instructions.added': '已新增',
'message.context.instructions.updated': '已更新',
'message.context.instructions.removed': '已移除',
'message.context.catalog.replaced': '替换目录',
'message.context.catalog.more': '…还有 {count} 条',
'message.context.snapshot.supersedes': '取代先前的快照',
'message.context.relay.from': '来自会话 {session}',
'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条',
'message.context.recall.truncated': '已截断',
'message.steering': '插话',
'message.compaction': '上下文已压缩',
'message.compaction.expand': '点击查看压缩摘要',
'message.compaction.unavailable': '压缩摘要不可用',
@@ -193,6 +205,18 @@ export const en = {
'chat.toBottom': 'Back to bottom',
'message.extraBlock': 'Extra content block',
'message.contextInjection': 'Context injection',
'message.contextRecall': 'Session recall',
'message.context.instructions.loaded': 'loaded',
'message.context.instructions.added': 'added',
'message.context.instructions.updated': 'updated',
'message.context.instructions.removed': 'removed',
'message.context.catalog.replaced': 'Replacement catalog',
'message.context.catalog.more': '… {count} more',
'message.context.snapshot.supersedes': 'Supersedes earlier snapshots',
'message.context.relay.from': 'From session {session}',
'message.context.recall.counts': '{retained} kept · {omitted} omitted',
'message.context.recall.truncated': 'truncated',
'message.steering': 'Interjection',
'message.compaction': 'Context compacted',
'message.compaction.expand': 'View compaction summary',
'message.compaction.unavailable': 'Compaction summary unavailable',

View File

@@ -212,47 +212,504 @@ describe('MessageItem arms', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => {
it('consumed steering is captioned as an interjection and keeps copy and branch actions', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
const fork = vi.fn()
const view = render(
<MessageItem t={t} node={{
kind: 'steering', messageId: 'steer-message', seq: 2, time: 1_000, turn: 1, source: null,
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
} as never}
onFork={fork}
/>,
)
expect(view.getByText('插话')).toBeTruthy()
expect(view.getByText('steer!')).toBeTruthy()
expect(view.getByText(/附加内容块/)).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('steer!')
fireEvent.click(view.getByRole('button', { name: '在新对话中分支' }))
expect(fork).toHaveBeenCalledWith(2)
})
it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => {
const ctxView = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'x\n"y":,[{}]' }],
content: [{ type: 'text', text: 'line one\n\nline two' }],
source: { kind: 'plugin', plugin: 'fixture', empty: {}, list: [] },
provenance: { role: 'inject', label: 'fixture' },
form: null,
} as never}
/>,
)
const disclosure = ctxView.getByRole('button', { name: '上下文注入' })
const disclosure = ctxView.getByRole('button', { name: /^上下文注入\s*fixture$/ })
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull()
expect(ctxView.container.querySelector('svg')).not.toBeNull()
fireEvent.click(disclosure)
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe(
'{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], '
+ '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }',
)
// An unknown form renders the opaque body: the model-facing text keeps its
// real line breaks instead of being escaped into one JSON line, and the
// remaining provenance follows it as fields.
expect(ctxView.container.querySelector('[data-context-text]')?.textContent)
.toBe('line one\n\nline two')
const fields = [...ctxView.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent)
expect(fields).toEqual(['plugin', 'empty', 'list'])
fireEvent.keyDown(disclosure, { key: ' ' })
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
})
it('context preserves the bounded JSON truncation contract', () => {
it('the instructions form names the files it reconciled above their text', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'x'.repeat(21_000) }],
content: [{ type: 'text', text: '<system-reminder>\nInstructions from: AGENTS.md\n</system-reminder>' }],
source: {
kind: 'workspace-instructions',
form: 'instructions',
baseline: true,
changes: [
{ action: 'set', scope: '.\u0000AGENTS.md', path: 'AGENTS.md', digest: 'abc' },
{ action: 'remove', scope: 'sub\u0000AGENTS.md', path: 'sub/AGENTS.md' },
{ action: 'replace', scope: '.\u0000AGENTS.md', path: 'AGENTS.md' },
],
},
provenance: { role: 'inject', label: 'AGENTS.md, sub/AGENTS.md' },
form: 'instructions',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*AGENTS\.md, sub\/AGENTS\.md$/ }))
const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent)
expect(files).toEqual(['AGENTS.md已载入', 'sub/AGENTS.md已移除'])
// The `<system-reminder>` framing is part of what the model read, so the
// body keeps it verbatim rather than presenting a cleaned-up excerpt.
expect(view.container.querySelector('[data-context-text]')?.textContent)
.toContain('<system-reminder>')
})
it('a delta distinguishes a newly reconciled file from a rewritten one', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'delta' }],
source: {
kind: 'workspace-instructions',
form: 'instructions',
changes: [
{ action: 'set', scope: 'a', path: 'new/AGENTS.md' },
{ action: 'replace', scope: 'b', path: 'old/AGENTS.md' },
],
},
provenance: { role: 'inject', label: 'new/AGENTS.md, old/AGENTS.md' },
form: 'instructions',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*new\/AGENTS\.md, old\/AGENTS\.md$/ }))
const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent)
expect(files).toEqual(['new/AGENTS.md已新增', 'old/AGENTS.md已更新'])
})
it('keeps an interleaved unknown block in the order the model received it', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [
{ type: 'text', text: 'before' },
{ type: 'future-block', payload: 1 },
{ type: 'text', text: 'after' },
],
source: null,
provenance: { role: 'inject', label: null },
form: null,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
expect(view.container.querySelector('[data-context-injection-body]')?.textContent)
const texts = [...view.container.querySelectorAll('[data-context-text]')].map(node => node.textContent)
expect(texts).toEqual(['before', 'after'])
expect(view.getByText(/未知内容块/)).toBeTruthy()
})
it('the catalog form lists its durable entries instead of the model-facing prose', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: '<system-reminder>\n<available_skills>\n- `a`: A\n</available_skills>' }],
source: {
kind: 'skill-catalog',
form: 'catalog',
entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill', description: 'Does B' }],
},
provenance: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
const entries = [...view.container.querySelectorAll('[data-context-entries] li')].map(node => node.textContent)
expect(entries).toEqual(['a-skillDoes A', 'b-skillDoes B'])
expect(view.container.querySelector('[data-context-text]')).toBeNull()
expect(view.container.querySelector('[data-context-catalog-update]')).toBeNull()
})
it('a replacement catalog says so above its entries', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'catalog prose' }],
source: {
kind: 'skill-catalog',
form: 'catalog',
update: true,
entries: [{ name: 'a-skill', description: 'Does A' }],
},
provenance: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录')
})
it('a partially unreadable catalog falls back whole rather than showing a short list', () => {
// All-or-nothing: a body that replaces the model-facing text must not show
// a confident, incomplete account of what the model read.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'catalog prose' }],
source: {
kind: 'skill-catalog',
form: 'catalog',
entries: [{ name: 'a-skill', description: 'Does A' }, { name: 'b-skill' }],
},
provenance: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.container.querySelector('[data-context-entries]')).toBeNull()
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose')
// The marker reports what rendered, not what was declared.
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
.toBeNull()
})
it('an unreadable instruction list falls back to the opaque body with its fields', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'instruction prose' }],
source: { kind: 'workspace-instructions', form: 'instructions', changes: [{ action: 'set' }] },
provenance: { role: 'inject', label: 'workspace-instructions' },
form: 'instructions',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ }))
expect(view.container.querySelector('[data-context-files]')).toBeNull()
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose')
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
})
it('joins adjacent text blocks the way a provider adapter flattens them', () => {
// No invented separator: showing a line break the model never saw would
// misreport the request.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }],
source: null,
provenance: { role: 'inject', label: null },
form: null,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: '上下文注入' }))
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('firstsecond')
})
it('bounds an oversized provenance field, not only the model-facing text', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'short' }],
source: { kind: 'plugin', note: 'y'.repeat(21_000) },
provenance: { role: 'inject', label: 'plugin' },
form: null,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
expect(view.container.querySelector('[data-context-fields] dd')?.textContent)
.toMatch(/… 已截断,共 \d+ 字符$/)
})
it('an empty replacement catalog stays a catalog: it retires every earlier name', () => {
// `renderCatalogUpdate` legitimately publishes zero entries when the last
// skill disappears; falling back would hide that the catalog was cleared.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'catalog prose' }],
source: { kind: 'skill-catalog', form: 'catalog', update: true, entries: [] },
provenance: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.container.querySelector('[data-context-catalog-update]')?.textContent).toBe('替换目录')
expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(0)
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
.toBe('catalog')
})
it('a catalog whose entries are unreadable falls back to the opaque body', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'catalog prose' }],
source: { kind: 'skill-catalog', form: 'catalog', entries: 'not-a-list' },
provenance: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.container.querySelector('[data-context-entries]')).toBeNull()
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('catalog prose')
})
it('bounds a large catalog and says how many rows it withheld', () => {
const entries = Array.from({ length: 205 }, (_, index) => ({ name: `s-${index}`, description: 'd' }))
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'catalog prose' }],
source: { kind: 'skill-catalog', form: 'catalog', entries },
provenance: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.container.querySelectorAll('[data-context-entries] li')).toHaveLength(200)
expect(view.container.querySelector('[data-context-entries-truncated]')?.textContent).toBe('…还有 5 条')
})
it('a catalog keeps a content block this version does not know', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'prose' }, { type: 'future-block', payload: 1 }],
source: { kind: 'skill-catalog', form: 'catalog', entries: [{ name: 'a', description: 'b' }] },
provenance: { role: 'inject', label: 'skill-catalog' },
form: 'catalog',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*skill-catalog$/ }))
expect(view.getByText(/未知内容块/)).toBeTruthy()
})
it('an instruction change with an unrecognized action falls back whole', () => {
// The action decides the word the row shows, so an unknown one cannot be
// presented as loaded or updated.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'instruction prose' }],
source: { kind: 'workspace-instructions', form: 'instructions', changes: [{ action: 'merge', path: 'A.md' }] },
provenance: { role: 'inject', label: 'workspace-instructions' },
form: 'instructions',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*workspace-instructions$/ }))
expect(view.container.querySelector('[data-context-files]')).toBeNull()
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('instruction prose')
})
it('the opaque fallback keeps a form declaration this version cannot present', () => {
// Otherwise a newer or foreign log's declared shape vanishes from the UI.
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'x' }],
source: { kind: 'plugin', plugin: 'later', form: 'a-later-form' },
provenance: { role: 'inject', label: 'later' },
form: null,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*later$/ }))
const fields = [...view.container.querySelectorAll('[data-context-fields] dt')].map(node => node.textContent)
expect(fields).toEqual(['plugin', 'form'])
})
it('the snapshot form attributes each part to the subsystem that produced it', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'Current runtime context.\n\nsandbox\n\nworkspace' }],
source: {
kind: 'plugin',
plugin: '@deepseek-ai/dsh-system-prompt',
form: 'snapshot',
sections: [{ name: 'sandbox:policy', text: 'workspace-write' }, { name: 'workspace', text: '/repo' }],
},
provenance: { role: 'inject', label: '@deepseek-ai/dsh-system-prompt' },
form: 'snapshot',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*@deepseek-ai\/dsh-system-prompt$/ }))
const rows = [...view.container.querySelectorAll('[data-context-sections] div')].map(node => node.textContent)
expect(rows).toEqual(['sandbox:policyworkspace-write', 'workspace/repo'])
})
it('a notice puts its account on the collapsed row', () => {
// The whole point of the form: readable without expanding.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'background task bash-1 finished.' }],
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice', summary: 'bash pnpm test [status: completed]' },
provenance: { role: 'inject', label: 'tool-tasks' },
form: 'notice',
} as never}
/>,
)
expect(view.container.querySelector('[data-context-summary]')?.textContent)
.toBe('bash pnpm test [status: completed]')
expect(view.container.querySelector('[data-context-injection-body]')).toBeNull()
})
it('a notice without its account falls back to the opaque body', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'notice prose' }],
source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice' },
provenance: { role: 'inject', label: 'tool-tasks' },
form: 'notice',
} as never}
/>,
)
expect(view.container.querySelector('[data-context-summary]')).toBeNull()
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*tool-tasks$/ }))
expect(view.container.querySelector('[data-context-fields]')).not.toBeNull()
})
it('each form falls back to the opaque body when its required facts are unreadable', () => {
// The fallback chain is the load-bearing wall: every dedicated form must
// reach it, and the row marker must not claim a form that did not render.
const cases = [
{ form: 'snapshot', source: { kind: 'plugin', form: 'snapshot', sections: 'not-a-list' }, label: 'plugin' },
{ form: 'relay', source: { kind: 'subagent-report', form: 'relay' }, label: 'subagent-report' },
{ form: 'recall', source: { kind: 'session-reference', form: 'recall', references: [{ label: 'x' }] }, label: 'session-reference' },
] as const
for (const { form, source, label } of cases) {
cleanup()
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: `${form} prose` }],
source, provenance: { role: 'inject', label }, form,
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: new RegExp(`^上下文注入\\s*${label}$`) }))
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe(`${form} prose`)
expect(view.container.querySelector('[data-context-injection-body]')?.getAttribute('data-context-form'))
.toBeNull()
}
})
it('a snapshot states the supersession its framing line carries', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context', seq: 3, content: [{ type: 'text', text: 'Current runtime context.' }],
source: { kind: 'plugin', form: 'snapshot', sections: [{ name: 'sandbox', text: 'w' }] },
provenance: { role: 'inject', label: 'plugin' },
form: 'snapshot',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*plugin$/ }))
expect(view.container.querySelector('[data-context-snapshot-supersedes]')?.textContent)
.toBe('取代先前的快照')
})
it('a relay names the agent that sent it above what it said', () => {
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'child report body' }],
source: { kind: 'subagent-report', form: 'relay', senderSessionId: 'child-7' },
provenance: { role: 'inject', label: 'subagent-report' },
form: 'relay',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*subagent-report$/ }))
expect(view.container.querySelector('[data-context-relay-sender]')?.textContent).toBe('来自会话 child-7')
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('child report body')
})
it('a recall reports how much of each source session survived the read', () => {
// Recalled context is bounded on the way in, so hiding the omitted count
// would overstate what the model received.
const view = render(
<MessageItem t={t} node={{
kind: 'context',
seq: 3,
content: [{ type: 'text', text: 'recalled material' }],
source: {
kind: 'session-reference',
form: 'recall',
version: 1,
references: [
{ label: '重构 loader', retainedMessages: 18, omittedMessages: 42, truncated: true },
{ label: '修 CI', retainedMessages: 3, omittedMessages: 0, truncated: false },
],
},
provenance: { role: 'recall', label: '重构 loader, 修 CI' },
form: 'recall',
} as never}
/>,
)
fireEvent.click(view.getByRole('button', { name: /^跨会话召回\s*重构 loader, 修 CI$/ }))
const rows = [...view.container.querySelectorAll('[data-context-recalls] li')].map(node => node.textContent)
expect(rows).toEqual(['重构 loader保留 18 条 · 省略 42 条已截断', '修 CI保留 3 条 · 省略 0 条'])
expect(view.container.querySelector('[data-context-text]')?.textContent).toBe('recalled material')
})
it('unknown nodes retain the generic JSON row', () => {
const unknownView = render(
<MessageItem t={t} node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,

View File

@@ -376,6 +376,9 @@ describe('ChatView', () => {
expect(view.queryByText('later')).toBeNull()
const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]')
expect(pendingBubble).not.toBeNull()
// Pending and durable steering carry the same interjection caption, so the
// hand-off does not change what the row says it is.
expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy()
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('interrupt now')
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
@@ -388,7 +391,8 @@ describe('ChatView', () => {
nodes: [
assistant(1, 'working'),
{
kind: 'user', seq: 2, time: 2_000,
kind: 'steering', messageId: pending.messageId,
seq: 2, time: 2_000,
content: [{ type: 'text', text: 'interrupt now' }], source: null,
},
],
@@ -396,6 +400,7 @@ describe('ChatView', () => {
})
expect(view.getAllByText('interrupt now')).toHaveLength(1)
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
expect(view.getAllByText('插话')).toHaveLength(1)
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement
const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' })
@@ -441,6 +446,8 @@ describe('ChatView', () => {
const nextRetry = { ...retry(3), turn: 2, retry: 2 }
const context = {
kind: 'context', seq: 4, time: 4_000, content: [], source: null,
provenance: { role: 'inject', label: null },
form: null,
} as const satisfies ConversationNode
const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true })
const view = render(<h.ChatView {...h.props} />)

View File

@@ -13,6 +13,8 @@ const checkpoint = {
time: 100,
content: [],
source: { kind: 'plugin', plugin: 'compact' },
provenance: { role: 'inject', label: 'compact' },
form: null,
} as ConversationNode
const abandoned = {