@kitlangton/motel 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -0
- package/package.json +5 -3
- package/src/App.tsx +233 -59
- package/src/daemon.test.ts +144 -7
- package/src/daemon.ts +113 -8
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +16 -0
- package/src/localServer.ts +111 -121
- package/src/mcp.ts +172 -0
- package/src/motelClient.ts +166 -14
- package/src/registry.ts +26 -23
- package/src/runtime.ts +8 -2
- package/src/server.ts +10 -9
- package/src/services/AsyncIngest.ts +52 -0
- package/src/services/TelemetryStore.ts +151 -26
- package/src/services/TraceQueryService.ts +3 -1
- package/src/services/ingestRpc.ts +41 -0
- package/src/services/telemetryWorker.ts +62 -0
- package/src/storybook/aiChatStory.tsx +243 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +28 -0
- package/src/ui/AiChatView.tsx +292 -0
- package/src/ui/SpanContentView.tsx +181 -0
- package/src/ui/SpanDetail.tsx +98 -17
- package/src/ui/TraceDetailsPane.tsx +2 -1
- package/src/ui/Waterfall.tsx +38 -138
- package/src/ui/aiChatModel.test.ts +347 -0
- package/src/ui/aiChatModel.ts +736 -0
- package/src/ui/aiState.ts +71 -0
- package/src/ui/app/TraceWorkspace.tsx +291 -120
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +173 -39
- package/src/ui/atoms.ts +131 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +27 -13
- package/src/ui/state.ts +4 -199
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +552 -364
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- package/src/ui/waterfallNav.ts +1 -1
package/src/ui/Waterfall.tsx
CHANGED
|
@@ -1,81 +1,17 @@
|
|
|
1
1
|
import { memo, useLayoutEffect, useRef, useState } from "react"
|
|
2
|
-
import type
|
|
2
|
+
import { isAiSpan, type LogItem, type TraceItem, type TraceSpanItem } from "../domain.ts"
|
|
3
3
|
import { formatDuration, lifecycleLabel, splitDuration, truncateText } from "./format.ts"
|
|
4
4
|
import { BlankRow, TextLine } from "./primitives.tsx"
|
|
5
5
|
import { colors, waterfallColors } from "./theme.ts"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
result.push(span)
|
|
16
|
-
if (collapsedIds.has(span.spanId)) {
|
|
17
|
-
skipDepth = span.depth
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
return result
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Find the index of a span's parent in the visible list. */
|
|
24
|
-
export const findParentIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
|
|
25
|
-
const span = spans[index]
|
|
26
|
-
if (!span || span.depth === 0) return null
|
|
27
|
-
for (let i = index - 1; i >= 0; i--) {
|
|
28
|
-
if (spans[i]!.depth < span.depth) return i
|
|
29
|
-
}
|
|
30
|
-
return null
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Find the index of a span's first child in the visible list. */
|
|
34
|
-
export const findFirstChildIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
|
|
35
|
-
const span = spans[index]
|
|
36
|
-
const next = spans[index + 1]
|
|
37
|
-
if (span && next && next.depth > span.depth) return index + 1
|
|
38
|
-
return null
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const INTERESTING_TAGS = [
|
|
42
|
-
"http.method", "http.url", "http.status_code", "http.route",
|
|
43
|
-
"db.system", "db.statement", "db.name",
|
|
44
|
-
"messaging.system", "messaging.destination",
|
|
45
|
-
"error", "error.message",
|
|
46
|
-
"net.peer.name", "net.peer.port",
|
|
47
|
-
] as const
|
|
48
|
-
|
|
49
|
-
const buildTreePrefix = (spans: readonly TraceSpanItem[], index: number): string => {
|
|
50
|
-
const span = spans[index]
|
|
51
|
-
if (span.depth === 0) return ""
|
|
52
|
-
|
|
53
|
-
const parts: string[] = []
|
|
54
|
-
|
|
55
|
-
const isLastChild = (spanIndex: number, depth: number): boolean => {
|
|
56
|
-
for (let i = spanIndex + 1; i < spans.length; i++) {
|
|
57
|
-
if (spans[i].depth < depth) return true
|
|
58
|
-
if (spans[i].depth === depth) return false
|
|
59
|
-
}
|
|
60
|
-
return true
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
parts.push(isLastChild(index, span.depth) ? "\u2514\u2500" : "\u251c\u2500")
|
|
64
|
-
|
|
65
|
-
for (let d = span.depth - 1; d >= 1; d--) {
|
|
66
|
-
let parentIndex = index
|
|
67
|
-
for (let i = index - 1; i >= 0; i--) {
|
|
68
|
-
if (spans[i].depth === d) {
|
|
69
|
-
parentIndex = i
|
|
70
|
-
break
|
|
71
|
-
}
|
|
72
|
-
if (spans[i].depth < d) break
|
|
73
|
-
}
|
|
74
|
-
parts.push(isLastChild(parentIndex, d) ? " " : "\u2502 ")
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return parts.reverse().join("")
|
|
78
|
-
}
|
|
6
|
+
export { getVisibleSpans } from "./waterfallModel.ts"
|
|
7
|
+
import {
|
|
8
|
+
buildTreePrefix,
|
|
9
|
+
findFirstChildIndex,
|
|
10
|
+
getWaterfallLayout,
|
|
11
|
+
getWaterfallSuffixMetrics,
|
|
12
|
+
type WaterfallSuffixMetrics,
|
|
13
|
+
spanPreviewEntries,
|
|
14
|
+
} from "./waterfallModel.ts"
|
|
79
15
|
|
|
80
16
|
const PARTIAL_BLOCKS = ["", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589", "\u2588"] as const
|
|
81
17
|
const ULTRA_SHORT_MARKERS = ["\u258f", "\u258e", "\u258d", "\u258c"] as const
|
|
@@ -180,66 +116,6 @@ const durationColor = (durationMs: number) => {
|
|
|
180
116
|
return colors.muted
|
|
181
117
|
}
|
|
182
118
|
|
|
183
|
-
export const getWaterfallLayout = (contentWidth: number, suffixWidth: number) => {
|
|
184
|
-
const labelMaxWidth = Math.min(Math.floor(contentWidth * 0.4), 32)
|
|
185
|
-
// Two single-space gaps: one between label and bar, one between bar and suffix.
|
|
186
|
-
const barWidth = Math.max(6, contentWidth - labelMaxWidth - suffixWidth - 2)
|
|
187
|
-
return { labelMaxWidth, barWidth } as const
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export type WaterfallSuffixMetrics = {
|
|
191
|
-
readonly maxDurationWidth: number
|
|
192
|
-
readonly suffixWidth: number
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Compute a shared suffix (duration) width from the visible viewport.
|
|
197
|
-
* Reserving the width once keeps every row's duration right-aligned on the
|
|
198
|
-
* same column regardless of per-row content. Log correlation lives in the
|
|
199
|
-
* span detail pane, not the row suffix.
|
|
200
|
-
*/
|
|
201
|
-
export const getWaterfallSuffixMetrics = (
|
|
202
|
-
spans: readonly { readonly durationMs: number; readonly spanId: string }[],
|
|
203
|
-
): WaterfallSuffixMetrics => {
|
|
204
|
-
let maxDurationWidth = 0
|
|
205
|
-
for (const span of spans) {
|
|
206
|
-
const d = formatDuration(Math.max(0, span.durationMs)).length
|
|
207
|
-
if (d > maxDurationWidth) maxDurationWidth = d
|
|
208
|
-
}
|
|
209
|
-
return { maxDurationWidth, suffixWidth: maxDurationWidth }
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Retained for tests: per-row view of the shared layout.
|
|
213
|
-
export const getWaterfallColumns = (
|
|
214
|
-
contentWidth: number,
|
|
215
|
-
metrics: WaterfallSuffixMetrics,
|
|
216
|
-
) => {
|
|
217
|
-
const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, metrics.suffixWidth)
|
|
218
|
-
return { labelMaxWidth, barWidth, suffixWidth: metrics.suffixWidth } as const
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export const spanPreviewEntries = (span: TraceSpanItem, logs: readonly LogItem[], maxEntries: number): Array<{ key: string; value: string; isWarning?: boolean }> => {
|
|
222
|
-
const entries = Object.entries(span.tags)
|
|
223
|
-
const interesting = entries.filter(([key]) =>
|
|
224
|
-
INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) || key.startsWith("error"),
|
|
225
|
-
)
|
|
226
|
-
const rest = entries.filter(([key]) =>
|
|
227
|
-
!INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) && !key.startsWith("error") && !key.startsWith("otel.") && key !== "span.kind",
|
|
228
|
-
)
|
|
229
|
-
const tagResults: Array<{ key: string; value: string; isWarning?: boolean }> = []
|
|
230
|
-
if (logs.length > 0) {
|
|
231
|
-
tagResults.push({ key: "logs", value: `${logs.length} correlated` })
|
|
232
|
-
tagResults.push({ key: "log", value: logs[0]!.body.replace(/\s+/g, " ") })
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
tagResults.push(...[...interesting, ...rest]
|
|
236
|
-
.slice(0, maxEntries - span.warnings.length)
|
|
237
|
-
.map(([key, value]) => ({ key, value })))
|
|
238
|
-
for (const warning of span.warnings) {
|
|
239
|
-
tagResults.push({ key: "warning", value: warning, isWarning: true })
|
|
240
|
-
}
|
|
241
|
-
return tagResults.slice(0, maxEntries)
|
|
242
|
-
}
|
|
243
119
|
|
|
244
120
|
const WaterfallRow = memo(({
|
|
245
121
|
span,
|
|
@@ -267,14 +143,33 @@ const WaterfallRow = memo(({
|
|
|
267
143
|
onSelect: () => void
|
|
268
144
|
}) => {
|
|
269
145
|
const prefix = buildTreePrefix(spans, index)
|
|
270
|
-
|
|
271
|
-
|
|
146
|
+
const isAi = isAiSpan(span.tags)
|
|
147
|
+
// Indicator column: `!` on error, chevron on collapsible parents,
|
|
148
|
+
// `✦` on AI leaves (LLM payloads detected — enter drills into a
|
|
149
|
+
// specialized chat view), `·` on other leaves. AI parents keep the
|
|
150
|
+
// chevron glyph so tree structure stays readable; the accent color
|
|
151
|
+
// (applied below) carries the "AI content lives here" signal.
|
|
152
|
+
const indicator = span.status === "error" ? "!"
|
|
153
|
+
: hasChildSpans ? (collapsed ? "\u25b8" : "\u25be")
|
|
154
|
+
: isAi ? "\u2726"
|
|
155
|
+
: "\u00b7"
|
|
272
156
|
const opName = span.isRunning ? `${span.operationName} [${lifecycleLabel(span)}]` : span.operationName
|
|
273
157
|
|
|
274
158
|
const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, suffixMetrics.suffixWidth)
|
|
275
159
|
|
|
276
|
-
|
|
277
|
-
|
|
160
|
+
// Op name budget = labelMaxWidth minus (prefix + indicator + 1 space).
|
|
161
|
+
// Never force a minimum: at very deep nesting or narrow widths the
|
|
162
|
+
// prefix + indicator may already fill the label column, in which
|
|
163
|
+
// case we render the op as an empty string (or a lone ellipsis) so
|
|
164
|
+
// the line stays within contentWidth. Previous code forced op to 4
|
|
165
|
+
// chars which could push total row width past the pane and make
|
|
166
|
+
// OpenTUI smear "..." across the right edge.
|
|
167
|
+
const opMaxWidth = Math.max(0, labelMaxWidth - prefix.length - 2)
|
|
168
|
+
const opTruncated = opMaxWidth === 0
|
|
169
|
+
? ""
|
|
170
|
+
: opName.length > opMaxWidth
|
|
171
|
+
? `${opName.slice(0, Math.max(0, opMaxWidth - 1))}\u2026`
|
|
172
|
+
: opName
|
|
278
173
|
const labelLen = prefix.length + 2 + opTruncated.length
|
|
279
174
|
const labelPad = " ".repeat(Math.max(0, labelMaxWidth - labelLen))
|
|
280
175
|
|
|
@@ -292,6 +187,11 @@ const WaterfallRow = memo(({
|
|
|
292
187
|
const indicatorColor = selected ? colors.selectedText
|
|
293
188
|
: dimmed ? colors.separator
|
|
294
189
|
: isError ? colors.error
|
|
190
|
+
// AI accent outranks parent/leaf color so both AI parents and AI
|
|
191
|
+
// leaves scan as "there's an LLM payload here" from across the
|
|
192
|
+
// waterfall. Error still wins because a failed AI span is first
|
|
193
|
+
// and foremost a failure.
|
|
194
|
+
: isAi ? colors.accent
|
|
295
195
|
: hasChildSpans ? colors.muted
|
|
296
196
|
: colors.passing
|
|
297
197
|
const opColor = selected ? colors.selectedText
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
buildChatListRows,
|
|
4
|
+
buildChunks,
|
|
5
|
+
chunkDetailTitle,
|
|
6
|
+
type Chunk,
|
|
7
|
+
isChunkExpanded,
|
|
8
|
+
renderChunkDetailLines,
|
|
9
|
+
renderChunks,
|
|
10
|
+
toggleChunkExpansion,
|
|
11
|
+
} from "./aiChatModel.ts"
|
|
12
|
+
|
|
13
|
+
const makeDetail = (messages: unknown, responseText: string | null = null) => ({
|
|
14
|
+
promptMessages: messages,
|
|
15
|
+
responseText,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const findByKind = (chunks: readonly Chunk[], kind: Chunk["kind"]) =>
|
|
19
|
+
chunks.filter((c) => c.kind === kind)
|
|
20
|
+
|
|
21
|
+
describe("buildChunks", () => {
|
|
22
|
+
it("returns no chunks for a null detail", () => {
|
|
23
|
+
expect(buildChunks(null).length).toBe(0)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("unwraps the `{ messages: [...] }` Vercel AI SDK shape", () => {
|
|
27
|
+
const chunks = buildChunks(
|
|
28
|
+
makeDetail({ messages: [{ role: "user", content: "hi" }] }),
|
|
29
|
+
)
|
|
30
|
+
expect(chunks.length).toBe(1)
|
|
31
|
+
expect(chunks[0]!.kind).toBe("user-text")
|
|
32
|
+
expect(chunks[0]!.body).toBe("hi")
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("accepts a bare message array", () => {
|
|
36
|
+
const chunks = buildChunks(
|
|
37
|
+
makeDetail([{ role: "assistant", content: "hello" }]),
|
|
38
|
+
)
|
|
39
|
+
expect(chunks[0]!.kind).toBe("assistant-text")
|
|
40
|
+
expect(chunks[0]!.body).toBe("hello")
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("marks system prompts collapsible + default-collapsed", () => {
|
|
44
|
+
const chunks = buildChunks(
|
|
45
|
+
makeDetail([{ role: "system", content: "you are helpful" }]),
|
|
46
|
+
)
|
|
47
|
+
expect(chunks[0]!.kind).toBe("system")
|
|
48
|
+
expect(chunks[0]!.collapsible).toBe(true)
|
|
49
|
+
expect(chunks[0]!.collapsedByDefault).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("builds a tool-call chunk with inline summary in the header", () => {
|
|
53
|
+
const chunks = buildChunks(
|
|
54
|
+
makeDetail([
|
|
55
|
+
{
|
|
56
|
+
role: "assistant",
|
|
57
|
+
content: [
|
|
58
|
+
{ type: "tool-call", toolName: "read", input: { filePath: "/tmp/x.ts" } },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
]),
|
|
62
|
+
)
|
|
63
|
+
const tc = findByKind(chunks, "tool-call")[0]!
|
|
64
|
+
expect(tc.header).toContain("read")
|
|
65
|
+
expect(tc.header).toContain("/tmp/x.ts")
|
|
66
|
+
expect(tc.toolName).toBe("read")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("strips noisy infra fields from bash tool summaries", () => {
|
|
70
|
+
const chunks = buildChunks(
|
|
71
|
+
makeDetail([
|
|
72
|
+
{
|
|
73
|
+
role: "assistant",
|
|
74
|
+
content: [
|
|
75
|
+
{
|
|
76
|
+
type: "tool-call",
|
|
77
|
+
toolName: "bash",
|
|
78
|
+
input: {
|
|
79
|
+
command: "git status --short",
|
|
80
|
+
timeout: 120_000,
|
|
81
|
+
workdir: "/home/me",
|
|
82
|
+
description: "ignored",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
]),
|
|
88
|
+
)
|
|
89
|
+
const tc = findByKind(chunks, "tool-call")[0]!
|
|
90
|
+
expect(tc.header).toContain("git status --short")
|
|
91
|
+
expect(tc.header).not.toContain("timeout")
|
|
92
|
+
expect(tc.header).not.toContain("workdir")
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("summarises todowrite with a count", () => {
|
|
96
|
+
const chunks = buildChunks(
|
|
97
|
+
makeDetail([
|
|
98
|
+
{
|
|
99
|
+
role: "assistant",
|
|
100
|
+
content: [{ type: "tool-call", toolName: "todowrite", input: { todos: [{}, {}, {}] } }],
|
|
101
|
+
},
|
|
102
|
+
]),
|
|
103
|
+
)
|
|
104
|
+
expect(findByKind(chunks, "tool-call")[0]!.header).toContain("3 todos")
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("scrubs base64 data URLs from user content", () => {
|
|
108
|
+
const big = "a".repeat(400)
|
|
109
|
+
const chunks = buildChunks(
|
|
110
|
+
makeDetail([
|
|
111
|
+
{
|
|
112
|
+
role: "user",
|
|
113
|
+
content: [{ type: "text", text: `look at data:image/png;base64,${big} thanks` }],
|
|
114
|
+
},
|
|
115
|
+
]),
|
|
116
|
+
)
|
|
117
|
+
const body = chunks[0]!.body
|
|
118
|
+
expect(body).not.toContain("aaaaaaaaaa")
|
|
119
|
+
expect(body).toContain("[data:image/png base64")
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("collapses long tool results by default", () => {
|
|
123
|
+
const longOutput = Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n")
|
|
124
|
+
const chunks = buildChunks(
|
|
125
|
+
makeDetail([
|
|
126
|
+
{
|
|
127
|
+
role: "tool",
|
|
128
|
+
content: [
|
|
129
|
+
{
|
|
130
|
+
type: "tool-result",
|
|
131
|
+
toolName: "bash",
|
|
132
|
+
output: { type: "text", value: longOutput },
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
]),
|
|
137
|
+
)
|
|
138
|
+
const tr = findByKind(chunks, "tool-result")[0]!
|
|
139
|
+
expect(tr.collapsible).toBe(true)
|
|
140
|
+
expect(tr.collapsedByDefault).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it("keeps short tool results expanded by default", () => {
|
|
144
|
+
const chunks = buildChunks(
|
|
145
|
+
makeDetail([
|
|
146
|
+
{
|
|
147
|
+
role: "tool",
|
|
148
|
+
content: [
|
|
149
|
+
{ type: "tool-result", toolName: "bash", output: { type: "text", value: "ok" } },
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
]),
|
|
153
|
+
)
|
|
154
|
+
const tr = findByKind(chunks, "tool-result")[0]!
|
|
155
|
+
expect(tr.collapsedByDefault).toBe(false)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("appends a response chunk when responseText is set", () => {
|
|
159
|
+
const chunks = buildChunks(
|
|
160
|
+
makeDetail([{ role: "user", content: "hi" }], "final answer"),
|
|
161
|
+
)
|
|
162
|
+
const response = findByKind(chunks, "response")[0]!
|
|
163
|
+
expect(response.body).toBe("final answer")
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("falls back to a raw-prompt chunk when messages is a plain string", () => {
|
|
167
|
+
const chunks = buildChunks(makeDetail("bare prompt string"))
|
|
168
|
+
expect(chunks[0]!.kind).toBe("raw-prompt")
|
|
169
|
+
expect(chunks[0]!.body).toBe("bare prompt string")
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it("gives each chunk a stable id keyed by message + part index", () => {
|
|
173
|
+
const chunks = buildChunks(
|
|
174
|
+
makeDetail([
|
|
175
|
+
{ role: "user", content: "a" },
|
|
176
|
+
{
|
|
177
|
+
role: "assistant",
|
|
178
|
+
content: [
|
|
179
|
+
{ type: "tool-call", toolName: "read", input: { filePath: "/x" } },
|
|
180
|
+
{ type: "tool-call", toolName: "read", input: { filePath: "/y" } },
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
]),
|
|
184
|
+
)
|
|
185
|
+
const ids = chunks.map((c) => c.id)
|
|
186
|
+
expect(new Set(ids).size).toBe(ids.length)
|
|
187
|
+
// Second assistant part gets messageIndex=1, partIndex=1.
|
|
188
|
+
expect(chunks.find((c) => c.id === "m1p1")).toBeDefined()
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe("isChunkExpanded + toggleChunkExpansion", () => {
|
|
193
|
+
const makeChunk = (over: Partial<Chunk> = {}): Chunk => ({
|
|
194
|
+
id: "x",
|
|
195
|
+
kind: "reasoning",
|
|
196
|
+
role: "assistant",
|
|
197
|
+
messageIndex: 0,
|
|
198
|
+
partIndex: 0,
|
|
199
|
+
header: "reasoning",
|
|
200
|
+
headerMeta: null,
|
|
201
|
+
body: "thinking",
|
|
202
|
+
needsHeader: true,
|
|
203
|
+
collapsible: true,
|
|
204
|
+
collapsedByDefault: true,
|
|
205
|
+
...over,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it("non-collapsible chunks are always expanded", () => {
|
|
209
|
+
const chunk = makeChunk({ collapsible: false, collapsedByDefault: false })
|
|
210
|
+
expect(isChunkExpanded(chunk, new Set())).toBe(true)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it("default-collapsed chunks need a positive override to expand", () => {
|
|
214
|
+
const chunk = makeChunk({ collapsedByDefault: true })
|
|
215
|
+
expect(isChunkExpanded(chunk, new Set())).toBe(false)
|
|
216
|
+
expect(isChunkExpanded(chunk, new Set(["x"]))).toBe(true)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it("default-open chunks need a negative override to collapse", () => {
|
|
220
|
+
const chunk = makeChunk({ collapsedByDefault: false })
|
|
221
|
+
expect(isChunkExpanded(chunk, new Set())).toBe(true)
|
|
222
|
+
expect(isChunkExpanded(chunk, new Set(["!x"]))).toBe(false)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it("toggleChunkExpansion flips visibility per chunk", () => {
|
|
226
|
+
const chunk = makeChunk({ collapsedByDefault: true })
|
|
227
|
+
const once = toggleChunkExpansion(chunk, new Set())
|
|
228
|
+
expect(isChunkExpanded(chunk, once)).toBe(true)
|
|
229
|
+
const twice = toggleChunkExpansion(chunk, once)
|
|
230
|
+
expect(isChunkExpanded(chunk, twice)).toBe(false)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe("renderChunks", () => {
|
|
235
|
+
it("emits a role divider when the role changes", () => {
|
|
236
|
+
const chunks = buildChunks(
|
|
237
|
+
makeDetail([
|
|
238
|
+
{ role: "user", content: "hi" },
|
|
239
|
+
{ role: "assistant", content: "hello" },
|
|
240
|
+
]),
|
|
241
|
+
)
|
|
242
|
+
const lines = renderChunks(chunks, { width: 80, expanded: new Set() })
|
|
243
|
+
const dividers = lines.filter((l) => l.kind === "role-divider")
|
|
244
|
+
expect(dividers.length).toBe(2)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it("hides bodies for collapsed chunks (no per-chunk expand hint)", () => {
|
|
248
|
+
const chunks = buildChunks(
|
|
249
|
+
makeDetail([
|
|
250
|
+
{ role: "system", content: "long system prompt here " .repeat(5) },
|
|
251
|
+
{ role: "user", content: "hi" },
|
|
252
|
+
]),
|
|
253
|
+
)
|
|
254
|
+
const lines = renderChunks(chunks, { width: 80, expanded: new Set() })
|
|
255
|
+
const systemChunkId = chunks.find((c) => c.kind === "system")!.id
|
|
256
|
+
const systemBodyLines = lines.filter((l) => l.chunkId === systemChunkId && l.kind === "text")
|
|
257
|
+
// Collapsed: only the chunk-header line survives, no body text
|
|
258
|
+
// and no "enter to expand" filler (the bottom footer carries the
|
|
259
|
+
// global keyboard hint now).
|
|
260
|
+
expect(systemBodyLines.length).toBe(0)
|
|
261
|
+
const systemHeaders = lines.filter((l) => l.chunkId === systemChunkId && l.kind === "chunk-header")
|
|
262
|
+
expect(systemHeaders.length).toBe(1)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it("renders role dividers on turn boundaries", () => {
|
|
266
|
+
const chunks = buildChunks(
|
|
267
|
+
makeDetail([
|
|
268
|
+
{ role: "user", content: "hi" },
|
|
269
|
+
{ role: "assistant", content: "hello" },
|
|
270
|
+
{ role: "user", content: "thanks" },
|
|
271
|
+
]),
|
|
272
|
+
)
|
|
273
|
+
const lines = renderChunks(chunks, { width: 80, expanded: new Set() })
|
|
274
|
+
const dividers = lines.filter((l) => l.kind === "role-divider").map((l) => l.text)
|
|
275
|
+
expect(dividers).toEqual(["USER", "ASSISTANT", "USER"])
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it("omits chunk-header rows for plain text chunks (user/assistant/response)", () => {
|
|
279
|
+
const chunks = buildChunks(
|
|
280
|
+
makeDetail([
|
|
281
|
+
{ role: "user", content: "hi" },
|
|
282
|
+
{ role: "assistant", content: "hello" },
|
|
283
|
+
], "final"),
|
|
284
|
+
)
|
|
285
|
+
const lines = renderChunks(chunks, { width: 80, expanded: new Set() })
|
|
286
|
+
// No chunk-header rows should exist for the user-text / assistant-text / response chunks.
|
|
287
|
+
const plainTextChunkIds = chunks
|
|
288
|
+
.filter((c) => ["user-text", "assistant-text", "response"].includes(c.kind))
|
|
289
|
+
.map((c) => c.id)
|
|
290
|
+
const headersForPlainText = lines.filter(
|
|
291
|
+
(l) => l.kind === "chunk-header" && plainTextChunkIds.includes(l.chunkId ?? ""),
|
|
292
|
+
)
|
|
293
|
+
expect(headersForPlainText.length).toBe(0)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it("annotates each line with its chunkId so selection can find it", () => {
|
|
297
|
+
const chunks = buildChunks(
|
|
298
|
+
makeDetail([{ role: "user", content: "hi" }]),
|
|
299
|
+
)
|
|
300
|
+
const lines = renderChunks(chunks, { width: 80, expanded: new Set() })
|
|
301
|
+
const userChunkId = chunks[0]!.id
|
|
302
|
+
const taggedLines = lines.filter((l) => l.chunkId === userChunkId)
|
|
303
|
+
expect(taggedLines.length).toBeGreaterThan(0)
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
describe("buildChatListRows", () => {
|
|
308
|
+
it("emits one role divider per turn and one chunk row per chunk", () => {
|
|
309
|
+
const chunks = buildChunks(
|
|
310
|
+
makeDetail([
|
|
311
|
+
{ role: "user", content: "hi" },
|
|
312
|
+
{ role: "assistant", content: [{ type: "tool-call", toolName: "read", input: { filePath: "/x" } }] },
|
|
313
|
+
]),
|
|
314
|
+
)
|
|
315
|
+
const rows = buildChatListRows(chunks)
|
|
316
|
+
expect(rows.filter((r) => r.kind === "role-divider").map((r) => r.text)).toEqual(["USER", "ASSISTANT"])
|
|
317
|
+
expect(rows.filter((r) => r.kind === "chunk").length).toBe(chunks.length)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it("uses first body line for plain text chunks and header text for structured chunks", () => {
|
|
321
|
+
const chunks = buildChunks(
|
|
322
|
+
makeDetail([
|
|
323
|
+
{ role: "user", content: "hello there" },
|
|
324
|
+
{ role: "assistant", content: [{ type: "tool-call", toolName: "bash", input: { command: "git status" } }] },
|
|
325
|
+
]),
|
|
326
|
+
)
|
|
327
|
+
const rows = buildChatListRows(chunks).filter((r) => r.kind === "chunk")
|
|
328
|
+
expect(rows[0]!.text).toBe("hello there")
|
|
329
|
+
expect(rows[1]!.text).toContain("bash")
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
describe("chunkDetailTitle + renderChunkDetailLines", () => {
|
|
334
|
+
it("returns a readable modal title per kind", () => {
|
|
335
|
+
const chunks = buildChunks(makeDetail([{ role: "assistant", content: [{ type: "tool-call", toolName: "bash", input: { command: "git status" } }] }]))
|
|
336
|
+
expect(chunkDetailTitle(chunks[0]!)).toBe("TOOL CALL · bash")
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it("wraps full detail lines for the modal", () => {
|
|
340
|
+
const chunks = buildChunks(makeDetail([{ role: "user", content: "a ".repeat(200) }]))
|
|
341
|
+
const lines = renderChunkDetailLines(chunks[0]!, 40)
|
|
342
|
+
expect(lines.length).toBeGreaterThan(1)
|
|
343
|
+
for (const line of lines) {
|
|
344
|
+
expect(line.length).toBeLessThanOrEqual(40)
|
|
345
|
+
}
|
|
346
|
+
})
|
|
347
|
+
})
|