round 2: address manual compaction review findings

This commit is contained in:
Hypatia May
2026-07-30 18:15:12 +08:00
parent eda7c76eb1
commit 4de4d693a2
22 changed files with 531 additions and 152 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/ui-trajectory/README.md
README.md: b9c8b849b3454fe46e1fc37713d9d3b9449734cf
README.zh.md: 19ae5050a4c4f7dfe80de0ab58e772e9d26e3f6a
README.md: a65c11aed9dd74f9b0b60795441f876c1d64b3ad
README.zh.md: 853d1be9468f1b136452a2bb72f6be314f45d7ee

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A fixed Overview above the ledger projects real record start/duration timing from left to right; dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. A fixed Overview above the ledger projects real record start/duration timing from left to right; dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
## Model Experience

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录清除选择则恢复完整分支。runtime 的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包package保持为纯消费方插件向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带数值所有者的压缩仍位于其所属轮次内。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录清除选择则恢复完整分支。runtime 的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包package保持为纯消费方插件向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并。契约api-contracts v3 §8。
## 模型体验

View File

@@ -27,7 +27,8 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
}
interface TableRecord {
turn: number
turn: number | null
section: number
group: string
groupStart: boolean
turnStart: boolean
@@ -69,7 +70,7 @@ interface ToolCallTextParts {
}
interface SelectedRequest {
turn: number
turn: number | null
number: number
group: string
}
@@ -237,15 +238,12 @@ export interface TrajectoryTableProps {
onToggleAssistant: (index: number) => void
}
/** One request identity paired with its session-global number. */
export interface TrajectoryRequestNumber {
/** Request-inspector fields shared by ordinary generation and compaction. */
interface TrajectoryRequestNumberBase {
/** Request anchor event sequence; absent for the currently streaming ordinary request. */
seq?: number
turn: number
step: number
group: string
number: number
purpose?: 'compaction'
status?: 'complete' | 'running' | 'error'
startedAt?: number
completedAt?: number | null
@@ -261,6 +259,20 @@ export interface TrajectoryRequestNumber {
cumulativeUsage?: TrajectoryUsage
}
/** One purpose-discriminated request identity paired with its session-global number. */
export type TrajectoryRequestNumber = TrajectoryRequestNumberBase & (
| {
purpose?: 'assistant'
turn: number
step: number
}
| {
purpose: 'compaction'
turn: number | null
step: 0
}
)
/** Disjoint provider token buckets for one request or a session prefix. */
export interface TrajectoryUsage {
input?: number
@@ -271,17 +283,18 @@ export interface TrajectoryUsage {
}
function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] {
return turns.flatMap((turn) => {
let firstInTurn = true
return turns.flatMap((turn, section) => {
let firstInSection = true
const records = turn.groups.flatMap((group) => {
return group.cells.map((cell, index) => {
const turnStart = firstInTurn
const turnStart = firstInSection
&& cell.requestOnly !== true
&& cell.kind !== 'system'
&& cell.kind !== 'compacted'
if (turnStart) firstInTurn = false
&& (cell.kind !== 'compacted' || turn.turn === null)
if (turnStart) firstInSection = false
return {
turn: turn.turn,
section,
group: group.title,
groupStart: index === 0,
turnStart,
@@ -305,18 +318,18 @@ function filterRecords(
record.cell.requestOnly !== true && matches.has(record.cell.index),
)
.map(record => ({ ...record, groupStart: false, turnStart: false, turnEnd: false }))
const startedTurns = new Set<number>()
const startedSections = new Set<number>()
for (const [index, record] of filtered.entries()) {
const previous = filtered[index - 1]
const next = filtered[index + 1]
record.groupStart = previous === undefined
|| previous.turn !== record.turn
|| previous.section !== record.section
|| previous.group !== record.group
record.turnStart = !startedTurns.has(record.turn)
record.turnStart = !startedSections.has(record.section)
&& record.cell.kind !== 'system'
&& record.cell.kind !== 'compacted'
if (record.turnStart) startedTurns.add(record.turn)
record.turnEnd = next === undefined || next.turn !== record.turn
&& (record.cell.kind !== 'compacted' || record.turn === null)
if (record.turnStart) startedSections.add(record.section)
record.turnEnd = next === undefined || next.section !== record.section
}
return filtered
}
@@ -327,10 +340,14 @@ function requestStep(group: string): number | undefined {
return Number.isInteger(value) && value > 0 ? value : undefined
}
function requestKey(turn: number, group: string): string {
function requestKey(turn: number | null, group: string): string {
return `${turn}\u0000${group}`
}
function sectionLabel(turn: number | null): string {
return turn === null ? 'Between turns' : `Turn ${turn}`
}
function indexRequestNumbers(
records: readonly TableRecord[],
sessionNumbers: readonly TrajectoryRequestNumber[] | undefined,
@@ -372,12 +389,13 @@ function collapseTurnRecords(
if (collapsedTurns.size === 0) return [...records]
const recordsByTurn = new Map<number, TableRecord[]>()
for (const record of records) {
if (record.turn === null) continue
const turnRecords = recordsByTurn.get(record.turn) ?? []
turnRecords.push(record)
recordsByTurn.set(record.turn, turnRecords)
}
return records.flatMap((record) => {
if (!collapsedTurns.has(record.turn)) return [record]
if (record.turn === null || !collapsedTurns.has(record.turn)) return [record]
const turnRecords = recordsByTurn.get(record.turn) ?? [record]
if (record.cell.requestOnly === true || record.cell.kind === 'system') return [record]
const contentRecords = turnRecords.filter(candidate =>
@@ -1486,7 +1504,7 @@ export function TrajectoryTable({
const selectedRequestCumulativeUsage =
selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage
const selectedRequestOptions = selectedRequestInfo?.requestConfig
const activeTurn = selectedRequest?.turn ?? selected?.turn
const activeTurn = selectedRequest === null ? selected?.turn : selectedRequest.turn
const selectedTabs = selectedRequest !== null
? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined)
: selected === undefined ? [] : detailTabs(selected)
@@ -1554,7 +1572,7 @@ export function TrajectoryTable({
const openRecordSummary = (target: TableRecord) => {
const targetAt = allRecords.findIndex(record => record.cell.index === target.cell.index)
if (collapsedTurns.has(target.turn)) onToggleTurn(target.turn)
if (target.turn !== null && collapsedTurns.has(target.turn)) onToggleTurn(target.turn)
if (target.cell.kind === 'tool' || target.cell.kind === 'subtool') {
for (let i = targetAt - 1; i >= 0; i--) {
const candidate = allRecords[i]
@@ -1600,7 +1618,7 @@ export function TrajectoryTable({
&& record.cell.index === allRecords[0]?.cell.index
const request = record.groupStart
&& !isCollapsedSummary
&& !collapsedTurns.has(record.turn)
&& (record.turn === null || !collapsedTurns.has(record.turn))
? requestNumbers.get(requestKey(record.turn, record.group))
: undefined
const requestInfo = request === undefined
@@ -1641,13 +1659,14 @@ export function TrajectoryTable({
? undefined
: isCollapsedSummary
? () => {
if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn)
else onToggleAssistant(record.cell.index)
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
onToggleTurn(record.turn)
} else onToggleAssistant(record.cell.index)
}
: () => { selectRecord(record.cell.index) }}
onDoubleClick={(event) => {
if (isCollapsedSummary || isRequestOnly) return
if (collapsedTurns.has(record.turn)) {
if (record.turn !== null && collapsedTurns.has(record.turn)) {
event.preventDefault()
onToggleTurn(record.turn)
return
@@ -1661,6 +1680,7 @@ export function TrajectoryTable({
return
}
if (!record.turnStart) return
if (record.turn === null) return
if (allRecords.filter(candidate =>
candidate.turn === record.turn
&& candidate.cell.requestOnly !== true
@@ -1673,8 +1693,9 @@ export function TrajectoryTable({
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
if (isCollapsedSummary) {
if (record.collapsedSummaryKind === 'turn') onToggleTurn(record.turn)
else onToggleAssistant(record.cell.index)
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
onToggleTurn(record.turn)
} else onToggleAssistant(record.cell.index)
return
}
selectRecord(record.cell.index)
@@ -1701,7 +1722,9 @@ export function TrajectoryTable({
onDoubleClick={(event) => { event.stopPropagation() }}
/>
)}
{activeTurn === record.turn && !isInitialSystem && (
{record.turn !== null
&& activeTurn === record.turn
&& !isInitialSystem && (
<span className={css.turnRail} aria-hidden="true" />
)}
{!isCollapsedSummary && selectedIndex === record.cell.index && (
@@ -1715,7 +1738,7 @@ export function TrajectoryTable({
? `${css.turnLabel} ${css.turnLabelActive}`
: css.turnLabel}
>
Turn {record.turn}
{sectionLabel(record.turn)}
</span>
)}
<div className={css.eventInner}>
@@ -1895,8 +1918,8 @@ export function TrajectoryTable({
</span>
<span className={css.detailsLocation}>
{selectedRequestInfo?.purpose === 'compaction'
? `Compaction · Turn ${selectedRequest.turn}`
: `Turn ${selectedRequest.turn}`}
? `Compaction · ${sectionLabel(selectedRequest.turn)}`
: sectionLabel(selectedRequest.turn)}
</span>
</>
)
@@ -1927,8 +1950,8 @@ export function TrajectoryTable({
</span>
<span className={css.detailsLocation}>
{selected.cell.kind === 'compacted'
? `Turn ${selected.turn}`
: `Turn ${selected.turn} · ${selected.group}`}
? sectionLabel(selected.turn)
: `${sectionLabel(selected.turn)} · ${selected.group}`}
</span>
</>
)}

View File

@@ -315,9 +315,9 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
)}
<div className={css.turnBoundaries} aria-hidden="true">
{model.turnBoundaries
.slice(1)
.filter(boundary =>
boundary.time >= domainStart
boundary.time > model.start
&& boundary.time >= domainStart
&& boundary.time <= domainStart + domainDuration)
.map(boundary => (
<span

View File

@@ -102,7 +102,7 @@ function searchMatches(
...(cell.outputBlocks ?? []),
]
const text = [
`turn ${turn.turn}`,
turn.turn === null ? 'between turns' : `turn ${turn.turn}`,
group.title,
cell.kind,
cell.kind === 'message' ? 'assistant' : undefined,
@@ -380,13 +380,15 @@ export function TrajectoryView({
const collapsibleTurnIds = useMemo(
() => turns
.filter(turn =>
turn.turn !== null
&&
turn.groups.reduce(
(count, group) =>
count + group.cells.filter(cell =>
cell.requestOnly !== true && cell.kind !== 'system').length,
0,
) > 1)
.map(turn => turn.turn),
.flatMap(turn => turn.turn === null ? [] : [turn.turn]),
[turns],
)
const allTurnsCollapsed = collapsibleTurnIds.length > 0

View File

@@ -107,6 +107,8 @@ export function trajectoryBranchContainsRequest(
request.resultSeq !== undefined
&& branch.retainedSurfaceSeqs.has(request.resultSeq)
) || (
request.purpose === 'compaction'
&&
request.replacementSeq !== undefined
&& branch.retainedSurfaceSeqs.has(request.replacementSeq)
)

View File

@@ -24,9 +24,9 @@ export interface TrajectoryGroupModel {
cells: readonly TrajectoryCellProps[]
}
/** One sticky-turn section. */
/** One sticky turn, or a standalone compaction section between turns. */
export interface TrajectoryTurnModel {
turn: number
turn: number | null
groups: readonly TrajectoryGroupModel[]
}
@@ -66,6 +66,9 @@ interface TurnBucket {
groups: LaidGroup[]
}
type AssistantRequestView = Extract<RequestView, { purpose: 'assistant' }>
type CompactionRequestView = Extract<RequestView, { purpose: 'compaction' }>
type InputNode = Extract<
ConversationSnapshot['nodes'][number],
{ kind: 'user' | 'steering' | 'context' }
@@ -81,18 +84,18 @@ type OrderedLayoutEntry =
| {
kind: 'compaction'
seq: number
request: RequestView
request: CompactionRequestView
}
| {
kind: 'system'
seq: number
request: RequestView
request: AssistantRequestView
change: RequestPromptChange
}
| {
kind: 'request'
seq: number
request: RequestView
request: AssistantRequestView
}
function layoutEntryOrder(entry: OrderedLayoutEntry): number {
@@ -136,6 +139,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
if (startedAt !== null) callStartById.set(call.callId, startedAt)
}
const turns = new Map<number, TurnBucket>()
const standaloneCompactions: TurnBucket[] = []
let index = 0
let prevAbsTime: number | null = null
let lastAssistantTurn: number | null = null
@@ -191,13 +195,16 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
nodeIndex,
})),
...requests
.filter(request => request.purpose === 'compaction')
.filter((request): request is CompactionRequestView =>
request.purpose === 'compaction')
.map(request => ({
kind: 'compaction' as const,
seq: request.startSeq,
request,
})),
...requests.flatMap(request => request.promptChange === undefined || request.prompt === undefined
...requests.flatMap(request => request.purpose !== 'assistant'
|| request.promptChange === undefined
|| request.prompt === undefined
? []
: [{
kind: 'system' as const,
@@ -206,7 +213,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
change: request.promptChange,
}]),
...requests
.filter(request => request.purpose === 'assistant')
.filter((request): request is AssistantRequestView =>
request.purpose === 'assistant')
.filter(request =>
!representedRequests.has(`${request.turn}\u0000${request.step}`),
)
@@ -297,13 +305,17 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
startedAt: finiteTime(request.startedAt),
}
attachUsage(cell, request.usage as UsageLike | undefined)
bucket(request.turn).groups.push({
title: `Compaction ${request.startSeq}`,
laid: [{
absTime: finiteTime(request.startedAt),
cell,
const compaction: TurnBucket = {
groups: [{
title: `Compaction ${request.startSeq}`,
laid: [{
absTime: finiteTime(request.startedAt),
cell,
}],
}],
})
}
if (request.turn === null) standaloneCompactions.push(compaction)
else bucket(request.turn).groups.push(...compaction.groups)
prevAbsTime = finiteTime(request.completedAt) ?? finiteTime(request.startedAt) ?? prevAbsTime
continue
}
@@ -446,15 +458,16 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
turns.set(1, first)
}
for (const entry of turns.values()) {
for (const entry of [...turns.values(), ...standaloneCompactions]) {
for (const group of entry.groups) {
for (const laid of group.laid) attachToolSchema(laid, callSchemas)
}
}
return [...turns.entries()]
.sort(([a], [b]) => a - b)
.map(([turn, entry]) => toTurnModel(turn, entry))
return [
...[...turns.entries()].map(([turn, entry]) => toTurnModel(turn, entry)),
...standaloneCompactions.map(entry => toTurnModel(null, entry)),
].sort((left, right) => firstCellIndex(left) - firstCellIndex(right))
}
function attachToolSchema(
@@ -468,7 +481,7 @@ function attachToolSchema(
}
function toTurnModel(
turn: number,
turn: number | null,
entry: TurnBucket,
): TrajectoryTurnModel {
const groups = entry.groups.map(({ title, laid }): TrajectoryGroupModel => {
@@ -482,6 +495,14 @@ function toTurnModel(
return { turn, groups }
}
/** Chronological section position from the fold's monotonically assigned cell indexes. */
function firstCellIndex(turn: TrajectoryTurnModel): number {
return Math.min(
...turn.groups.flatMap(group => group.cells.map(cell => cell.index)),
Number.POSITIVE_INFINITY,
)
}
/** Wall-span duration + tool histogram, e.g. `1.5 s bash×6`. */
function groupDescription(laid: readonly LaidCell[]): string | undefined {
const parts: string[] = []

View File

@@ -86,10 +86,12 @@ export function deriveTrajectoryTimeline(
group.cells.filter(cell => cell.requestOnly !== true),
)
if (cells.length === 0) continue
turnBoundaries.push({
turn: turn.turn,
time: spans.length,
})
if (turn.turn !== null) {
turnBoundaries.push({
turn: turn.turn,
time: spans.length,
})
}
spans.push(...cells.map((cell, offset): TrajectoryTimelineSpan => ({
start: spans.length + offset,
end: spans.length + offset + 1,
@@ -147,10 +149,12 @@ function deriveTimedTimeline(
start: span.start - removedUserIdle,
end: (actualDuration ? span.end : span.start) - removedUserIdle,
})))
turnBoundaries.push({
turn: turn.turn,
time: turnStart - removedUserIdle,
})
if (turn.turn !== null) {
turnBoundaries.push({
turn: turn.turn,
time: turnStart - removedUserIdle,
})
}
previousTurnEnd = previousTurnEnd === null
? turnEnd
: Math.max(previousTurnEnd, turnEnd)

View File

@@ -38,17 +38,22 @@ function request(
resultSeq?: number,
replacementSeq?: number,
): RequestView {
return {
purpose,
const base = {
startSeq,
turn: 1,
step: purpose === 'assistant' ? 1 : 0,
startedAt: startSeq,
completedAt: startSeq + 1,
status: 'complete',
status: 'complete' as const,
...(resultSeq === undefined ? {} : { resultSeq }),
...(replacementSeq === undefined ? {} : { replacementSeq }),
}
return purpose === 'assistant'
? { ...base, purpose, turn: 1, step: 1 }
: {
...base,
purpose,
turn: 1,
step: 0,
...(replacementSeq === undefined ? {} : { replacementSeq }),
}
}
describe('trajectory context branches', () => {

View File

@@ -5,7 +5,9 @@
*/
import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, RequestView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx'
@@ -161,6 +163,49 @@ describe('deriveTrajectoryLayout', () => {
expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2'])
})
it('places standalone compaction chronologically in its own between-turn section', () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null },
{
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
blocks: [{ kind: 'text', text: 'before compaction' }],
},
{ kind: 'user', seq: 5, time: 5_000, content: [{ type: 'text', text: 'second' }], source: null },
{
kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
blocks: [{ kind: 'text', text: 'after compaction' }],
},
] as unknown as ConversationSnapshot['nodes']
const compaction: RequestView = {
purpose: 'compaction',
startSeq: 3,
turn: null,
step: 0,
startedAt: 3_000,
completedAt: 4_000,
status: 'complete',
summary: [{ type: 'text', text: 'standalone summary' }],
}
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes,
partial: null,
runningCalls: [],
requests: [compaction],
})
expect(turns.map(turn => turn.turn)).toEqual([1, null, 2])
expect(turns[1]?.groups).toMatchObject([{
title: 'Compaction 3',
cells: [{
kind: 'compacted',
sourceSeq: 3,
text: 'standalone summary',
}],
}])
})
it('keeps usage and a meaningful summary when assistant has no text block', () => {
const nodes = [
{

View File

@@ -277,6 +277,41 @@ describe('tab switching in ConversationRoot', () => {
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
})
it('labels a standalone compaction as between-turn work in the ledger and inspector', async () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [], source: null },
{
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
blocks: [{ kind: 'text', text: 'before' }],
},
{ kind: 'user', seq: 5, time: 5_000, content: [], source: null },
{
kind: 'assistant', seq: 6, time: 6_000, turn: 2, step: 1,
blocks: [{ kind: 'text', text: 'after' }],
},
] as unknown as ConversationSnapshot['nodes']
const compaction: RequestView = {
purpose: 'compaction',
startSeq: 3,
turn: null,
step: 0,
startedAt: 3_000,
completedAt: 4_000,
status: 'complete',
summary: [{ type: 'text', text: 'standalone summary' }],
}
const b = await bench(historySnapshot(nodes, { requests: [compaction] }))
const view = mount(b.slots, nodes)
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.getByText('Between turns')).toBeTruthy()
expect(view.container.textContent).not.toContain('Turn null')
fireEvent.click(screen.getByRole('button', { name: 'Request #2 · Compaction' }))
expect(screen.getByText('Compaction · Between turns')).toBeTruthy()
expect(view.container.textContent).not.toContain('Turn null')
})
it('dragging the overview focuses overlapping records without filtering the ledger', async () => {
const b = await bench()
mount(b.slots)
@@ -386,6 +421,44 @@ describe('timeline projection', () => {
})
})
it('projects between-turn compaction without inventing a turn boundary', () => {
const withStandaloneCompaction = [
{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{ index: 1, kind: 'message', text: 'before', timeSeconds: 0 }],
}],
},
{
turn: null,
groups: [{
title: 'Compaction 3',
cells: [{ index: 2, kind: 'compacted', text: 'summary', timeSeconds: 0 }],
}],
},
{
turn: 2,
groups: [{
title: 'Step 1',
cells: [{ index: 3, kind: 'message', text: 'after', timeSeconds: 0 }],
}],
},
] satisfies readonly TrajectoryTurnModel[]
expect(deriveTrajectoryTimeline(withStandaloneCompaction)).toMatchObject({
spans: [
{ index: 1, start: 0, end: 1 },
{ index: 2, start: 1, end: 2 },
{ index: 3, start: 2, end: 3 },
],
turnBoundaries: [
{ turn: 1, time: 0 },
{ turn: 2, time: 2 },
],
})
})
it('empty inputs produce no model and the standalone view reports its empty form', () => {
expect(deriveTrajectoryTimeline([])).toBeNull()
render(createElement(