@kitlangton/motel 0.1.3 → 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 +11 -1
- package/package.json +5 -3
- package/src/App.tsx +239 -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 +62 -4
- package/src/httpApi.ts +4 -1
- package/src/localServer.ts +112 -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 +285 -27
- package/src/services/TraceQueryService.ts +4 -2
- 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 +61 -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 +35 -3
- package/src/ui/Waterfall.tsx +94 -167
- 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 +295 -120
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +191 -35
- package/src/ui/atoms.ts +131 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +47 -21
- package/src/ui/state.ts +4 -169
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +576 -300
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- package/src/ui/waterfallNav.ts +1 -1
- package/web/dist/assets/{index-DKinj-OE.js → index-DnyVo03x.js} +1 -1
- package/web/dist/index.html +1 -1
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Storybook-style preview for AiChatView. Renders the component
|
|
2
|
+
// against a menu of fixtures so we can iterate on the rendering
|
|
3
|
+
// without needing real LLM traffic captured.
|
|
4
|
+
//
|
|
5
|
+
// Run it: `bun run story:chat`
|
|
6
|
+
// Keys:
|
|
7
|
+
// 1..N switch fixture
|
|
8
|
+
// j / k scroll down / up (1 line)
|
|
9
|
+
// ctrl-d/u half-page
|
|
10
|
+
// gg / G jump to top / bottom
|
|
11
|
+
// r force re-render with a fresh date (to sanity-check headers)
|
|
12
|
+
// q / ^c quit
|
|
13
|
+
|
|
14
|
+
import { RegistryProvider } from "@effect/atom-react"
|
|
15
|
+
import { RGBA, TextAttributes, createCliRenderer } from "@opentui/core"
|
|
16
|
+
import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
17
|
+
import { useEffect, useMemo, useRef, useState } from "react"
|
|
18
|
+
import { buildChunks, type Chunk } from "../ui/aiChatModel.ts"
|
|
19
|
+
import { AiChatView } from "../ui/AiChatView.tsx"
|
|
20
|
+
import { Divider, TextLine } from "../ui/primitives.tsx"
|
|
21
|
+
import type { AiCallDetailState } from "../ui/state.ts"
|
|
22
|
+
import { applyTheme, colors, SEPARATOR } from "../ui/theme.ts"
|
|
23
|
+
import type { ChatFixture } from "./fixtures/index.ts"
|
|
24
|
+
import { errorFixture } from "./fixtures/errorState.ts"
|
|
25
|
+
import { imagePasteFixture } from "./fixtures/imagePaste.ts"
|
|
26
|
+
import { kitchenSinkFixture } from "./fixtures/kitchenSink.ts"
|
|
27
|
+
import { rawPromptFixture } from "./fixtures/rawPrompt.ts"
|
|
28
|
+
import { shortFixture } from "./fixtures/short.ts"
|
|
29
|
+
import { toolHeavyFixture } from "./fixtures/toolHeavy.ts"
|
|
30
|
+
|
|
31
|
+
// Kitchen-sink first so launching the story lands on something that
|
|
32
|
+
// shows every rendering branch at once. The other fixtures exercise
|
|
33
|
+
// one case at a time for regression testing.
|
|
34
|
+
const FIXTURES: readonly ChatFixture[] = [
|
|
35
|
+
kitchenSinkFixture,
|
|
36
|
+
shortFixture,
|
|
37
|
+
toolHeavyFixture,
|
|
38
|
+
imagePasteFixture,
|
|
39
|
+
rawPromptFixture,
|
|
40
|
+
errorFixture,
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
const HEADER_ROWS = 2
|
|
44
|
+
const FOOTER_ROWS = 1
|
|
45
|
+
|
|
46
|
+
const StoryApp = () => {
|
|
47
|
+
applyTheme("motel-default")
|
|
48
|
+
const renderer = useRenderer()
|
|
49
|
+
const { width, height } = useTerminalDimensions()
|
|
50
|
+
const w = width ?? 120
|
|
51
|
+
const h = height ?? 32
|
|
52
|
+
const [fixtureIdx, setFixtureIdx] = useState(0)
|
|
53
|
+
const [selectedChunkId, setSelectedChunkId] = useState<string | null>(null)
|
|
54
|
+
const [detailChunkId, setDetailChunkId] = useState<string | null>(null)
|
|
55
|
+
const [detailScrollOffset, setDetailScrollOffset] = useState(0)
|
|
56
|
+
const pendingGRef = useRef(false)
|
|
57
|
+
const quittingRef = useRef(false)
|
|
58
|
+
|
|
59
|
+
const fixture = FIXTURES[fixtureIdx] ?? FIXTURES[0]!
|
|
60
|
+
|
|
61
|
+
const detailState: AiCallDetailState = useMemo(() => ({
|
|
62
|
+
status: "ready",
|
|
63
|
+
spanId: fixture.span.spanId,
|
|
64
|
+
data: fixture.detail,
|
|
65
|
+
error: null,
|
|
66
|
+
}), [fixture])
|
|
67
|
+
|
|
68
|
+
const chunks = useMemo<readonly Chunk[]>(() => buildChunks(fixture.detail), [fixture])
|
|
69
|
+
|
|
70
|
+
// Reset selection + expansion whenever fixture changes.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
setSelectedChunkId(chunks[0]?.id ?? null)
|
|
73
|
+
setDetailChunkId(null)
|
|
74
|
+
setDetailScrollOffset(0)
|
|
75
|
+
}, [fixtureIdx, chunks])
|
|
76
|
+
|
|
77
|
+
const move = (delta: number) => {
|
|
78
|
+
if (chunks.length === 0) return
|
|
79
|
+
const idx = selectedChunkId ? chunks.findIndex((c) => c.id === selectedChunkId) : 0
|
|
80
|
+
const next = chunks[Math.max(0, Math.min(idx + delta, chunks.length - 1))]
|
|
81
|
+
if (next) setSelectedChunkId(next.id)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
useKeyboard((key) => {
|
|
85
|
+
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
86
|
+
// renderer.destroy() runs the opentui teardown (disables
|
|
87
|
+
// mouse tracking / kitty keyboard / bracketed paste / alt
|
|
88
|
+
// screen) before onDestroy exits the process. Bypassing it
|
|
89
|
+
// with a raw process.exit leaks those escape sequences into
|
|
90
|
+
// the host shell.
|
|
91
|
+
if (quittingRef.current) return
|
|
92
|
+
quittingRef.current = true
|
|
93
|
+
renderer.destroy()
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (/^[1-9]$/.test(key.name) && !key.ctrl && !key.meta) {
|
|
97
|
+
const idx = parseInt(key.name, 10) - 1
|
|
98
|
+
if (idx < FIXTURES.length) setFixtureIdx(idx)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (key.name === "j" || key.name === "down") {
|
|
102
|
+
if (detailChunkId) setDetailScrollOffset((current) => current + 1)
|
|
103
|
+
else move(1)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
if (key.name === "k" || key.name === "up") {
|
|
107
|
+
if (detailChunkId) setDetailScrollOffset((current) => Math.max(0, current - 1))
|
|
108
|
+
else move(-1)
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
if (key.name === "pagedown" || (key.ctrl && key.name === "d")) {
|
|
112
|
+
if (detailChunkId) setDetailScrollOffset((current) => current + Math.max(1, Math.floor(bodyLines / 2)))
|
|
113
|
+
else move(Math.max(1, Math.floor(chunks.length / 4)))
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
if (key.name === "pageup" || (key.ctrl && key.name === "u")) {
|
|
117
|
+
if (detailChunkId) setDetailScrollOffset((current) => Math.max(0, current - Math.max(1, Math.floor(bodyLines / 2))))
|
|
118
|
+
else move(-Math.max(1, Math.floor(chunks.length / 4)))
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
if (key.name === "g" && !key.shift) {
|
|
122
|
+
if (detailChunkId) {
|
|
123
|
+
if (pendingGRef.current) {
|
|
124
|
+
setDetailScrollOffset(0)
|
|
125
|
+
pendingGRef.current = false
|
|
126
|
+
} else {
|
|
127
|
+
pendingGRef.current = true
|
|
128
|
+
setTimeout(() => { pendingGRef.current = false }, 500)
|
|
129
|
+
}
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
if (pendingGRef.current) {
|
|
133
|
+
if (chunks[0]) setSelectedChunkId(chunks[0].id)
|
|
134
|
+
pendingGRef.current = false
|
|
135
|
+
} else {
|
|
136
|
+
pendingGRef.current = true
|
|
137
|
+
setTimeout(() => { pendingGRef.current = false }, 500)
|
|
138
|
+
}
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
if (key.name === "g" && key.shift) {
|
|
142
|
+
if (detailChunkId) setDetailScrollOffset(999999)
|
|
143
|
+
else {
|
|
144
|
+
const last = chunks[chunks.length - 1]
|
|
145
|
+
if (last) setSelectedChunkId(last.id)
|
|
146
|
+
}
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (key.name === "escape") {
|
|
150
|
+
setDetailChunkId(null)
|
|
151
|
+
setDetailScrollOffset(0)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
if (key.name === "return" || key.name === "enter") {
|
|
155
|
+
const chunk = chunks.find((c) => c.id === selectedChunkId)
|
|
156
|
+
if (chunk) {
|
|
157
|
+
setDetailChunkId(chunk.id)
|
|
158
|
+
setDetailScrollOffset(0)
|
|
159
|
+
}
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const bodyLines = Math.max(4, h - HEADER_ROWS - FOOTER_ROWS - 4 /* AI_CHAT_HEADER_ROWS */)
|
|
165
|
+
|
|
166
|
+
// Short compact labels — a single TextLine truncates with "..." if
|
|
167
|
+
// it overflows the padded content width, so we keep labels tight and
|
|
168
|
+
// use a single separator between items.
|
|
169
|
+
const fixtureList = FIXTURES.map((f, i) => (
|
|
170
|
+
<span key={f.id}>
|
|
171
|
+
<span fg={i === fixtureIdx ? colors.accent : colors.muted} attributes={i === fixtureIdx ? TextAttributes.BOLD : undefined}>
|
|
172
|
+
{`${i + 1} ${f.label}`}
|
|
173
|
+
</span>
|
|
174
|
+
{i < FIXTURES.length - 1 ? <span fg={colors.separator}>{" \u00b7 "}</span> : null}
|
|
175
|
+
</span>
|
|
176
|
+
))
|
|
177
|
+
|
|
178
|
+
// Header + divider live inside a paddingLeft/Right={1} box, so the
|
|
179
|
+
// real content width is `w - 2`. Divider must match or we get a
|
|
180
|
+
// mid-line "..." truncation.
|
|
181
|
+
const contentWidth = Math.max(8, w - 2)
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<box width={w} height={h} flexDirection="column" backgroundColor={RGBA.fromHex(colors.screenBg)}>
|
|
185
|
+
<box paddingLeft={1} paddingRight={1} height={HEADER_ROWS} flexDirection="column">
|
|
186
|
+
<TextLine>
|
|
187
|
+
<span fg={colors.muted} attributes={TextAttributes.BOLD}>AI CHAT</span>
|
|
188
|
+
<span fg={colors.separator}>{" \u00b7 "}</span>
|
|
189
|
+
{fixtureList}
|
|
190
|
+
</TextLine>
|
|
191
|
+
<Divider width={contentWidth} />
|
|
192
|
+
</box>
|
|
193
|
+
<AiChatView
|
|
194
|
+
span={fixture.span}
|
|
195
|
+
detailState={detailState}
|
|
196
|
+
chunks={chunks}
|
|
197
|
+
selectedChunkId={selectedChunkId}
|
|
198
|
+
onSelectChunk={(chunkId) => setSelectedChunkId(chunkId)}
|
|
199
|
+
detailChunkId={detailChunkId}
|
|
200
|
+
onOpenDetail={(chunkId) => {
|
|
201
|
+
setSelectedChunkId(chunkId)
|
|
202
|
+
setDetailChunkId(chunkId)
|
|
203
|
+
setDetailScrollOffset(0)
|
|
204
|
+
}}
|
|
205
|
+
onCloseDetail={() => {
|
|
206
|
+
setDetailChunkId(null)
|
|
207
|
+
setDetailScrollOffset(0)
|
|
208
|
+
}}
|
|
209
|
+
detailScrollOffset={detailScrollOffset}
|
|
210
|
+
onSetDetailScrollOffset={(updater) => setDetailScrollOffset(updater)}
|
|
211
|
+
contentWidth={Math.max(24, w - 2)}
|
|
212
|
+
bodyLines={bodyLines}
|
|
213
|
+
paneWidth={w}
|
|
214
|
+
/>
|
|
215
|
+
<box paddingLeft={1} paddingRight={1} height={FOOTER_ROWS}>
|
|
216
|
+
<TextLine>
|
|
217
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>1-9</span>
|
|
218
|
+
<span fg={colors.muted}>{" fixture "}</span>
|
|
219
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>j/k</span>
|
|
220
|
+
<span fg={colors.muted}>{" move "}</span>
|
|
221
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>enter</span>
|
|
222
|
+
<span fg={colors.muted}>{" detail "}</span>
|
|
223
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>gg/G</span>
|
|
224
|
+
<span fg={colors.muted}>{" top/bottom "}</span>
|
|
225
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>q</span>
|
|
226
|
+
<span fg={colors.muted}>{" quit"}</span>
|
|
227
|
+
</TextLine>
|
|
228
|
+
</box>
|
|
229
|
+
</box>
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const renderer = await createCliRenderer({
|
|
234
|
+
exitOnCtrlC: false,
|
|
235
|
+
screenMode: "alternate-screen",
|
|
236
|
+
onDestroy: () => { process.exit(0) },
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
createRoot(renderer).render(
|
|
240
|
+
<RegistryProvider>
|
|
241
|
+
<StoryApp />
|
|
242
|
+
</RegistryProvider>,
|
|
243
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type ChatFixture, makeDetail, makeSpan } from "./index.ts"
|
|
2
|
+
|
|
3
|
+
// A tool-call that errored mid-stream: finishReason === "error" and a
|
|
4
|
+
// tool-result whose output shape is irregular (object without the
|
|
5
|
+
// expected `type: "text"` wrapper). Covers graceful-degrade rendering.
|
|
6
|
+
export const errorFixture: ChatFixture = {
|
|
7
|
+
id: "error",
|
|
8
|
+
label: "error",
|
|
9
|
+
span: makeSpan({ status: "error", durationMs: 820 }),
|
|
10
|
+
detail: makeDetail({
|
|
11
|
+
status: "error",
|
|
12
|
+
finishReason: "error",
|
|
13
|
+
durationMs: 820,
|
|
14
|
+
promptMessages: {
|
|
15
|
+
messages: [
|
|
16
|
+
{ role: "user", content: [{ type: "text", text: "Grab the latest commits from main." }] },
|
|
17
|
+
{
|
|
18
|
+
role: "assistant",
|
|
19
|
+
content: [
|
|
20
|
+
{ type: "tool-call", toolCallId: "tc-1", toolName: "bash", input: { command: "git fetch origin main && git log --oneline -5 origin/main" } },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
role: "tool",
|
|
25
|
+
content: [
|
|
26
|
+
{
|
|
27
|
+
type: "tool-result",
|
|
28
|
+
toolCallId: "tc-1",
|
|
29
|
+
toolName: "bash",
|
|
30
|
+
output: { error: "HTTP 429 rate limited", code: 429, retryable: true },
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
role: "assistant",
|
|
36
|
+
content: [
|
|
37
|
+
{ type: "text", text: "The request was rate-limited. I'll retry after a delay — or you can rerun manually." },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
responseText: null,
|
|
43
|
+
}),
|
|
44
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ChatFixture, makeDetail, makeSpan } from "./index.ts"
|
|
2
|
+
|
|
3
|
+
// User pastes an image as a data URL alongside text. The renderer
|
|
4
|
+
// should replace the base64 blob with a compact `[data:image/png
|
|
5
|
+
// base64 NNk]` marker and leave the prose readable. Regression case
|
|
6
|
+
// for the "screen filled with base64" bug.
|
|
7
|
+
const base64Chunk = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAD".repeat(1000)
|
|
8
|
+
|
|
9
|
+
export const imagePasteFixture: ChatFixture = {
|
|
10
|
+
id: "image-paste",
|
|
11
|
+
label: "image",
|
|
12
|
+
span: makeSpan(),
|
|
13
|
+
detail: makeDetail({
|
|
14
|
+
promptMessages: {
|
|
15
|
+
messages: [
|
|
16
|
+
{ role: "system", content: "You are a code review assistant." },
|
|
17
|
+
{
|
|
18
|
+
role: "user",
|
|
19
|
+
content: [
|
|
20
|
+
{ type: "text", text: `I captured this screenshot of the bug: data:image/png;base64,${base64Chunk}\n\nCan you tell me what's off about the formatting here?` },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
role: "assistant",
|
|
25
|
+
content: [
|
|
26
|
+
{ type: "text", text: "Based on the image, the top padding looks inconsistent between rows. Let me open the component to check." },
|
|
27
|
+
{ type: "tool-call", toolCallId: "tc-1", toolName: "read", input: { filePath: "/src/ui/Row.tsx" } },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
responseText: "The padding comes from two different tokens; we should unify them.",
|
|
33
|
+
}),
|
|
34
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Shared fixture type for the AI-chat storybook. Each fixture is a
|
|
2
|
+
// label + a synthetic span + a synthetic AI call detail, in the same
|
|
3
|
+
// shape the real server returns. Prefer hand-crafted minimal examples
|
|
4
|
+
// over real captures — they exercise renderer cases deliberately and
|
|
5
|
+
// don't leak session content into the repo.
|
|
6
|
+
|
|
7
|
+
import type { AiCallDetail, TraceSpanItem } from "../../domain.ts"
|
|
8
|
+
|
|
9
|
+
export interface ChatFixture {
|
|
10
|
+
readonly id: string
|
|
11
|
+
readonly label: string
|
|
12
|
+
readonly span: TraceSpanItem
|
|
13
|
+
readonly detail: AiCallDetail
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const makeSpan = (overrides: Partial<TraceSpanItem> = {}): TraceSpanItem => ({
|
|
17
|
+
spanId: "fixture-span-0000",
|
|
18
|
+
parentSpanId: null,
|
|
19
|
+
operationName: "ai.streamText",
|
|
20
|
+
serviceName: "storybook",
|
|
21
|
+
scopeName: "ai",
|
|
22
|
+
kind: "internal",
|
|
23
|
+
status: "ok",
|
|
24
|
+
startTime: new Date(),
|
|
25
|
+
durationMs: 2400,
|
|
26
|
+
isRunning: false,
|
|
27
|
+
depth: 0,
|
|
28
|
+
tags: {
|
|
29
|
+
"ai.operationId": "ai.streamText",
|
|
30
|
+
"ai.model.id": "claude-opus-4-7",
|
|
31
|
+
"ai.model.provider": "anthropic",
|
|
32
|
+
"ai.prompt.messages": "[]",
|
|
33
|
+
},
|
|
34
|
+
warnings: [],
|
|
35
|
+
events: [],
|
|
36
|
+
...overrides,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export const makeDetail = (overrides: Partial<AiCallDetail> = {}): AiCallDetail => ({
|
|
40
|
+
traceId: "fixture-trace-0000",
|
|
41
|
+
spanId: "fixture-span-0000",
|
|
42
|
+
operation: "streamText",
|
|
43
|
+
service: "storybook",
|
|
44
|
+
functionId: "story.demo",
|
|
45
|
+
provider: "anthropic",
|
|
46
|
+
model: "claude-opus-4-7",
|
|
47
|
+
status: "ok",
|
|
48
|
+
startedAt: new Date().toISOString(),
|
|
49
|
+
durationMs: 2400,
|
|
50
|
+
sessionId: "ses_fixture",
|
|
51
|
+
userId: "kit",
|
|
52
|
+
finishReason: "stop",
|
|
53
|
+
promptMessages: null,
|
|
54
|
+
responseText: null,
|
|
55
|
+
toolCalls: [],
|
|
56
|
+
toolsAvailable: null,
|
|
57
|
+
providerMetadata: null,
|
|
58
|
+
usage: { inputTokens: 1234, outputTokens: 321, totalTokens: 1555, cachedInputTokens: 0, reasoningTokens: null },
|
|
59
|
+
timing: { msToFirstChunk: null, msToFinish: null, avgOutputTokensPerSecond: null },
|
|
60
|
+
logs: [],
|
|
61
|
+
...overrides,
|
|
62
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { type ChatFixture, makeDetail, makeSpan } from "./index.ts"
|
|
2
|
+
|
|
3
|
+
// "Kitchen sink" fixture — every rendering branch in one transcript so
|
|
4
|
+
// we can iterate on styling without flipping between fixtures. Each
|
|
5
|
+
// section below is labelled with the case it exercises.
|
|
6
|
+
//
|
|
7
|
+
// Order roughly mirrors what a realistic conversation looks like so
|
|
8
|
+
// scrolling feels natural, but it's not meant to be a coherent chat.
|
|
9
|
+
|
|
10
|
+
const base64Chunk = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAD".repeat(400)
|
|
11
|
+
|
|
12
|
+
export const kitchenSinkFixture: ChatFixture = {
|
|
13
|
+
id: "kitchen-sink",
|
|
14
|
+
label: "kitchen-sink",
|
|
15
|
+
span: makeSpan({ operationName: "ai.streamText", durationMs: 12_400 }),
|
|
16
|
+
detail: makeDetail({
|
|
17
|
+
durationMs: 12_400,
|
|
18
|
+
finishReason: "tool-calls",
|
|
19
|
+
usage: { inputTokens: 42_000, outputTokens: 1_200, totalTokens: 43_200, cachedInputTokens: 18_500, reasoningTokens: 320 },
|
|
20
|
+
promptMessages: {
|
|
21
|
+
messages: [
|
|
22
|
+
// ── 1. Long system prompt → collapses to 6 lines + hint
|
|
23
|
+
{
|
|
24
|
+
role: "system",
|
|
25
|
+
content: Array.from(
|
|
26
|
+
{ length: 120 },
|
|
27
|
+
(_, i) => `System instruction ${i}: long boilerplate that nobody reads inline`,
|
|
28
|
+
).join("\n"),
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
// ── 2. Plain user text
|
|
32
|
+
{ role: "user", content: [{ type: "text", text: "I'd like to refactor the formatter and add a todo list." }] },
|
|
33
|
+
|
|
34
|
+
// ── 3. Assistant turn exercising many inline patterns:
|
|
35
|
+
// reasoning, plain text, bash tool-call with noisy
|
|
36
|
+
// infra keys, read with filePath+offset+limit, todowrite
|
|
37
|
+
// with count, task with description+subagent_type.
|
|
38
|
+
{
|
|
39
|
+
role: "assistant",
|
|
40
|
+
content: [
|
|
41
|
+
{ type: "reasoning", text: "I should start by inspecting the current formatter and the test file, then plan the changes in a todo list before touching any code." },
|
|
42
|
+
{ type: "text", text: "Plan: check current state, build a todo list, then delegate the heavy read to a subagent." },
|
|
43
|
+
{ type: "tool-call", toolCallId: "tc-1", toolName: "bash", input: { command: "git status --short --branch", timeout: 120_000, workdir: "/Users/kit/code/open-source/opencode", description: "ignored" } },
|
|
44
|
+
{ type: "tool-call", toolCallId: "tc-2", toolName: "read", input: { filePath: "/src/formatter.ts", offset: 40, limit: 80 } },
|
|
45
|
+
{ type: "tool-call", toolCallId: "tc-3", toolName: "todowrite", input: { todos: [{}, {}, {}, {}, {}] } },
|
|
46
|
+
{ type: "tool-call", toolCallId: "tc-4", toolName: "task", input: { description: "Find every caller of formatDocument and summarise their shape", subagent_type: "explore" } },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// ── 4. Tool results: short plain, long truncated, and
|
|
51
|
+
// irregular object-shaped output (error-case fallback).
|
|
52
|
+
{
|
|
53
|
+
role: "tool",
|
|
54
|
+
content: [
|
|
55
|
+
{ type: "tool-result", toolCallId: "tc-1", toolName: "bash", output: { type: "text", value: "## dev...origin/dev [ahead 8, behind 11]\n M src/formatter.ts" } },
|
|
56
|
+
{ type: "tool-result", toolCallId: "tc-2", toolName: "read", output: { type: "text", value: Array.from({ length: 80 }, (_, i) => `${i + 1}: // formatter line ${i} — some body content`).join("\n") } },
|
|
57
|
+
{ type: "tool-result", toolCallId: "tc-3", toolName: "todowrite", output: { type: "text", value: "ok (5 todos)" } },
|
|
58
|
+
{ type: "tool-result", toolCallId: "tc-4", toolName: "task", output: { error: "rate-limited", code: 429, retryable: true } },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
// ── 5. Another assistant turn: edit (primary+secondary
|
|
63
|
+
// summary line), webfetch (url), unknown tool, unknown
|
|
64
|
+
// content-part type.
|
|
65
|
+
{
|
|
66
|
+
role: "assistant",
|
|
67
|
+
content: [
|
|
68
|
+
{ type: "text", text: "Now the edit itself, then a quick doc fetch for context:" },
|
|
69
|
+
{ type: "tool-call", toolCallId: "tc-5", toolName: "edit", input: { filePath: "/src/formatter.ts", oldString: "function format(doc: Doc)", newString: "export function format(doc: Doc)" } },
|
|
70
|
+
{ type: "tool-call", toolCallId: "tc-6", toolName: "webfetch", input: { url: "https://effect.website/docs/observability/tracing", format: "markdown" } },
|
|
71
|
+
{ type: "tool-call", toolCallId: "tc-7", toolName: "novel-tool-unknown-to-us", input: { foo: "bar", count: 3 } },
|
|
72
|
+
// Unknown content part kind — should fall through to
|
|
73
|
+
// the `[future-thing] …` hint line.
|
|
74
|
+
{ type: "future-thing", payload: { a: 1, b: "x" } } as unknown as { type: string },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
// ── 6. User pastes an image (base64 data URL) → scrubbed
|
|
79
|
+
// to a compact marker.
|
|
80
|
+
{
|
|
81
|
+
role: "user",
|
|
82
|
+
content: [
|
|
83
|
+
{ type: "text", text: `Here's the bug screenshot: data:image/png;base64,${base64Chunk}\n\nCan you tell me what's wrong with the spacing?` },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// ── 7. Final assistant turn — text only, for the response
|
|
88
|
+
// baseline.
|
|
89
|
+
{
|
|
90
|
+
role: "assistant",
|
|
91
|
+
content: [{ type: "text", text: "The padding token on row 3 is using `spacing.md` instead of `spacing.sm`. Swapping it fixes the alignment." }],
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
// ── 8. Extra cycles below are purely to make the kitchen sink
|
|
95
|
+
// scroll in the story so spacing/selection can be judged in
|
|
96
|
+
// motion rather than only in the first viewport.
|
|
97
|
+
{ role: "user", content: [{ type: "text", text: "Can you also check the border color on the warning banner?" }] },
|
|
98
|
+
{
|
|
99
|
+
role: "assistant",
|
|
100
|
+
content: [
|
|
101
|
+
{ type: "text", text: "Yep — I’ll inspect the banner component and its token mapping." },
|
|
102
|
+
{ type: "tool-call", toolCallId: "tc-8", toolName: "read", input: { filePath: "/src/components/WarningBanner.tsx", offset: 1, limit: 120 } },
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
role: "tool",
|
|
107
|
+
content: [
|
|
108
|
+
{ type: "tool-result", toolCallId: "tc-8", toolName: "read", output: { type: "text", value: Array.from({ length: 25 }, (_, i) => `${i + 1}: const borderColor = tokens.warning.border // sample ${i}`).join("\n") } },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
{ role: "assistant", content: [{ type: "text", text: "The banner is using the right border token, but the hover state swaps in the generic accent border. That’s why it feels inconsistent." }] },
|
|
113
|
+
{ role: "user", content: [{ type: "text", text: "What about the empty state in the sidebar — too much top padding there too?" }] },
|
|
114
|
+
|
|
115
|
+
{
|
|
116
|
+
role: "assistant",
|
|
117
|
+
content: [
|
|
118
|
+
{ type: "reasoning", text: "This is another quick component read. I should keep the answer short and just point to the token causing the extra top space." },
|
|
119
|
+
{ type: "tool-call", toolCallId: "tc-9", toolName: "grep", input: { pattern: "empty state|paddingTop|padding-top", path: "/src" } },
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
role: "tool",
|
|
124
|
+
content: [
|
|
125
|
+
{ type: "tool-result", toolCallId: "tc-9", toolName: "grep", output: { type: "text", value: "src/ui/Sidebar.tsx:44: const paddingTop = tokens.spacing.xl\nsrc/ui/Sidebar.tsx:72: <EmptyState style={{ paddingTop }} />" } },
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
{ role: "assistant", content: [{ type: "text", text: "Yes — the sidebar empty state is using `spacing.xl`. Dropping it to `spacing.lg` would align it with the rest of the panel chrome." }] },
|
|
129
|
+
|
|
130
|
+
{ role: "user", content: [{ type: "text", text: "Could you sketch the three fixes as a compact todo list?" }] },
|
|
131
|
+
{
|
|
132
|
+
role: "assistant",
|
|
133
|
+
content: [
|
|
134
|
+
{ type: "tool-call", toolCallId: "tc-10", toolName: "todowrite", input: { todos: [{ title: "fix row-3 padding token" }, { title: "fix warning banner hover border" }, { title: "reduce sidebar empty-state top padding" }] } },
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
role: "tool",
|
|
139
|
+
content: [
|
|
140
|
+
{ type: "tool-result", toolCallId: "tc-10", toolName: "todowrite", output: { type: "text", value: "ok (3 todos)" } },
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
{ role: "assistant", content: [{ type: "text", text: "1. Fix row 3 padding token. 2. Keep warning banner hover on the warning border token. 3. Reduce sidebar empty-state top padding from `xl` to `lg`." }] },
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
responseText: "The padding token on row 3 is using `spacing.md` instead of `spacing.sm`. Swapping it fixes the alignment.",
|
|
147
|
+
}),
|
|
148
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ChatFixture, makeDetail, makeSpan } from "./index.ts"
|
|
2
|
+
|
|
3
|
+
// Older AI SDK captures stored `ai.prompt` as a plain string instead
|
|
4
|
+
// of a structured `{ messages: [...] }` object. Renderer should fall
|
|
5
|
+
// back to a single PROMPT (raw) block. Smoke-test for the fallback
|
|
6
|
+
// path.
|
|
7
|
+
export const rawPromptFixture: ChatFixture = {
|
|
8
|
+
id: "raw-prompt",
|
|
9
|
+
label: "raw",
|
|
10
|
+
span: makeSpan(),
|
|
11
|
+
detail: makeDetail({
|
|
12
|
+
promptMessages: "Summarise the following: a long bare prompt with no message structure. Imagine this is how an older ai-sdk version captured the conversation. It's just one opaque text blob that used to flow into the model.",
|
|
13
|
+
responseText: "Here's a one-line summary.",
|
|
14
|
+
}),
|
|
15
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ChatFixture, makeDetail, makeSpan } from "./index.ts"
|
|
2
|
+
|
|
3
|
+
// Minimal 2-turn Q&A. Serves as the "baseline sanity check" — if this
|
|
4
|
+
// doesn't render cleanly, something's wrong with the headers/wrapping.
|
|
5
|
+
export const shortFixture: ChatFixture = {
|
|
6
|
+
id: "short",
|
|
7
|
+
label: "short",
|
|
8
|
+
span: makeSpan(),
|
|
9
|
+
detail: makeDetail({
|
|
10
|
+
promptMessages: {
|
|
11
|
+
messages: [
|
|
12
|
+
{ role: "system", content: "You are a helpful assistant. Keep replies concise." },
|
|
13
|
+
{ role: "user", content: [{ type: "text", text: "Hey, can you explain what SQLite WAL mode means in one paragraph?" }] },
|
|
14
|
+
{
|
|
15
|
+
role: "assistant",
|
|
16
|
+
content: [
|
|
17
|
+
{
|
|
18
|
+
type: "text",
|
|
19
|
+
text: "WAL (write-ahead log) is a journaling mode in SQLite where writes are appended to a separate `.wal` file instead of rewriting the main database in place. Readers can keep reading the last committed snapshot from the main file while a writer is appending to the WAL, which means concurrent reads and writes don't block each other. Periodically SQLite checkpoints the WAL back into the main database. The upside: much higher concurrency than the default rollback journal. The downside: you get an extra file per database and slightly more complex crash recovery.",
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
responseText: "WAL (write-ahead log) is a journaling mode in SQLite where writes are appended to a separate `.wal` file instead of rewriting the main database in place. Readers can keep reading the last committed snapshot from the main file while a writer is appending to the WAL, which means concurrent reads and writes don't block each other.",
|
|
26
|
+
}),
|
|
27
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type ChatFixture, makeDetail, makeSpan } from "./index.ts"
|
|
2
|
+
|
|
3
|
+
// Realistic multi-turn session with bash + read + edit tool calls and
|
|
4
|
+
// tool results. Mirrors the shape we saw in actual opencode traces.
|
|
5
|
+
// Exercises: smart tool-input summaries, consecutive tool calls,
|
|
6
|
+
// bounded tool-result rendering, reasoning blocks.
|
|
7
|
+
export const toolHeavyFixture: ChatFixture = {
|
|
8
|
+
id: "tool-heavy",
|
|
9
|
+
label: "tools",
|
|
10
|
+
span: makeSpan({ operationName: "ai.streamText", durationMs: 9_400 }),
|
|
11
|
+
detail: makeDetail({
|
|
12
|
+
durationMs: 9_400,
|
|
13
|
+
finishReason: "tool-calls",
|
|
14
|
+
usage: { inputTokens: 135_131, outputTokens: 943, totalTokens: 136_074, cachedInputTokens: 133_818, reasoningTokens: null },
|
|
15
|
+
promptMessages: {
|
|
16
|
+
messages: [
|
|
17
|
+
{
|
|
18
|
+
role: "system",
|
|
19
|
+
content: Array.from({ length: 120 }, (_, i) => `System rule ${i}: long boilerplate content goes here that nobody usually needs to read inline`).join("\n"),
|
|
20
|
+
},
|
|
21
|
+
{ role: "user", content: [{ type: "text", text: "Can you review the formatter changes in PR 23020 and tell me if it looks like a regression?" }] },
|
|
22
|
+
{
|
|
23
|
+
role: "assistant",
|
|
24
|
+
content: [
|
|
25
|
+
{ type: "reasoning", text: "I should inspect the current worktree state and compare it against the PR diff. Starting with `git status` and then grabbing the diff from GitHub." },
|
|
26
|
+
{ type: "tool-call", toolCallId: "tc-1", toolName: "bash", input: { command: "git status --short --branch", timeout: 120_000, workdir: "/Users/kit/code/open-source/opencode", description: "Show branch state" } },
|
|
27
|
+
{ type: "tool-call", toolCallId: "tc-2", toolName: "bash", input: { command: "git worktree list", timeout: 120_000, workdir: "/Users/kit/code/open-source/opencode" } },
|
|
28
|
+
{ type: "tool-call", toolCallId: "tc-3", toolName: "bash", input: { command: "gh pr view 23020 --json title,headRefName,baseRefName,author,url,files", timeout: 120_000 } },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
role: "tool",
|
|
33
|
+
content: [
|
|
34
|
+
{ type: "tool-result", toolCallId: "tc-1", toolName: "bash", output: { type: "text", value: "## dev...origin/dev [ahead 8, behind 11]\n M packages/opencode/specs/effect/loose-ends.md" } },
|
|
35
|
+
{ type: "tool-result", toolCallId: "tc-2", toolName: "bash", output: { type: "text", value: ["/Users/kit/code/open-source/opencode 6b7f34df2 [dev]", "/private/tmp/oc-repro-22872 69f7182f0 [kit/repro-write-lsp-hang]", "/private/tmp/opencode-pr22385-base 87b2a9d74 (detached HEAD)", "/Users/kit/code/open-source/opencode-1.4.7 9f201d637 (detached HEAD)", "/Users/kit/code/open-source/opencode-await-thenable 343a56418 [kit/oxlint-await-thenable]", "/Users/kit/code/open-source/opencode-config-httpapi df7e2fe63 [kit/config-httpapi]"].join("\n") } },
|
|
36
|
+
{ type: "tool-result", toolCallId: "tc-3", toolName: "bash", output: { type: "text", value: '{"title":"fix: add formatter","headRefName":"pr-23020","baseRefName":"dev","url":"https://github.com/anomalyco/opencode/pull/23020","files":[{"path":"packages/opencode/src/formatter.ts","additions":43,"deletions":12}]}' } },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
role: "assistant",
|
|
41
|
+
content: [
|
|
42
|
+
{ type: "text", text: "Let me read the actual formatter diff to see what changed in detail." },
|
|
43
|
+
{ type: "tool-call", toolCallId: "tc-4", toolName: "read", input: { filePath: "/Users/kit/code/open-source/opencode/packages/opencode/src/formatter.ts" } },
|
|
44
|
+
{ type: "tool-call", toolCallId: "tc-5", toolName: "read", input: { filePath: "/Users/kit/code/open-source/opencode/packages/opencode/src/formatter.test.ts", offset: 1, limit: 200 } },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
role: "tool",
|
|
49
|
+
content: [
|
|
50
|
+
{ type: "tool-result", toolCallId: "tc-4", toolName: "read", output: { type: "text", value: Array.from({ length: 60 }, (_, i) => `${i + 1}: // formatter source line ${i}`).join("\n") } },
|
|
51
|
+
{ type: "tool-result", toolCallId: "tc-5", toolName: "read", output: { type: "text", value: Array.from({ length: 30 }, (_, i) => `${i + 1}: test("case ${i}", () => { expect(x).toBe(y) })`).join("\n") } },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
role: "assistant",
|
|
56
|
+
content: [
|
|
57
|
+
{ type: "text", text: "The diff changes how trailing newlines are emitted. It looks intentional — they're aligning the output with what prettier does downstream. I wouldn't flag it as a regression." },
|
|
58
|
+
{ type: "tool-call", toolCallId: "tc-6", toolName: "todowrite", input: { todos: [{ title: "verify tests still pass" }, { title: "comment on the PR" }, { title: "merge if CI green" }] } },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
responseText: "Not a regression. The formatter change aligns with how prettier emits trailing newlines; the test still covers the behaviour. I'd approve it.",
|
|
64
|
+
}),
|
|
65
|
+
}
|