Merge pull request #2034 from deepseek-harness/worktree/merge-compact-card
feat(web): merge compact status and summary cards
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
|
||||
README.md: 8ac29a4258bbd7456b20c61e547d48c570e84d27
|
||||
README.zh.md: 0e065e43ecc571e68d3976d2100eb43959cb2e3d
|
||||
README.md: 7b7b983c0047a54457089fc084f2532bb990b4d2
|
||||
README.zh.md: 9c3c6ec04c8ba20ac6357b3701d7a39eafaf9fd2
|
||||
|
||||
@@ -36,7 +36,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
|
||||
|
||||
`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.
|
||||
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, replaced-item count, and estimated shadowed-token count come from the checkpoint's `compact/summary` provenance; a window cut that left the provenance outside makes those fields unavailable, and a later page that supplies it resolves them. `CommandNode.outcome.sourceEventSeq` preserves a successful command's explicit reference to that summary event, allowing the presentation layer to pair `/compact` with its checkpoint without parsing settlement copy or assuming the two rows are adjacent. 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.
|
||||
|
||||
## Request inspection
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
|
||||
|
||||
`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` 溯源;窗口切分把溯源留在窗口外时该行不可展开而非空白,后续补上溯源的分页会解析出文本。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
|
||||
由于投影按日志顺序,节点数组天然按 seq 单调:仅日志的 `command/run` / `command/done` 节点按 seq 插入,`Session` 按分数 seq 归并被打断的冻结节点,而检查点所引范围落在窗口之外的窗口会渲染出标记且不打印任何日志。标记的摘要文本、被替换条目数量和估算的被遮蔽 token 数量都来自检查点的 `compact/summary` 溯源;窗口切分把溯源留在窗口外时这些字段不可用,后续补上溯源的分页会解析出它们。`CommandNode.outcome.sourceEventSeq` 保留成功命令对该摘要事件的显式引用,使呈现层能够配对 `/compact` 与其检查点,而无须解析结算文案或假定两行相邻。性能契约:一次追加最多物化一个节点,并且仅在加入该节点时复制投影;不改变任何节点的事件保持上一次的数组引用(分片风暴零成本),未变化的节点保持其对象标识。
|
||||
|
||||
## 请求检查
|
||||
|
||||
|
||||
@@ -192,6 +192,12 @@ export interface CompactionSummaryNode {
|
||||
/** Summary text from the checkpoint's `compact/summary` provenance; null when
|
||||
* the window cut left that provenance outside (the marker is then not expandable). */
|
||||
summary: string | null
|
||||
/** Seq of the loaded `compact/summary` event, or null when that provenance is outside the window. */
|
||||
summaryEventSeq: number | null
|
||||
/** Number of surface items replaced, or null when summary provenance is unavailable or malformed. */
|
||||
shadowedItemCount: number | null
|
||||
/** Estimated token price of the replaced items, or null when summary provenance is unavailable or malformed. */
|
||||
shadowedTokenCount: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,7 +242,12 @@ export interface CommandNode {
|
||||
*/
|
||||
args: string | null
|
||||
/** Settlement outcome (done payload); null while the command is still executing. */
|
||||
outcome: { kind: 'success' | 'error'; text?: string } | null
|
||||
outcome: {
|
||||
kind: 'success' | 'error'
|
||||
text?: string
|
||||
/** Earlier authoritative domain event for a richer client-computed presentation. */
|
||||
sourceEventSeq?: number
|
||||
} | null
|
||||
}
|
||||
|
||||
/** Finalized conversation node union (kind discriminates; seq is the React key). */
|
||||
|
||||
@@ -158,6 +158,29 @@ function compactSummaryText(event: SessionEvent): string | null {
|
||||
return text.trim() === '' ? null : text
|
||||
}
|
||||
|
||||
interface CompactSummaryDetails {
|
||||
readonly summary: string | null
|
||||
readonly shadowedItemCount: number | null
|
||||
readonly shadowedTokenCount: number | null
|
||||
}
|
||||
|
||||
/** Recover human-facing summary material from one structurally narrowed wire event. */
|
||||
function compactSummaryDetails(event: SessionEvent): CompactSummaryDetails {
|
||||
const data = event.data as unknown as { shadowedSeqs?: unknown; shadowedTokenCount?: unknown }
|
||||
const shadowedSeqs = data.shadowedSeqs
|
||||
const tokenCount = data.shadowedTokenCount
|
||||
return {
|
||||
summary: compactSummaryText(event),
|
||||
shadowedItemCount: Array.isArray(shadowedSeqs)
|
||||
&& shadowedSeqs.every((seq: unknown) => Number.isSafeInteger(seq) && (seq as number) >= 0)
|
||||
? shadowedSeqs.length
|
||||
: null,
|
||||
shadowedTokenCount: Number.isSafeInteger(tokenCount) && (tokenCount as number) >= 0
|
||||
? tokenCount as number
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One landed checkpoint -> the human-facing compaction marker. The summary text
|
||||
* comes from the checkpoint's own provenance (`sourceEventSeqs` names the
|
||||
@@ -172,13 +195,28 @@ function materializeCompaction(
|
||||
): CompactionSummaryNode {
|
||||
const sources = (checkpoint as SessionEvent & { sourceEventSeqs?: number[] }).sourceEventSeqs
|
||||
let summary: string | null = null
|
||||
let summaryEventSeq: number | null = null
|
||||
let shadowedItemCount: number | null = null
|
||||
let shadowedTokenCount: number | null = null
|
||||
for (const seq of sources ?? []) {
|
||||
const candidate = eventIndex.get(seq)
|
||||
if (candidate === undefined || (candidate.type as string) !== 'compact/summary') continue
|
||||
summary = compactSummaryText(candidate)
|
||||
const details = compactSummaryDetails(candidate)
|
||||
summary = details.summary
|
||||
summaryEventSeq = candidate.seq
|
||||
shadowedItemCount = details.shadowedItemCount
|
||||
shadowedTokenCount = details.shadowedTokenCount
|
||||
break
|
||||
}
|
||||
return { kind: 'compaction', seq: checkpoint.seq, time: checkpoint.time, summary }
|
||||
return {
|
||||
kind: 'compaction',
|
||||
seq: checkpoint.seq,
|
||||
time: checkpoint.time,
|
||||
summary,
|
||||
summaryEventSeq,
|
||||
shadowedItemCount,
|
||||
shadowedTokenCount,
|
||||
}
|
||||
}
|
||||
|
||||
/** Log-ordered human transcript over a paged raw event window (never consults surface order). */
|
||||
@@ -323,9 +361,22 @@ export class TranscriptAdapter {
|
||||
return true
|
||||
}
|
||||
if ((event.type as string) !== 'command/done') return false
|
||||
const data = event.data as unknown as { commandId: CommandId; kind: 'success' | 'error'; text?: string }
|
||||
const data = event.data as unknown as {
|
||||
commandId: CommandId
|
||||
kind: 'success' | 'error'
|
||||
text?: string
|
||||
sourceEventSeq?: number
|
||||
}
|
||||
const run = this.commandIdx.get(data.commandId)
|
||||
const outcome = { kind: data.kind, ...data.text === undefined ? {} : { text: data.text } }
|
||||
const sourceEventSeq = data.kind === 'success'
|
||||
&& Number.isSafeInteger(data.sourceEventSeq) && (data.sourceEventSeq as number) >= 0
|
||||
? data.sourceEventSeq as number
|
||||
: undefined
|
||||
const outcome = {
|
||||
kind: data.kind,
|
||||
...data.text === undefined ? {} : { text: data.text },
|
||||
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
|
||||
}
|
||||
if (run === undefined) {
|
||||
// Cross-window cut: the run page fell out of the window — build the
|
||||
// node from the done alone (same soft-fall as a call-less tool result).
|
||||
|
||||
@@ -36,7 +36,10 @@ describe('compaction checkpoint recognition', () => {
|
||||
it('recognizes a checkpoint carrying the seam-canonical source', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([canonicalCheckpoint(1)])
|
||||
expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null }])
|
||||
expect(adapter.nodes()).toEqual([{
|
||||
kind: 'compaction', seq: 1, time: 1_700_000_000_001, summary: null,
|
||||
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
|
||||
}])
|
||||
})
|
||||
|
||||
it("agrees with the seam's own predicate on the source it recognizes", () => {
|
||||
|
||||
@@ -92,8 +92,19 @@ export const ev = {
|
||||
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
|
||||
commandRunWithoutInput: (seq: number, commandId: string, name: string): SessionEvent =>
|
||||
at(seq, { type: 'command/run', data: { commandId, name, source: { kind: 'user' } } }),
|
||||
commandDone: (seq: number, commandId: string, kind: 'success' | 'error' = 'success', text?: string): SessionEvent =>
|
||||
at(seq, { type: 'command/done', data: { commandId, kind, ...text === undefined ? {} : { text } } }),
|
||||
commandDone: (
|
||||
seq: number,
|
||||
commandId: string,
|
||||
kind: 'success' | 'error' = 'success',
|
||||
text?: string,
|
||||
sourceEventSeq?: number,
|
||||
): SessionEvent =>
|
||||
at(seq, { type: 'command/done', data: {
|
||||
commandId,
|
||||
kind,
|
||||
...text === undefined ? {} : { text },
|
||||
...sourceEventSeq === undefined ? {} : { sourceEventSeq },
|
||||
} }),
|
||||
/** A compaction's log-only `compact/summary` provenance record. */
|
||||
compactSummary: (seq: number, summary: string, start: number, end: number): SessionEvent =>
|
||||
at(seq, { type: 'compact/summary', data: {
|
||||
|
||||
@@ -245,8 +245,14 @@ describe('TranscriptAdapter', () => {
|
||||
checkpoint(5, 4, { start: 2, end: 3, sourceEventSeqs: [4, 2, 3] }),
|
||||
])
|
||||
expect(adapter.nodes().filter(n => n.kind === 'compaction')).toEqual([
|
||||
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first' },
|
||||
{ kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second' },
|
||||
{
|
||||
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: 'first',
|
||||
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
|
||||
},
|
||||
{
|
||||
kind: 'compaction', seq: 5, time: 1_700_000_000_005, summary: 'second',
|
||||
summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -318,7 +324,7 @@ describe('TranscriptAdapter', () => {
|
||||
...(summary === undefined ? [] : [summary]),
|
||||
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
|
||||
])
|
||||
expect(adapter.nodes()).toEqual([
|
||||
expect(adapter.nodes()).toMatchObject([
|
||||
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null },
|
||||
])
|
||||
})
|
||||
@@ -332,7 +338,10 @@ describe('TranscriptAdapter', () => {
|
||||
checkpoint(2, 1, { start: 0, end: 0, sourceEventSeqs: [1, 0] }),
|
||||
])
|
||||
expect(adapter.nodes()).toEqual([
|
||||
{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要' },
|
||||
{
|
||||
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: '可用摘要',
|
||||
summaryEventSeq: 1, shadowedItemCount: 2, shadowedTokenCount: 100,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -346,7 +355,10 @@ describe('TranscriptAdapter', () => {
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}),
|
||||
})])
|
||||
expect(adapter.nodes()).toEqual([{ kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null }])
|
||||
expect(adapter.nodes()).toEqual([{
|
||||
kind: 'compaction', seq: 2, time: 1_700_000_000_002, summary: null,
|
||||
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
|
||||
}])
|
||||
})
|
||||
|
||||
it('skips a non-summary provenance seq before reaching the real one', () => {
|
||||
@@ -490,20 +502,22 @@ describe('TranscriptAdapter', () => {
|
||||
expect(adapter.nodes().map(n => n.kind)).toEqual(['user', 'command'])
|
||||
})
|
||||
|
||||
it('renders the /compact row alongside the marker its own command produced', () => {
|
||||
// The row that reports the compaction is a command node; dropping command
|
||||
// folding would delete it together with every other slash-command row.
|
||||
it('preserves the domain-event link for the UI to fold a /compact row into its marker', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.user(0, '压缩前的问题'),
|
||||
ev.commandRun(1, 'cmd-compact', 'compact'),
|
||||
compactSummary(2, [{ type: 'text', text: '手动压缩摘要' }]),
|
||||
checkpoint(3, 2, { start: 0, end: 0, sourceEventSeqs: [2, 0] }),
|
||||
ev.commandDone(4, 'cmd-compact', 'success', '已压缩'),
|
||||
ev.commandDone(4, 'cmd-compact', 'success', '已压缩', 2),
|
||||
])
|
||||
const nodes = adapter.nodes()
|
||||
expect(nodes.map(n => [n.kind, n.seq])).toEqual([['user', 0], ['command', 1], ['compaction', 3]])
|
||||
expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } })
|
||||
expect(nodes[1]).toMatchObject({
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', text: '已压缩', sourceEventSeq: 2 },
|
||||
})
|
||||
expect(nodes[2]).toMatchObject({ kind: 'compaction', summaryEventSeq: 2 })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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: 0cf50146cc44ef0d6cc060a4c97b3d1ff454f013
|
||||
README.zh.md: 8bfb96bb9326d8fcadc3c357b6abaad88c92bd17
|
||||
README.md: 6b541b840ed67ee6fd735a0643dde8c60f1ec22d
|
||||
README.zh.md: 01692c395cdb0f50e0fd41ab92f51d9e3ceecb4f
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
|
||||
|
||||
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders.
|
||||
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with structured summary provenance shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when summary provenance is outside the loaded window, the checkpoint remains visible but non-expandable.
|
||||
|
||||
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
|
||||
|
||||
@@ -64,7 +64,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced.
|
||||
- **Stats-line durations and speeds cover the in-window flow only** — LLM and tool wall times plus the TTFT and throughput averages fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
|
||||
- **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link.
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn that has ended; mid-turn narration, Think-only nodes, and every node of a turn still producing steps stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
|
||||
|
||||
压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。
|
||||
压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。每个具备结构化摘要溯源的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;摘要溯源位于已加载窗口之外时,检查点仍然可见但不可展开。
|
||||
|
||||
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
|
||||
|
||||
@@ -64,7 +64,6 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。
|
||||
- **统计行的耗时与速率只覆盖窗口内消息流**:LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
|
||||
- **详情面板没有入口**:`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个已结束轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述、纯 Think 节点,以及仍在产出步骤的轮次里的所有节点都不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。
|
||||
|
||||
@@ -32,6 +32,7 @@ import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { CompactionCommandCard } from './CompactionCommandCard.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
|
||||
@@ -267,17 +268,21 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
|
||||
/** One command lifecycle row: keyed dispatch on the command name with the
|
||||
* generic card as the render-site fallback (zero registration required). A
|
||||
* run-less cross-window node has no name and always lands on the fallback. */
|
||||
const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
|
||||
const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: {
|
||||
renderSlot: RenderToolRow
|
||||
node: CommandNode
|
||||
compaction?: Extract<ConversationNode, { kind: 'compaction' }>
|
||||
t: ChatViewSlotProps['t']
|
||||
}) {
|
||||
const owner = useMemo(() => ({ node }), [node])
|
||||
const owner = useMemo(() => ({ node, ...compaction === undefined ? {} : { compaction } }), [compaction, node])
|
||||
const fallback = node.name === 'compact'
|
||||
? <CompactionCommandCard {...owner} t={t} />
|
||||
: <GenericCommandCard {...owner} t={t} />
|
||||
return (
|
||||
<div className={css.callRow}>
|
||||
{renderSlot('conversation.chat.commandview', owner, {
|
||||
entryKey: node.name ?? '',
|
||||
fallback: <GenericCommandCard {...owner} t={t} />,
|
||||
fallback,
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
@@ -580,6 +585,16 @@ export function ChatView({
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (item.kind === 'command-compaction') {
|
||||
return (
|
||||
<CommandRow
|
||||
renderSlot={renderSlot}
|
||||
node={item.command}
|
||||
compaction={item.compaction}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const node: ConversationNode = item.node
|
||||
if (node.kind === 'assistant') {
|
||||
const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined
|
||||
@@ -642,9 +657,17 @@ export function ChatView({
|
||||
<div
|
||||
key={item.key}
|
||||
className={css.flowItem}
|
||||
data-chat-anchor-key={item.kind === 'node' ? `node:${String(item.node.seq)}` : undefined}
|
||||
data-chat-anchor-key={item.kind === 'node'
|
||||
? `node:${String(item.node.seq)}`
|
||||
: item.kind === 'command-compaction'
|
||||
? `node:${String(item.compaction.seq)}`
|
||||
: undefined}
|
||||
data-chat-flow-key={item.key}
|
||||
data-chat-flow-kind={item.kind === 'node' ? item.node.kind : 'tool-group'}
|
||||
data-chat-flow-kind={item.kind === 'node'
|
||||
? item.node.kind
|
||||
: item.kind === 'command-compaction'
|
||||
? item.kind
|
||||
: 'tool-group'}
|
||||
>
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// CompactionCommandCard: the `/compact` command's running row and its
|
||||
// successful checkpoint disclosure. Outcomes without a checkpoint keep the
|
||||
// generic command card so no-history, cancellation, and failures retain their
|
||||
// complete handler-authored text.
|
||||
|
||||
import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
|
||||
import { CompactionItem } from './CompactionItem.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
|
||||
interface CompactionCommandCardProps extends CommandRowOwnerProps {
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
|
||||
/** Render one manual compaction lifecycle without duplicating its checkpoint marker. */
|
||||
export function CompactionCommandCard({ node, compaction, t }: CompactionCommandCardProps) {
|
||||
if (compaction !== undefined) {
|
||||
return (
|
||||
<CompactionItem
|
||||
node={compaction}
|
||||
title="compact"
|
||||
fallbackSummary={node.outcome?.text ?? null}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (node.outcome !== null) return <GenericCommandCard node={node} t={t} />
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
variant="others"
|
||||
icon={<IconApiOutline14 size={14} />}
|
||||
title="compact"
|
||||
summary={t('message.compaction.running')}
|
||||
body={null}
|
||||
state="running"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
import { memo, useState } from 'react'
|
||||
import type { CompactionSummaryNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconApiOutline14,
|
||||
IconChevronDownOutline14,
|
||||
IconChevronRightOutline14,
|
||||
MarkdownText,
|
||||
@@ -18,6 +19,10 @@ import css from './MessageItem.module.css'
|
||||
|
||||
interface CompactionItemProps {
|
||||
node: CompactionSummaryNode
|
||||
/** Optional command title for a manual compaction folded into this marker. */
|
||||
title?: string
|
||||
/** Command settlement text used when structured compaction counts are unavailable. */
|
||||
fallbackSummary?: string | null
|
||||
/** The owning view's locale seat. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
@@ -27,10 +32,22 @@ interface CompactionItemProps {
|
||||
* @param props - the marker node off the snapshot cache.
|
||||
* @returns the marker row, with the summary disclosure when one is available.
|
||||
*/
|
||||
export const CompactionItem = memo(function CompactionItem({ node, t }: CompactionItemProps) {
|
||||
export const CompactionItem = memo(function CompactionItem({
|
||||
node,
|
||||
title,
|
||||
fallbackSummary,
|
||||
t,
|
||||
}: CompactionItemProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const expandable = node.summary !== null
|
||||
const open = expandable && expanded
|
||||
const summary = node.shadowedItemCount !== null && node.shadowedTokenCount !== null
|
||||
? t('message.compaction.completed', {
|
||||
items: node.shadowedItemCount,
|
||||
tokens: node.shadowedTokenCount,
|
||||
})
|
||||
: fallbackSummary
|
||||
?? (expandable ? t('message.compaction.expand') : t('message.compaction.unavailable'))
|
||||
return (
|
||||
<div className={css.compactionRow}>
|
||||
<button
|
||||
@@ -40,14 +57,20 @@ export const CompactionItem = memo(function CompactionItem({ node, t }: Compacti
|
||||
aria-expanded={expandable ? open : undefined}
|
||||
onClick={() => { setExpanded(value => !value) }}
|
||||
>
|
||||
<span className={css.compactionLeading}>
|
||||
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
<span className={css.compactionLeading} aria-hidden>
|
||||
<span className={css.compactionContextIcon} data-compaction-icon="context">
|
||||
<IconApiOutline14 />
|
||||
</span>
|
||||
<span
|
||||
className={css.compactionDisclosureIcon}
|
||||
data-compaction-disclosure={open ? 'expanded' : 'collapsed'}
|
||||
>
|
||||
{open ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
</span>
|
||||
</span>
|
||||
<span className={css.compactionTitle}>{t('message.compaction')}</span>
|
||||
<span className={css.compactionTitle}>{title ?? t('message.compaction')}</span>
|
||||
<span className={css.compactionSep} aria-hidden />
|
||||
<span className={css.compactionSummary}>
|
||||
{expandable ? t('message.compaction.expand') : t('message.compaction.unavailable')}
|
||||
</span>
|
||||
<span className={css.compactionSummary}>{summary}</span>
|
||||
</button>
|
||||
{open && node.summary !== null
|
||||
&& <div className={css.compactionBody}><MarkdownText text={node.summary} /></div>}
|
||||
|
||||
@@ -33,9 +33,9 @@
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
/* Compaction marker: one dim 24px row with a chevron disclosure for the
|
||||
summary body. Dimmed title (not label-primary) — the row is a boundary
|
||||
notice, not conversation content. */
|
||||
/* Compaction marker: one dim 24px row with a context icon at rest and a
|
||||
hover/focus disclosure for the summary body. Dimmed title (not
|
||||
label-primary) — the row is a boundary notice, not conversation content. */
|
||||
.compactionRow {
|
||||
padding: 2px 0;
|
||||
}
|
||||
@@ -65,15 +65,36 @@
|
||||
|
||||
.compactionLeading {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.compactionContextIcon,
|
||||
.compactionDisclosureIcon {
|
||||
display: inline-flex;
|
||||
grid-area: 1 / 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.compactionDisclosureIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled):hover .compactionContextIcon,
|
||||
.compactionButton:not(:disabled):focus-visible .compactionContextIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.compactionButton:not(:disabled):hover .compactionDisclosureIcon,
|
||||
.compactionButton:not(:disabled):focus-visible .compactionDisclosureIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.compactionTitle {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -9,13 +9,48 @@
|
||||
* flow share their gates.
|
||||
*/
|
||||
import type {
|
||||
AssistantBlock, ConversationNode, ConversationSnapshot, ToolResultNode,
|
||||
AssistantBlock, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One renderable flow item; key is the React key and the parent's identity unit. */
|
||||
export type ChatFlowItem =
|
||||
| { kind: 'node'; key: string; node: ConversationNode }
|
||||
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
|
||||
| {
|
||||
kind: 'command-compaction'
|
||||
key: string
|
||||
command: CommandNode
|
||||
compaction: CompactionSummaryNode
|
||||
}
|
||||
|
||||
/** Match explicit command outcome references to exactly one compaction checkpoint. */
|
||||
function commandCompactionPairs(nodes: readonly ConversationNode[]): {
|
||||
readonly byCommandId: ReadonlyMap<string, CompactionSummaryNode>
|
||||
readonly byCompactionSeq: ReadonlyMap<number, CommandNode>
|
||||
} {
|
||||
const commandsBySource = new Map<number, CommandNode | null>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'command' || node.name !== 'compact' || node.outcome?.kind !== 'success') continue
|
||||
const source = node.outcome.sourceEventSeq
|
||||
if (source === undefined) continue
|
||||
commandsBySource.set(source, commandsBySource.has(source) ? null : node)
|
||||
}
|
||||
const compactionsBySummary = new Map<number, CompactionSummaryNode | null>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'compaction' || node.summaryEventSeq === null) continue
|
||||
const summary = node.summaryEventSeq
|
||||
compactionsBySummary.set(summary, compactionsBySummary.has(summary) ? null : node)
|
||||
}
|
||||
const byCommandId = new Map<string, CompactionSummaryNode>()
|
||||
const byCompactionSeq = new Map<number, CommandNode>()
|
||||
for (const [source, command] of commandsBySource) {
|
||||
const compaction = compactionsBySummary.get(source)
|
||||
if (command === null || compaction === undefined || compaction === null) continue
|
||||
byCommandId.set(command.commandId, compaction)
|
||||
byCompactionSeq.set(compaction.seq, command)
|
||||
}
|
||||
return { byCommandId, byCompactionSeq }
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the node has model-visible text content worth IconActions chrome.
|
||||
@@ -115,9 +150,28 @@ export function assistantBranchSeqs(
|
||||
*/
|
||||
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
|
||||
const items: ChatFlowItem[] = []
|
||||
const pairs = commandCompactionPairs(nodes)
|
||||
let group: ToolResultNode[] | null = null
|
||||
for (const node of nodes) {
|
||||
if (rendersNothing(node)) continue
|
||||
if (node.kind === 'command' && pairs.byCommandId.has(node.commandId)) {
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'compaction') {
|
||||
group = null
|
||||
const command = pairs.byCompactionSeq.get(node.seq)
|
||||
if (command !== undefined) {
|
||||
items.push({
|
||||
kind: 'command-compaction',
|
||||
key: `c${command.commandId}`,
|
||||
command,
|
||||
compaction: node,
|
||||
})
|
||||
} else {
|
||||
items.push({ kind: 'node', key: `n${node.seq}`, node })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'tool-result') {
|
||||
if (group === null) {
|
||||
group = [node]
|
||||
@@ -138,7 +192,13 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
|
||||
}
|
||||
} else {
|
||||
group = null
|
||||
items.push({ kind: 'node', key: `n${node.seq}`, node })
|
||||
items.push({
|
||||
kind: 'node',
|
||||
key: node.kind === 'command' && node.name === 'compact'
|
||||
? `c${node.commandId}`
|
||||
: `n${node.seq}`,
|
||||
node,
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react'
|
||||
import type {
|
||||
InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ComposerBlock } from '../input/blocks.ts'
|
||||
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
|
||||
@@ -217,14 +217,16 @@ export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
|
||||
/**
|
||||
* Owner share of the per-command row slot: the frozen {@link CommandNode}
|
||||
* slice off the snapshot (cache-stable reference — memo premise). The node
|
||||
* carries the whole lifecycle (structured name/args, pairing id,
|
||||
* outcome-or-executing), so a
|
||||
* registrant needs no second data channel; domain state arrives through its
|
||||
* own projection cell.
|
||||
* carries the whole lifecycle (structured name/args, pairing id, and
|
||||
* outcome-or-executing). A successful domain command may also carry the
|
||||
* explicitly linked projection node needed to fold two log records into one
|
||||
* presentation row.
|
||||
*/
|
||||
export interface CommandRowOwnerProps {
|
||||
/** Folded command lifecycle node (run + optional done). */
|
||||
node: CommandNode
|
||||
/** Explicitly linked compaction checkpoint for the settled `/compact` presentation. */
|
||||
compaction?: CompactionSummaryNode
|
||||
}
|
||||
|
||||
/** Full props of a registered command-row component (same shape rule as {@link ToolRowProps}). */
|
||||
|
||||
@@ -80,6 +80,8 @@ export const zh = {
|
||||
'message.context.recall.truncated': '已截断',
|
||||
'message.steering': '插话',
|
||||
'message.compaction': '上下文已压缩',
|
||||
'message.compaction.running': '正在压缩…',
|
||||
'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)',
|
||||
'message.compaction.expand': '点击查看压缩摘要',
|
||||
'message.compaction.unavailable': '压缩摘要不可用',
|
||||
'message.unknownSurface': '未知 surface 事件:{type}',
|
||||
@@ -220,6 +222,8 @@ export const en = {
|
||||
'message.context.recall.truncated': 'truncated',
|
||||
'message.steering': 'Interjection',
|
||||
'message.compaction': 'Context compacted',
|
||||
'message.compaction.running': 'Compacting context…',
|
||||
'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)',
|
||||
'message.compaction.expand': 'View compaction summary',
|
||||
'message.compaction.unavailable': 'Compaction summary unavailable',
|
||||
'message.unknownSurface': 'Unknown surface event: {type}',
|
||||
|
||||
@@ -691,11 +691,15 @@ describe('MessageItem arms', () => {
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'compaction', seq: 5, time: 1_000,
|
||||
summary: '## 摘要标题\n\n保留的事实。',
|
||||
summaryEventSeq: 4,
|
||||
shadowedItemCount: 16,
|
||||
shadowedTokenCount: 11_309,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
const row = view.getByRole('button', { name: /上下文已压缩/ })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy()
|
||||
expect(view.queryByText(/保留的事实/)).toBeNull()
|
||||
fireEvent.click(row)
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
@@ -705,7 +709,10 @@ describe('MessageItem arms', () => {
|
||||
})
|
||||
|
||||
it('a marker whose provenance fell outside the window is not expandable', () => {
|
||||
const view = render(<MessageItem t={t} node={{ kind: 'compaction', seq: 6, time: 1_000, summary: null }} />)
|
||||
const view = render(<MessageItem t={t} node={{
|
||||
kind: 'compaction', seq: 6, time: 1_000, summary: null,
|
||||
summaryEventSeq: null, shadowedItemCount: null, shadowedTokenCount: null,
|
||||
}} />)
|
||||
const row = view.getByRole('button', { name: /上下文已压缩/ })
|
||||
expect(row).toHaveProperty('disabled', true)
|
||||
expect(row.getAttribute('aria-expanded')).toBeNull()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Profiler } from 'react'
|
||||
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot,
|
||||
AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot,
|
||||
ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, TurnErrorNode,
|
||||
UserMessageNode, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -93,6 +93,19 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode
|
||||
const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
|
||||
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
|
||||
})
|
||||
const command = (over: Partial<CommandNode> = {}): CommandNode => ({
|
||||
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
|
||||
name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
...over,
|
||||
})
|
||||
const compaction = (over: Partial<CompactionSummaryNode> = {}): CompactionSummaryNode => ({
|
||||
kind: 'compaction', seq: 8, time: 8_000,
|
||||
summary: '## 压缩摘要\n\n保留的事实。',
|
||||
summaryEventSeq: 7,
|
||||
shadowedItemCount: 16,
|
||||
shadowedTokenCount: 11_309,
|
||||
...over,
|
||||
})
|
||||
|
||||
/** Empty sessions-list hook for the global standard-kit seat. */
|
||||
function emptySessions() {
|
||||
@@ -212,6 +225,79 @@ describe('chat-flow derivation', () => {
|
||||
expect(updated[1]?.kind === 'node' && updated[1].node).toBe(second)
|
||||
})
|
||||
|
||||
it('folds a successful /compact lifecycle into its explicitly linked checkpoint', () => {
|
||||
const running = command({
|
||||
seq: 1,
|
||||
commandId: 'cmd-compact' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: null,
|
||||
})
|
||||
expect(flowKeys(deriveChatFlow([user(0, 'before'), running]))).toBe('n0|ccmd-compact')
|
||||
|
||||
const settled = {
|
||||
...running,
|
||||
outcome: { kind: 'success' as const, text: 'Compacted 16 history items.', sourceEventSeq: 3 },
|
||||
}
|
||||
const checkpoint = compaction({ seq: 4, summaryEventSeq: 3 })
|
||||
const items = deriveChatFlow([user(0, 'before'), settled, user(2, 'injected while compacting'), checkpoint])
|
||||
expect(flowKeys(items)).toBe('n0|n2|ccmd-compact')
|
||||
expect(items.at(-1)).toEqual({
|
||||
kind: 'command-compaction',
|
||||
key: 'ccmd-compact',
|
||||
command: settled,
|
||||
compaction: checkpoint,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not split adjacent tool results around a folded /compact command', () => {
|
||||
const folded = command({
|
||||
seq: 2,
|
||||
commandId: 'cmd-compact' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', sourceEventSeq: 4 },
|
||||
})
|
||||
const items = deriveChatFlow([
|
||||
toolResult(1, 'a'),
|
||||
folded,
|
||||
toolResult(3, 'b'),
|
||||
compaction({ seq: 5, summaryEventSeq: 4 }),
|
||||
])
|
||||
expect(flowKeys(items)).toBe('g1|ccmd-compact')
|
||||
expect(
|
||||
items[0]?.kind === 'tool-group' && items[0].results.map(result => result.callId),
|
||||
).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('keeps automatic, unlinked, and ambiguously linked compactions as separate rows', () => {
|
||||
const automatic = compaction({ seq: 2, summaryEventSeq: 1 })
|
||||
expect(flowKeys(deriveChatFlow([automatic]))).toBe('n2')
|
||||
|
||||
const first = command({
|
||||
seq: 3,
|
||||
commandId: 'cmd-a' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', sourceEventSeq: 9 },
|
||||
})
|
||||
const second = command({
|
||||
seq: 4,
|
||||
commandId: 'cmd-b' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', sourceEventSeq: 9 },
|
||||
})
|
||||
const ambiguous = compaction({ seq: 10, summaryEventSeq: 9 })
|
||||
expect(flowKeys(deriveChatFlow([first, second, ambiguous]))).toBe('ccmd-a|ccmd-b|n10')
|
||||
|
||||
const sole = command({
|
||||
seq: 11,
|
||||
commandId: 'cmd-sole' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', sourceEventSeq: 12 },
|
||||
})
|
||||
const duplicateA = compaction({ seq: 13, summaryEventSeq: 12 })
|
||||
const duplicateB = compaction({ seq: 14, summaryEventSeq: 12 })
|
||||
expect(flowKeys(deriveChatFlow([sole, duplicateA, duplicateB]))).toBe('ccmd-sole|n13|n14')
|
||||
})
|
||||
|
||||
it('skips render-nothing assistant nodes so tool runs stay one group', () => {
|
||||
// A tool-call-only step message (and blank text/reasoning) renders nothing:
|
||||
// it must not split the run into two groups with an empty line between.
|
||||
@@ -1172,11 +1258,6 @@ describe('ChatView', () => {
|
||||
})
|
||||
|
||||
it('renders command nodes as durable rows: settled text, error state, executing spinner, run-less soft-fall', () => {
|
||||
const command = (over: Partial<CommandNode>): CommandNode => ({
|
||||
kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'],
|
||||
name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
|
||||
...over,
|
||||
})
|
||||
// Settled success: the bare command name is the title, the outcome text
|
||||
// the summary — neither the dispatched `/` nor its arguments reach the row
|
||||
// (the settlement text already says what the command did).
|
||||
@@ -1211,4 +1292,65 @@ describe('ChatView', () => {
|
||||
expect(ov.getByText('命令')).toBeTruthy()
|
||||
expect(ov.getByText('已完成')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders /compact as one stateful disclosure from running through completion', () => {
|
||||
const running = command({
|
||||
commandId: 'cmd-compact' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: null,
|
||||
})
|
||||
const h = makeHarness({ nodes: [running] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getByText('正在压缩…')).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
|
||||
act(() => {
|
||||
h.set({
|
||||
nodes: [{
|
||||
...running,
|
||||
outcome: {
|
||||
kind: 'success',
|
||||
text: 'Compacted 16 history items (~11309 tokens).',
|
||||
sourceEventSeq: 7,
|
||||
},
|
||||
}, compaction()],
|
||||
})
|
||||
})
|
||||
|
||||
expect(view.queryByText('正在压缩…')).toBeNull()
|
||||
expect(view.queryByText('上下文已压缩')).toBeNull()
|
||||
expect(view.getByText('已压缩 16 条历史记录(约 11309 tokens)')).toBeTruthy()
|
||||
const row = view.getByRole('button', { name: /compact/ })
|
||||
expect(row.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(row.querySelector('[data-compaction-icon="context"]')).not.toBeNull()
|
||||
expect(row.querySelector('[data-compaction-disclosure="collapsed"]')).not.toBeNull()
|
||||
expect(view.queryByText('保留的事实。')).toBeNull()
|
||||
fireEvent.click(row)
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(row.querySelector('[data-compaction-disclosure="expanded"]')).not.toBeNull()
|
||||
expect(view.getByRole('heading', { name: '压缩摘要' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps /compact no-history and error settlements on the generic command row', () => {
|
||||
const noHistory = makeHarness({
|
||||
nodes: [command({
|
||||
name: 'compact',
|
||||
outcome: { kind: 'success', text: 'No compactable history yet.' },
|
||||
})],
|
||||
})
|
||||
const noHistoryView = render(<noHistory.ChatView {...noHistory.props} />)
|
||||
expect(noHistoryView.getByText('No compactable history yet.')).toBeTruthy()
|
||||
expect(noHistoryView.queryByRole('button')).toBeNull()
|
||||
|
||||
const failed = makeHarness({
|
||||
nodes: [command({
|
||||
commandId: 'cmd-compact-failed' as CommandNode['commandId'],
|
||||
name: 'compact',
|
||||
outcome: { kind: 'error', text: 'Compaction cancelled.' },
|
||||
})],
|
||||
})
|
||||
const failedView = render(<failed.ChatView {...failed.props} />)
|
||||
expect(failedView.getByText('Compaction cancelled.')).toBeTruthy()
|
||||
expect(failedView.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -324,7 +324,10 @@ describe('deriveTrajectoryLayout', () => {
|
||||
},
|
||||
// A landed compaction renders no cell, but is still a real log position,
|
||||
// so it moves the cursor after the visible context row.
|
||||
{ kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts' },
|
||||
{
|
||||
kind: 'compaction', seq: 5, time: 9_500, summary: 'checkpoint facts',
|
||||
summaryEventSeq: 4, shadowedItemCount: 2, shadowedTokenCount: 100,
|
||||
},
|
||||
{
|
||||
kind: 'assistant', seq: 6, time: 10_000, turn: 1, step: 0,
|
||||
blocks: [{ kind: 'text', text: 'done' }],
|
||||
|
||||
Reference in New Issue
Block a user