@kitlangton/motel 0.2.1 → 0.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitlangton/motel",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
4
4
  "description": "A local OpenTelemetry ingest + TUI viewer for development, backed by SQLite.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -65,8 +65,8 @@
65
65
  "search-spans": "bun run src/cli.ts search-spans",
66
66
  "trace-stats": "bun run src/cli.ts trace-stats",
67
67
  "log-stats": "bun run src/cli.ts log-stats",
68
- "web:dev": "cd web && npx vite",
69
- "web:build": "cd web && npx vite build",
68
+ "web:dev": "bun run --cwd web dev",
69
+ "web:build": "bun run --cwd web build",
70
70
  "story:chat": "bun run src/storybook/aiChatStory.tsx",
71
71
  "typecheck": "tsc --noEmit",
72
72
  "prepublishOnly": "bun run web:build"
package/src/App.tsx CHANGED
@@ -28,7 +28,7 @@ import { applyTheme, colors, SEPARATOR, themeLabel } from "./ui/theme.ts"
28
28
  import { useKeyboardNav } from "./ui/useKeyboardNav.ts"
29
29
  import { AttrFilterModal } from "./ui/AttrFilterModal.tsx"
30
30
  import { useAttrFilterPicker } from "./ui/useAttrFilterPicker.ts"
31
- import { getVisibleSpans } from "./ui/Waterfall.tsx"
31
+ import { getVisibleSpans } from "./ui/waterfallModel.ts"
32
32
 
33
33
  const NOTICE_TIMEOUT_MS = 2500
34
34
 
@@ -1,3 +1,4 @@
1
+ import { Database } from "bun:sqlite"
1
2
  import { afterEach, describe, expect, test } from "bun:test"
2
3
  import { Effect } from "effect"
3
4
  import * as fs from "node:fs"
@@ -30,6 +31,16 @@ const makeHarness = (): Harness => {
30
31
  return { runtimeDir, port, databasePath, manager }
31
32
  }
32
33
 
34
+ const withCwd = async <A>(cwd: string, f: () => Promise<A>): Promise<A> => {
35
+ const previous = process.cwd()
36
+ process.chdir(cwd)
37
+ try {
38
+ return await f()
39
+ } finally {
40
+ process.chdir(previous)
41
+ }
42
+ }
43
+
33
44
  /**
34
45
  * Start a motel-shaped HTTP server on a test port that answers
35
46
  * /api/health with an arbitrary delay. Used to simulate a real daemon
@@ -193,4 +204,63 @@ describe("daemon manager", () => {
193
204
  const finalStatus = await Effect.runPromise(harness.manager.getStatus)
194
205
  expect(finalStatus.running).toBe(false)
195
206
  })
207
+
208
+ test("becomes healthy even if trace summary rebuild hits a write lock", async () => {
209
+ const harness = makeHarness()
210
+ activeHarnesses.push(harness)
211
+
212
+ const firstStart = await Effect.runPromise(harness.manager.ensure)
213
+ expect(firstStart.running).toBe(true)
214
+ await Effect.runPromise(harness.manager.stop)
215
+
216
+ const locker = new Database(harness.databasePath)
217
+ locker.exec("BEGIN IMMEDIATE")
218
+ try {
219
+ const startedAt = performance.now()
220
+ const restarted = await Effect.runPromise(harness.manager.ensure)
221
+ const elapsed = performance.now() - startedAt
222
+ expect(restarted.running).toBe(true)
223
+ expect(restarted.managed).toBe(true)
224
+ expect(elapsed).toBeLessThan(10_000)
225
+ } finally {
226
+ locker.exec("ROLLBACK")
227
+ locker.close()
228
+ }
229
+ }, 20_000)
230
+
231
+ test("starts for the caller cwd even when motel is installed elsewhere", async () => {
232
+ const projectDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-project-")))
233
+ const databasePath = path.join(projectDir, ".motel-data", "telemetry.sqlite")
234
+ let manager: ReturnType<typeof createDaemonManager> | null = null
235
+
236
+ try {
237
+ await withCwd(projectDir, async () => {
238
+ manager = createDaemonManager({
239
+ repoRoot,
240
+ port: randomPort(),
241
+ })
242
+
243
+ const started = await Effect.runPromise(manager.ensure)
244
+ expect(started.running).toBe(true)
245
+ expect(started.managed).toBe(true)
246
+ expect(started.workdir).toBe(projectDir)
247
+ expect(started.sameWorkdir).toBe(true)
248
+ expect(started.databasePath).toBe(databasePath)
249
+ expect(started.logPath).toBe(path.join(projectDir, ".motel-data", "daemon.log"))
250
+
251
+ const reused = await Effect.runPromise(manager.ensure)
252
+ expect(reused.pid).toBe(started.pid)
253
+
254
+ const stopped = await Effect.runPromise(manager.stop)
255
+ expect(stopped.running).toBe(false)
256
+ })
257
+ } finally {
258
+ await withCwd(projectDir, async () => {
259
+ if (manager) {
260
+ await Effect.runPromise(manager.stop).catch(() => undefined)
261
+ }
262
+ })
263
+ fs.rmSync(projectDir, { recursive: true, force: true })
264
+ }
265
+ })
196
266
  })
package/src/daemon.ts CHANGED
@@ -2,14 +2,12 @@ import * as fs from "node:fs"
2
2
  import { promises as fsp } from "node:fs"
3
3
  import * as path from "node:path"
4
4
  import { Effect } from "effect"
5
- import { isAlive, listAliveEntries, MOTEL_SERVICE_ID, type RegistryEntry } from "./registry.js"
5
+ import { isAlive, listAliveEntries, MOTEL_SERVICE_ID, MOTEL_VERSION, type RegistryEntry } from "./registry.js"
6
6
 
7
7
  const DEFAULT_REPO_ROOT = path.resolve(import.meta.dir, "..")
8
- const DEFAULT_RUNTIME_DIR = path.join(DEFAULT_REPO_ROOT, ".motel-data")
9
- const DEFAULT_DATABASE_PATH = path.join(DEFAULT_RUNTIME_DIR, "telemetry.sqlite")
10
8
  const DEFAULT_HOST = "127.0.0.1"
11
9
  const DEFAULT_PORT = 27686
12
- const START_TIMEOUT_MS = 15_000
10
+ const START_TIMEOUT_MS = 30_000
13
11
  const STOP_TIMEOUT_MS = 10_000
14
12
  const LOCK_TIMEOUT_MS = 10_000
15
13
  const POLL_INTERVAL_MS = 150
@@ -44,6 +42,8 @@ type LockShape = {
44
42
 
45
43
  type DaemonConfig = {
46
44
  readonly repoRoot: string
45
+ readonly serverEntry: string
46
+ readonly workdir: string
47
47
  readonly runtimeDir: string
48
48
  readonly databasePath: string
49
49
  readonly logPath: string
@@ -79,6 +79,7 @@ export type DaemonManager = {
79
79
 
80
80
  type DaemonOptions = {
81
81
  readonly repoRoot?: string
82
+ readonly workdir?: string
82
83
  readonly runtimeDir?: string
83
84
  readonly databasePath?: string
84
85
  readonly host?: string
@@ -96,12 +97,15 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
96
97
 
97
98
  const resolveConfig = (options: DaemonOptions = {}): DaemonConfig => {
98
99
  const repoRoot = path.resolve(options.repoRoot ?? DEFAULT_REPO_ROOT)
99
- const runtimeDir = path.resolve(options.runtimeDir ?? DEFAULT_RUNTIME_DIR)
100
+ const workdir = path.resolve(options.workdir ?? process.cwd())
101
+ const runtimeDir = path.resolve(options.runtimeDir ?? path.join(workdir, ".motel-data"))
100
102
  const databasePath = path.resolve(options.databasePath ?? path.join(runtimeDir, "telemetry.sqlite"))
101
103
  const host = options.host ?? DEFAULT_HOST
102
104
  const port = options.port ?? DEFAULT_PORT
103
105
  return {
104
106
  repoRoot,
107
+ serverEntry: path.join(repoRoot, "src/server.ts"),
108
+ workdir,
105
109
  runtimeDir,
106
110
  databasePath,
107
111
  logPath: path.join(runtimeDir, "daemon.log"),
@@ -112,16 +116,14 @@ const resolveConfig = (options: DaemonOptions = {}): DaemonConfig => {
112
116
  }
113
117
  }
114
118
 
115
- const cwdMatches = (workdir: string) => {
116
- const cwd = process.cwd()
117
- const normalizedCwd = cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`
118
- const normalizedWorkdir = workdir.endsWith(path.sep) ? workdir : `${workdir}${path.sep}`
119
- return normalizedCwd === normalizedWorkdir || normalizedCwd.startsWith(normalizedWorkdir)
119
+ const workdirMatches = (targetWorkdir: string, daemonWorkdir: string) => {
120
+ const normalizedTarget = targetWorkdir.endsWith(path.sep) ? targetWorkdir : `${targetWorkdir}${path.sep}`
121
+ const normalizedDaemon = daemonWorkdir.endsWith(path.sep) ? daemonWorkdir : `${daemonWorkdir}${path.sep}`
122
+ return normalizedTarget === normalizedDaemon || normalizedTarget.startsWith(normalizedDaemon)
120
123
  }
121
124
 
122
- const pickByCwd = (entries: readonly RegistryEntry[]) => {
123
- const cwd = process.cwd()
124
- const withSep = cwd.endsWith(path.sep) ? cwd : `${cwd}${path.sep}`
125
+ const pickByWorkdir = (entries: readonly RegistryEntry[], targetWorkdir: string) => {
126
+ const withSep = targetWorkdir.endsWith(path.sep) ? targetWorkdir : `${targetWorkdir}${path.sep}`
125
127
  return entries
126
128
  .filter((entry) => {
127
129
  const workdir = entry.workdir.endsWith(path.sep) ? entry.workdir : `${entry.workdir}${path.sep}`
@@ -130,8 +132,6 @@ const pickByCwd = (entries: readonly RegistryEntry[]) => {
130
132
  .sort((a, b) => b.workdir.length - a.workdir.length)[0] ?? null
131
133
  }
132
134
 
133
- const readRegistryEntry = () => pickByCwd(listAliveEntries())
134
-
135
135
  const expectedEnv = (config: DaemonConfig) => ({
136
136
  MOTEL_OTEL_BASE_URL: config.baseUrl,
137
137
  MOTEL_OTEL_QUERY_URL: config.baseUrl,
@@ -145,6 +145,7 @@ const expectedEnv = (config: DaemonConfig) => ({
145
145
  export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager => {
146
146
  const config = resolveConfig(options)
147
147
  const mapError = (error: unknown) => new DaemonError(error instanceof Error ? error.message : String(error))
148
+ const readRegistryEntry = () => pickByWorkdir(listAliveEntries(), config.workdir)
148
149
 
149
150
  const fetchHealth = async (timeoutMs: number = HEALTH_FAST_TIMEOUT_MS): Promise<HealthShape | null> => {
150
151
  try {
@@ -156,12 +157,39 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
156
157
  }
157
158
  }
158
159
 
160
+ const startupMarkers = [`Listening on ${config.baseUrl}`, `motel local telemetry server listening on ${config.baseUrl}`]
161
+
162
+ const readLogSince = async (offset: number) => {
163
+ try {
164
+ const raw = await fsp.readFile(config.logPath, "utf8")
165
+ return raw.slice(offset)
166
+ } catch {
167
+ return ""
168
+ }
169
+ }
170
+
171
+ const detectStartedFromLog = async (pid: number, offset: number): Promise<HealthShape | null> => {
172
+ if (!isAlive(pid)) return null
173
+ const tail = await readLogSince(offset)
174
+ if (!startupMarkers.some((marker) => tail.includes(marker))) return null
175
+ return {
176
+ ok: true,
177
+ service: MOTEL_SERVICE_ID,
178
+ databasePath: config.databasePath,
179
+ pid,
180
+ url: config.baseUrl,
181
+ workdir: config.workdir,
182
+ startedAt: new Date().toISOString(),
183
+ version: MOTEL_VERSION,
184
+ }
185
+ }
186
+
159
187
  const describeManagedMismatch = (health: HealthShape) => {
160
188
  if (health.service !== MOTEL_SERVICE_ID) {
161
189
  return `Port ${config.port} is in use by ${health.service}, not ${MOTEL_SERVICE_ID}.`
162
190
  }
163
- if (!cwdMatches(health.workdir)) {
164
- return `Port ${config.port} is serving motel for ${health.workdir}, not ${process.cwd()}.`
191
+ if (!workdirMatches(config.workdir, health.workdir)) {
192
+ return `Port ${config.port} is serving motel for ${health.workdir}, not ${config.workdir}.`
165
193
  }
166
194
  if (health.databasePath !== config.databasePath) {
167
195
  return `Port ${config.port} is serving motel with ${health.databasePath}, expected ${config.databasePath}.`
@@ -181,8 +209,8 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
181
209
  * when absent we skip the DB check rather than refusing to adopt.
182
210
  */
183
211
  const describeRegistryMismatch = (entry: RegistryEntry): string | null => {
184
- if (!cwdMatches(entry.workdir)) {
185
- return `Port ${config.port} is serving motel for ${entry.workdir}, not ${process.cwd()}.`
212
+ if (!workdirMatches(config.workdir, entry.workdir)) {
213
+ return `Port ${config.port} is serving motel for ${entry.workdir}, not ${config.workdir}.`
186
214
  }
187
215
  if (entry.databasePath && entry.databasePath !== config.databasePath) {
188
216
  return `Port ${config.port} is serving motel with ${entry.databasePath}, expected ${config.databasePath}.`
@@ -217,7 +245,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
217
245
  workdir: entry.workdir,
218
246
  startedAt: entry.startedAt,
219
247
  version: entry.version,
220
- sameWorkdir: cwdMatches(entry.workdir),
248
+ sameWorkdir: workdirMatches(config.workdir, entry.workdir),
221
249
  reason: mismatch,
222
250
  logPath: config.logPath,
223
251
  lockPath: config.lockPath,
@@ -276,7 +304,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
276
304
  return fs.openSync(config.logPath, "a")
277
305
  }
278
306
 
279
- const waitForHealthy = async (pid: number) => {
307
+ const waitForHealthy = async (pid: number, logOffset: number) => {
280
308
  const deadline = Date.now() + START_TIMEOUT_MS
281
309
  while (Date.now() < deadline) {
282
310
  const health = await fetchHealth()
@@ -285,6 +313,8 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
285
313
  if (!mismatch) return health
286
314
  throw new Error(mismatch)
287
315
  }
316
+ const started = await detectStartedFromLog(pid, logOffset)
317
+ if (started) return started
288
318
  if (!isAlive(pid)) {
289
319
  // The spawned child is gone. Before declaring failure,
290
320
  // do one patient probe: the child may have died from
@@ -316,7 +346,9 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
316
346
  while (Date.now() < deadline) {
317
347
  if (!isAlive(pid)) return
318
348
  const health = await fetchHealth()
319
- if (!health || health.pid !== pid) return
349
+ if (health && health.pid !== pid) return
350
+ const registry = readRegistryEntry()
351
+ if (!health && (!registry || registry.pid !== pid)) return
320
352
  await sleep(POLL_INTERVAL_MS)
321
353
  }
322
354
 
@@ -352,7 +384,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
352
384
  workdir: registry?.workdir ?? null,
353
385
  startedAt: registry?.startedAt ?? null,
354
386
  version: registry?.version ?? null,
355
- sameWorkdir: registry ? cwdMatches(registry.workdir) : false,
387
+ sameWorkdir: registry ? workdirMatches(config.workdir, registry.workdir) : false,
356
388
  reason: registry ? "Registry entry exists but daemon is not healthy." : null,
357
389
  logPath: config.logPath,
358
390
  lockPath: config.lockPath,
@@ -371,7 +403,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
371
403
  workdir: health.workdir,
372
404
  startedAt: health.startedAt,
373
405
  version: health.version,
374
- sameWorkdir: cwdMatches(health.workdir),
406
+ sameWorkdir: workdirMatches(config.workdir, health.workdir),
375
407
  reason: mismatch,
376
408
  logPath: config.logPath,
377
409
  lockPath: config.lockPath,
@@ -404,10 +436,11 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
404
436
  }
405
437
 
406
438
  const logFd = await openLogFile()
439
+ const logOffset = fs.fstatSync(logFd).size
407
440
  try {
408
441
  const proc = Bun.spawn({
409
- cmd: [process.execPath, "run", "src/server.ts"],
410
- cwd: config.repoRoot,
442
+ cmd: [process.execPath, "run", config.serverEntry],
443
+ cwd: config.workdir,
411
444
  detached: true,
412
445
  env: {
413
446
  ...process.env,
@@ -425,7 +458,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
425
458
  throw new Error("Daemon failed to spawn.")
426
459
  }
427
460
 
428
- const health = await waitForHealthy(spawnedPid)
461
+ const health = await waitForHealthy(spawnedPid, logOffset)
429
462
  return {
430
463
  running: true,
431
464
  managed: true,
@@ -436,7 +469,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
436
469
  workdir: health.workdir,
437
470
  startedAt: health.startedAt,
438
471
  version: health.version,
439
- sameWorkdir: cwdMatches(health.workdir),
472
+ sameWorkdir: workdirMatches(config.workdir, health.workdir),
440
473
  reason: null,
441
474
  logPath: config.logPath,
442
475
  lockPath: config.lockPath,
@@ -495,9 +528,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
495
528
  }
496
529
  }
497
530
 
498
- const defaultManager = createDaemonManager()
499
-
500
- export const applyManagedDaemonEnv = defaultManager.applyEnv
501
- export const getManagedDaemonStatus = defaultManager.getStatus
502
- export const ensureManagedDaemon = defaultManager.ensure
503
- export const stopManagedDaemon = defaultManager.stop
531
+ export const applyManagedDaemonEnv = Effect.suspend(() => createDaemonManager().applyEnv)
532
+ export const getManagedDaemonStatus = Effect.suspend(() => createDaemonManager().getStatus)
533
+ export const ensureManagedDaemon = Effect.suspend(() => createDaemonManager().ensure)
534
+ export const stopManagedDaemon = Effect.suspend(() => createDaemonManager().stop)
@@ -9,7 +9,7 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
9
9
  import * as HttpStaticServer from "effect/unstable/http/HttpStaticServer"
10
10
  import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"
11
11
  import { MotelHttpApi } from "./httpApi.js"
12
- import { attributeFiltersFromEntries, attributeContainsFiltersFromEntries, ATTRIBUTE_FILTER_PREFIX, ATTRIBUTE_CONTAINS_PREFIX } from "./queryFilters.js"
12
+ import { attributeFiltersFromEntries, attributeContainsFiltersFromEntries } from "./queryFilters.js"
13
13
  import { MOTEL_SERVICE_ID, MOTEL_VERSION, removeRegistryEntry, writeRegistryEntry } from "./registry.js"
14
14
  import { AsyncIngest, AsyncIngestLive } from "./services/AsyncIngest.js"
15
15
  import { TelemetryStore, TelemetryStoreLive } from "./services/TelemetryStore.js"
@@ -72,14 +72,10 @@ const parseLookbackMinutes = (value: string | null, fallback: number) => {
72
72
  const parseBoundedLookbackMinutes = (value: string | null, fallback: number, max: number) => clamp(parseLookbackMinutes(value, fallback), 1, max)
73
73
 
74
74
  const attributeFiltersFromQuery = (url: URL) =>
75
- attributeFiltersFromEntries(
76
- [...url.searchParams.entries()].filter(([key]) => key.startsWith(ATTRIBUTE_FILTER_PREFIX) && !key.startsWith(ATTRIBUTE_CONTAINS_PREFIX)),
77
- )
75
+ attributeFiltersFromEntries(url.searchParams.entries())
78
76
 
79
77
  const attributeContainsFiltersFromQuery = (url: URL) =>
80
- attributeContainsFiltersFromEntries(
81
- [...url.searchParams.entries()].filter(([key]) => key.startsWith(ATTRIBUTE_CONTAINS_PREFIX)),
82
- )
78
+ attributeContainsFiltersFromEntries(url.searchParams.entries())
83
79
 
84
80
  type CursorShape =
85
81
  | { readonly kind: "trace"; readonly startedAt: number; readonly id: string }
@@ -20,10 +20,11 @@
20
20
  */
21
21
 
22
22
  import * as BunWorker from "@effect/platform-bun/BunWorker"
23
- import { Context, Layer } from "effect"
23
+ import { Context, Effect, Layer, Scope } from "effect"
24
24
  import * as RpcClient from "effect/unstable/rpc/RpcClient"
25
25
  import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"
26
26
  import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
27
+ import type { WorkerError } from "effect/unstable/workers/WorkerError"
27
28
  import { IngestRpcs } from "./ingestRpc.ts"
28
29
 
29
30
  // RpcClient.make always surfaces RpcClientError in addition to the
@@ -33,7 +34,7 @@ import { IngestRpcs } from "./ingestRpc.ts"
33
34
  // unrelated structural mismatches.
34
35
  export class AsyncIngest extends Context.Service<
35
36
  AsyncIngest,
36
- RpcClient.FromGroup<typeof IngestRpcs, RpcClientError>
37
+ RpcClient.FromGroup<typeof IngestRpcs, RpcClientError | WorkerError>
37
38
  >()("@motel/AsyncIngest") {}
38
39
 
39
40
  // Protocol: RpcClient.layerProtocolWorker manages a worker pool and
@@ -48,5 +49,20 @@ const WorkerProtocol = RpcClient.layerProtocolWorker({ size: 1 }).pipe(
48
49
 
49
50
  export const AsyncIngestLive = Layer.effect(
50
51
  AsyncIngest,
51
- RpcClient.make(IngestRpcs),
52
- ).pipe(Layer.provide(WorkerProtocol))
52
+ Effect.gen(function*() {
53
+ const scope = yield* Scope.Scope
54
+ // Keep daemon startup cheap: creating the RPC client here would eagerly
55
+ // spawn the worker and make /api/health wait on the worker's SQLite
56
+ // bootstrap. Cache a lazy initializer instead so the worker only starts
57
+ // on the first ingest request, but is still shared thereafter.
58
+ const getClient = yield* RpcClient.make(IngestRpcs).pipe(
59
+ Effect.provide(WorkerProtocol),
60
+ Effect.cached,
61
+ )
62
+ const withScope = <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.provideService(effect, Scope.Scope, scope)
63
+ return {
64
+ ingestTraces: (input, options) => Effect.flatMap(withScope(getClient), (client) => client.ingestTraces(input, options)),
65
+ ingestLogs: (input, options) => Effect.flatMap(withScope(getClient), (client) => client.ingestLogs(input, options)),
66
+ }
67
+ }),
68
+ )
@@ -7,6 +7,9 @@ import type { AiCallDetail, AiCallSummary, FacetItem, LogItem, SpanItem, StatsIt
7
7
  import { AI_ATTR_MAP, AI_FTS_KEYS, AI_TEXT_SEARCH_KEYS, truncatePreview } from "../domain.js"
8
8
  import { attributeMap, nanosToMilliseconds, parseAnyValue, spanKindLabel, spanStatusLabel, stringifyValue, type OtlpLogExportRequest, type OtlpTraceExportRequest } from "../otlp.js"
9
9
 
10
+ const isSqliteLockError = (error: unknown) =>
11
+ error instanceof Error && /(database is locked|database table is locked|SQLITE_BUSY)/i.test(error.message)
12
+
10
13
  interface SpanRow {
11
14
  readonly trace_id: string
12
15
  readonly span_id: string
@@ -502,108 +505,110 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
502
505
  PRAGMA mmap_size = 268435456;
503
506
  `)
504
507
  } else {
505
- db.exec(`
506
- PRAGMA journal_mode = WAL;
507
- PRAGMA synchronous = NORMAL;
508
- PRAGMA temp_store = MEMORY;
509
- -- Longer busy timeout: the ingest worker holds the write lock
510
- -- for up to a few seconds during big OTLP batches, and the main
511
- -- daemon's retention passes can do the same. 15s gives either
512
- -- side enough slack to serialise instead of erroring.
513
- PRAGMA busy_timeout = 15000;
514
- -- WAL checkpoint automatically when it grows past ~16MB. Without
515
- -- this the WAL happily runs into the hundreds of MB and queries
516
- -- start paying the cost of walking the WAL on every read.
517
- PRAGMA wal_autocheckpoint = 4000;
518
- -- Bump cache above the 2MB default. 64MB fits most hot index pages
519
- -- (trace_summaries, spans, span_attributes indexes) in RAM even on
520
- -- multi-GB databases, cutting cold-read latency meaningfully on
521
- -- picker / search queries that sweep the index.
522
- PRAGMA cache_size = -65536;
523
- -- Let SQLite memory-map the first 256MB of the file. This is a
524
- -- cheap way to avoid read() syscalls on hot pages and lets the OS
525
- -- page cache serve index lookups directly. Safe on macOS and Linux;
526
- -- SQLite silently caps at actual file size for smaller DBs.
527
- PRAGMA mmap_size = 268435456;
528
-
529
- CREATE TABLE IF NOT EXISTS spans (
530
- trace_id TEXT NOT NULL,
531
- span_id TEXT NOT NULL,
532
- parent_span_id TEXT,
533
- service_name TEXT NOT NULL,
534
- scope_name TEXT,
535
- operation_name TEXT NOT NULL,
536
- kind TEXT,
537
- start_time_ms INTEGER NOT NULL,
538
- end_time_ms INTEGER NOT NULL,
539
- duration_ms REAL NOT NULL,
540
- status TEXT NOT NULL,
541
- attributes_json TEXT NOT NULL,
542
- resource_json TEXT NOT NULL,
543
- events_json TEXT NOT NULL,
544
- PRIMARY KEY (trace_id, span_id)
545
- );
546
-
547
- CREATE INDEX IF NOT EXISTS idx_spans_service_time ON spans(service_name, start_time_ms DESC);
548
- CREATE INDEX IF NOT EXISTS idx_spans_trace_time ON spans(trace_id, start_time_ms ASC);
549
- CREATE INDEX IF NOT EXISTS idx_spans_span_id ON spans(span_id);
550
- CREATE INDEX IF NOT EXISTS idx_spans_status_time ON spans(status, start_time_ms DESC);
551
-
552
- CREATE TABLE IF NOT EXISTS logs (
553
- id INTEGER PRIMARY KEY AUTOINCREMENT,
554
- trace_id TEXT,
555
- span_id TEXT,
556
- service_name TEXT NOT NULL,
557
- scope_name TEXT,
558
- severity_text TEXT NOT NULL,
559
- timestamp_ms INTEGER NOT NULL,
560
- body TEXT NOT NULL,
561
- attributes_json TEXT NOT NULL,
562
- resource_json TEXT NOT NULL
563
- );
564
-
565
- CREATE INDEX IF NOT EXISTS idx_logs_service_time ON logs(service_name, timestamp_ms DESC);
566
- CREATE INDEX IF NOT EXISTS idx_logs_trace_time ON logs(trace_id, timestamp_ms DESC);
567
- CREATE INDEX IF NOT EXISTS idx_logs_span_time ON logs(span_id, timestamp_ms DESC);
568
- CREATE INDEX IF NOT EXISTS idx_logs_severity_time ON logs(severity_text, timestamp_ms DESC);
569
-
570
- CREATE TABLE IF NOT EXISTS trace_summaries (
571
- trace_id TEXT PRIMARY KEY,
572
- service_name TEXT NOT NULL,
573
- root_operation_name TEXT NOT NULL,
574
- started_at_ms INTEGER NOT NULL,
575
- ended_at_ms INTEGER NOT NULL,
576
- active_span_count INTEGER NOT NULL DEFAULT 0,
577
- duration_ms REAL NOT NULL,
578
- span_count INTEGER NOT NULL,
579
- error_count INTEGER NOT NULL
580
- );
581
-
582
- CREATE INDEX IF NOT EXISTS idx_trace_summaries_started_at ON trace_summaries(started_at_ms DESC, trace_id DESC);
583
- CREATE INDEX IF NOT EXISTS idx_trace_summaries_service_started_at ON trace_summaries(service_name, started_at_ms DESC, trace_id DESC);
584
- CREATE INDEX IF NOT EXISTS idx_trace_summaries_duration ON trace_summaries(duration_ms DESC);
585
-
586
- CREATE TABLE IF NOT EXISTS span_attributes (
587
- trace_id TEXT NOT NULL,
588
- span_id TEXT NOT NULL,
589
- key TEXT NOT NULL,
590
- value TEXT NOT NULL,
591
- PRIMARY KEY (trace_id, span_id, key)
592
- );
593
-
594
- CREATE INDEX IF NOT EXISTS idx_span_attributes_key_value ON span_attributes(key, value, trace_id, span_id);
595
- CREATE INDEX IF NOT EXISTS idx_span_attributes_trace_span ON span_attributes(trace_id, span_id);
596
-
597
- CREATE TABLE IF NOT EXISTS log_attributes (
598
- log_id INTEGER NOT NULL,
599
- key TEXT NOT NULL,
600
- value TEXT NOT NULL,
601
- PRIMARY KEY (log_id, key)
602
- );
603
-
604
- CREATE INDEX IF NOT EXISTS idx_log_attributes_key_value ON log_attributes(key, value, log_id);
605
- CREATE INDEX IF NOT EXISTS idx_log_attributes_log_id ON log_attributes(log_id);
606
- `)
508
+ db.exec(`
509
+ -- Bump cache above the 2MB default. 64MB fits most hot index pages
510
+ -- (trace_summaries, spans, span_attributes indexes) in RAM even on
511
+ -- multi-GB databases, cutting cold-read latency meaningfully on
512
+ -- picker / search queries that sweep the index.
513
+ PRAGMA cache_size = -65536;
514
+ -- Let SQLite memory-map the first 256MB of the file. This is a
515
+ -- cheap way to avoid read() syscalls on hot pages and lets the OS
516
+ -- page cache serve index lookups directly. Safe on macOS and Linux;
517
+ -- SQLite silently caps at actual file size for smaller DBs.
518
+ PRAGMA mmap_size = 268435456;
519
+ `)
520
+ try {
521
+ db.exec(`
522
+ PRAGMA journal_mode = WAL;
523
+ PRAGMA synchronous = NORMAL;
524
+ PRAGMA temp_store = MEMORY;
525
+ -- WAL checkpoint automatically when it grows past ~16MB. Without
526
+ -- this the WAL happily runs into the hundreds of MB and queries
527
+ -- start paying the cost of walking the WAL on every read.
528
+ PRAGMA wal_autocheckpoint = 4000;
529
+
530
+ CREATE TABLE IF NOT EXISTS spans (
531
+ trace_id TEXT NOT NULL,
532
+ span_id TEXT NOT NULL,
533
+ parent_span_id TEXT,
534
+ service_name TEXT NOT NULL,
535
+ scope_name TEXT,
536
+ operation_name TEXT NOT NULL,
537
+ kind TEXT,
538
+ start_time_ms INTEGER NOT NULL,
539
+ end_time_ms INTEGER NOT NULL,
540
+ duration_ms REAL NOT NULL,
541
+ status TEXT NOT NULL,
542
+ attributes_json TEXT NOT NULL,
543
+ resource_json TEXT NOT NULL,
544
+ events_json TEXT NOT NULL,
545
+ PRIMARY KEY (trace_id, span_id)
546
+ );
547
+
548
+ CREATE INDEX IF NOT EXISTS idx_spans_service_time ON spans(service_name, start_time_ms DESC);
549
+ CREATE INDEX IF NOT EXISTS idx_spans_trace_time ON spans(trace_id, start_time_ms ASC);
550
+ CREATE INDEX IF NOT EXISTS idx_spans_span_id ON spans(span_id);
551
+ CREATE INDEX IF NOT EXISTS idx_spans_status_time ON spans(status, start_time_ms DESC);
552
+
553
+ CREATE TABLE IF NOT EXISTS logs (
554
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
555
+ trace_id TEXT,
556
+ span_id TEXT,
557
+ service_name TEXT NOT NULL,
558
+ scope_name TEXT,
559
+ severity_text TEXT NOT NULL,
560
+ timestamp_ms INTEGER NOT NULL,
561
+ body TEXT NOT NULL,
562
+ attributes_json TEXT NOT NULL,
563
+ resource_json TEXT NOT NULL
564
+ );
565
+
566
+ CREATE INDEX IF NOT EXISTS idx_logs_service_time ON logs(service_name, timestamp_ms DESC);
567
+ CREATE INDEX IF NOT EXISTS idx_logs_trace_time ON logs(trace_id, timestamp_ms DESC);
568
+ CREATE INDEX IF NOT EXISTS idx_logs_span_time ON logs(span_id, timestamp_ms DESC);
569
+ CREATE INDEX IF NOT EXISTS idx_logs_severity_time ON logs(severity_text, timestamp_ms DESC);
570
+
571
+ CREATE TABLE IF NOT EXISTS trace_summaries (
572
+ trace_id TEXT PRIMARY KEY,
573
+ service_name TEXT NOT NULL,
574
+ root_operation_name TEXT NOT NULL,
575
+ started_at_ms INTEGER NOT NULL,
576
+ ended_at_ms INTEGER NOT NULL,
577
+ active_span_count INTEGER NOT NULL DEFAULT 0,
578
+ duration_ms REAL NOT NULL,
579
+ span_count INTEGER NOT NULL,
580
+ error_count INTEGER NOT NULL
581
+ );
582
+
583
+ CREATE INDEX IF NOT EXISTS idx_trace_summaries_started_at ON trace_summaries(started_at_ms DESC, trace_id DESC);
584
+ CREATE INDEX IF NOT EXISTS idx_trace_summaries_service_started_at ON trace_summaries(service_name, started_at_ms DESC, trace_id DESC);
585
+ CREATE INDEX IF NOT EXISTS idx_trace_summaries_duration ON trace_summaries(duration_ms DESC);
586
+
587
+ CREATE TABLE IF NOT EXISTS span_attributes (
588
+ trace_id TEXT NOT NULL,
589
+ span_id TEXT NOT NULL,
590
+ key TEXT NOT NULL,
591
+ value TEXT NOT NULL,
592
+ PRIMARY KEY (trace_id, span_id, key)
593
+ );
594
+
595
+ CREATE INDEX IF NOT EXISTS idx_span_attributes_key_value ON span_attributes(key, value, trace_id, span_id);
596
+ CREATE INDEX IF NOT EXISTS idx_span_attributes_trace_span ON span_attributes(trace_id, span_id);
597
+
598
+ CREATE TABLE IF NOT EXISTS log_attributes (
599
+ log_id INTEGER NOT NULL,
600
+ key TEXT NOT NULL,
601
+ value TEXT NOT NULL,
602
+ PRIMARY KEY (log_id, key)
603
+ );
604
+
605
+ CREATE INDEX IF NOT EXISTS idx_log_attributes_key_value ON log_attributes(key, value, log_id);
606
+ CREATE INDEX IF NOT EXISTS idx_log_attributes_log_id ON log_attributes(log_id);
607
+ `)
608
+ } catch (err) {
609
+ if (!isSqliteLockError(err)) throw err
610
+ console.warn(`motel: writer bootstrap skipped during startup: ${(err as Error).message}`)
611
+ }
607
612
  }
608
613
 
609
614
  // Tables detected at runtime. For writer connections these flags are
@@ -726,6 +731,12 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
726
731
  // ANALYZE / optimize failures are never fatal — queries still work,
727
732
  // they just run with default row estimates.
728
733
  }
734
+ // Longer busy timeout: the ingest worker holds the write lock for up
735
+ // to a few seconds during big OTLP batches, and the daemon's retention
736
+ // passes can do the same. Apply this AFTER startup maintenance so
737
+ // lock-conflicted bootstrap steps fail fast instead of stalling health
738
+ // for the full 15s timeout.
739
+ try { db.exec(`PRAGMA busy_timeout = 15000;`) } catch { /* ignore */ }
729
740
  } // end: if (!opts.readonly) writer init
730
741
 
731
742
  const insertSpan = db.query(`
@@ -774,14 +785,15 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
774
785
  GROUP BY trace_id
775
786
  `)
776
787
 
777
- // One-time full rebuild of the trace_summaries table at open so
778
- // any drift from interrupted ingests gets reconciled. Writer-only
779
- // because the DELETE + INSERT would fail on a readonly connection
780
- // (and would fight the daemon's writer for the lock anyway).
781
- if (!opts.readonly) {
782
- db.query(`DELETE FROM trace_summaries`).run()
783
- rebuildTraceSummaries.run()
784
- }
788
+ const reconcileTraceSummaries = Effect.sync(() => {
789
+ try {
790
+ db.query(`DELETE FROM trace_summaries`).run()
791
+ rebuildTraceSummaries.run()
792
+ } catch (err) {
793
+ if (!isSqliteLockError(err)) throw err
794
+ console.warn(`motel: trace summary rebuild skipped during startup: ${(err as Error).message}`)
795
+ }
796
+ })
785
797
 
786
798
  const deleteSpanAttributes = db.query(`DELETE FROM span_attributes WHERE trace_id = ? AND span_id = ?`)
787
799
  const insertSpanAttribute = db.query(`INSERT INTO span_attributes (trace_id, span_id, key, value) VALUES (?, ?, ?, ?)`)
@@ -887,6 +899,12 @@ export const makeTelemetryStoreLayer = (opts: TelemetryStoreOptions) => Layer.ef
887
899
  // daemon). The ingest worker and TUI skip it to avoid two writers
888
900
  // competing for the write lock with overlapping DELETE passes.
889
901
  if (opts.runRetention) {
902
+ // Reconcile any summary drift from interrupted ingests, but do it
903
+ // after the server becomes healthy. Running this synchronously at
904
+ // open can sit behind another writer's lock for ~15s and make the
905
+ // daemon look hung even though the port is already bound.
906
+ yield* Effect.forkScoped(reconcileTraceSummaries)
907
+
890
908
  // Enable incremental vacuum so retention can reclaim freed
891
909
  // pages over time instead of needing a stop-the-world VACUUM.
892
910
  // Idempotent: repeat calls after the first are no-ops.
@@ -212,6 +212,7 @@ const StoryApp = () => {
212
212
  bodyLines={bodyLines}
213
213
  paneWidth={w}
214
214
  />
215
+ <Divider width={contentWidth} />
215
216
  <box paddingLeft={1} paddingRight={1} height={FOOTER_ROWS}>
216
217
  <TextLine>
217
218
  <span fg={colors.count} attributes={TextAttributes.BOLD}>1-9</span>
@@ -48,6 +48,16 @@ const rowTextColor = (chunk: Chunk | null, role: Role, selected: boolean): strin
48
48
  return colors.text
49
49
  }
50
50
 
51
+ const splitToolRowText = (text: string): { readonly head: string; readonly tail: string | null } => {
52
+ const match = text.match(/\s{2,}/)
53
+ const sep = match?.index ?? -1
54
+ if (sep < 0) return { head: text, tail: null }
55
+ return {
56
+ head: text.slice(0, sep),
57
+ tail: text.slice(sep + match![0].length),
58
+ }
59
+ }
60
+
51
61
  const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
52
62
 
53
63
  const chunkRows = (rows: readonly ChatListRow[]) => rows.filter((row) => row.kind === "chunk")
@@ -151,15 +161,16 @@ export const AiChatView = ({
151
161
  }) => {
152
162
  const rows = useMemo(() => buildChatListRows(chunks), [chunks])
153
163
  const selectable = useMemo(() => chunkRows(rows), [rows])
164
+ const chunkById = useMemo(() => new Map(chunks.map((chunk) => [chunk.id, chunk] as const)), [chunks])
165
+ const selectedOrdinal = useMemo(
166
+ () => selectedChunkId ? selectable.findIndex((row) => row.chunkId === selectedChunkId) : -1,
167
+ [selectable, selectedChunkId],
168
+ )
154
169
  const [scrollOffset, setScrollOffset] = useState(0)
155
170
 
156
- const selectedChunk = useMemo(
157
- () => selectedChunkId ? chunks.find((chunk) => chunk.id === selectedChunkId) ?? null : null,
158
- [chunks, selectedChunkId],
159
- )
160
171
  const detailChunk = useMemo(
161
- () => detailChunkId ? chunks.find((chunk) => chunk.id === detailChunkId) ?? null : null,
162
- [chunks, detailChunkId],
172
+ () => detailChunkId ? chunkById.get(detailChunkId) ?? null : null,
173
+ [chunkById, detailChunkId],
163
174
  )
164
175
 
165
176
  const selectedRowIndex = useMemo(
@@ -201,7 +212,7 @@ export const AiChatView = ({
201
212
  const maxOffset = Math.max(0, rows.length - bodyLines)
202
213
  const offset = clamp(scrollOffset, 0, maxOffset)
203
214
  const visible = rows.slice(offset, offset + bodyLines)
204
- const headerRight = `${operation} ${SEPARATOR} ${durationLabel} ${SEPARATOR} ${selectable.length > 0 ? `${Math.max(1, selectable.findIndex((row) => row.chunkId === selectedChunkId) + 1)}/${selectable.length}` : "0/0"}`
215
+ const headerRight = `${operation} ${SEPARATOR} ${durationLabel} ${SEPARATOR} ${selectable.length > 0 ? `${Math.max(1, selectedOrdinal + 1)}/${selectable.length}` : "0/0"}`
205
216
  const handleListWheel = (event: MouseScrollEvent) => {
206
217
  if (detailChunk) return
207
218
  const delta = scrollDelta(event)
@@ -257,18 +268,23 @@ export const AiChatView = ({
257
268
  </TextLine>
258
269
  )
259
270
  }
260
- const chunk = chunks.find((candidate) => candidate.id === row.chunkId) ?? null
271
+ const chunk = row.chunkId ? chunkById.get(row.chunkId) ?? null : null
261
272
  const isSelected = row.chunkId === selectedChunkId
262
273
  const prefix = rowPrefix(chunk)
263
274
  const meta = row.meta ?? ""
264
275
  const textWidth = Math.max(8, contentWidth - prefix.length - meta.length - 4)
265
276
  const display = truncateText(row.text, textWidth)
266
277
  const gap = Math.max(1, contentWidth - prefix.length - display.length - meta.length - 1)
278
+ const toolLike = chunk?.kind === "tool-call" || chunk?.kind === "tool-result"
279
+ const { head, tail } = toolLike ? splitToolRowText(display) : { head: display, tail: null }
280
+ const headColor = rowTextColor(chunk, row.role, isSelected)
281
+ const tailColor = isSelected ? colors.muted : colors.separator
267
282
  return (
268
283
  <box key={`row-${offset + i}`} height={1} onMouseDown={() => { if (row.chunkId) onSelectChunk(row.chunkId) }}>
269
284
  <TextLine bg={isSelected ? colors.selectedBg : undefined}>
270
285
  <span fg={isSelected ? roleColor(row.role) : colors.separator}>{isSelected ? "▎" : " "}</span>
271
- <span fg={rowTextColor(chunk, row.role, isSelected)} attributes={isSelected ? TextAttributes.BOLD : undefined}>{prefix}{display}</span>
286
+ <span fg={headColor} attributes={isSelected ? TextAttributes.BOLD : undefined}>{`${prefix}${head}`}</span>
287
+ {tail ? <span fg={tailColor}>{` ${tail}`}</span> : null}
272
288
  {meta ? <><span fg={colors.muted}>{" ".repeat(gap)}</span><span fg={colors.muted}>{meta}</span></> : null}
273
289
  </TextLine>
274
290
  </box>
@@ -5,7 +5,7 @@ import { AlignedHeaderLine, Divider, FilterBar, PlainLine, TextLine } from "./pr
5
5
  import { WaterfallTimeline } from "./Waterfall.tsx"
6
6
  import { computeMatchingSpanIds } from "./waterfallFilter.ts"
7
7
  import { getVisibleSpans } from "./waterfallModel.ts"
8
- import type { LoadStatus, LogState } from "./state.ts"
8
+ import type { LoadStatus } from "./state.ts"
9
9
  import { colors, SEPARATOR } from "./theme.ts"
10
10
 
11
11
  /**
@@ -24,13 +24,12 @@ export const TraceDetailsPane = ({
24
24
  traceSummary,
25
25
  traceStatus,
26
26
  traceError,
27
- traceLogsState,
27
+ traceLogCount,
28
28
  contentWidth,
29
29
  bodyLines,
30
30
  paneWidth,
31
31
  selectedSpanIndex,
32
32
  collapsedSpanIds,
33
- focused = false,
34
33
  onSelectSpan,
35
34
  waterfallFilterMode,
36
35
  waterfallFilterText,
@@ -39,13 +38,12 @@ export const TraceDetailsPane = ({
39
38
  traceSummary: TraceSummaryItem | null
40
39
  traceStatus: LoadStatus
41
40
  traceError: string | null
42
- traceLogsState: LogState
41
+ traceLogCount: number
43
42
  contentWidth: number
44
43
  bodyLines: number
45
44
  paneWidth: number
46
45
  selectedSpanIndex: number | null
47
46
  collapsedSpanIds: ReadonlySet<string>
48
- focused?: boolean
49
47
  onSelectSpan: (index: number) => void
50
48
  waterfallFilterMode: boolean
51
49
  waterfallFilterText: string
@@ -54,20 +52,6 @@ export const TraceDetailsPane = ({
54
52
  () => trace ? getVisibleSpans(trace.spans, collapsedSpanIds) : [],
55
53
  [trace, collapsedSpanIds],
56
54
  )
57
- const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
58
- const traceLogCount = traceLogsState.data.length
59
- const spanLogCounts = useMemo(() => {
60
- const counts = new Map<string, number>()
61
- for (const log of traceLogsState.data) {
62
- if (!log.spanId) continue
63
- counts.set(log.spanId, (counts.get(log.spanId) ?? 0) + 1)
64
- }
65
- return counts
66
- }, [traceLogsState.data])
67
- const selectedSpanLogs = useMemo(
68
- () => selectedSpan ? traceLogsState.data.filter((log) => log.spanId === selectedSpan.spanId) : [],
69
- [selectedSpan, traceLogsState.data],
70
- )
71
55
  const matchingSpanIds = useMemo(
72
56
  () => trace ? computeMatchingSpanIds(trace.spans, waterfallFilterText) : null,
73
57
  [trace, waterfallFilterText],
@@ -144,14 +128,12 @@ export const TraceDetailsPane = ({
144
128
  </box>
145
129
  ) : null}
146
130
  <box flexDirection="column" paddingLeft={1} paddingRight={1}>
147
- <WaterfallTimeline
148
- trace={trace}
149
- filteredSpans={filteredSpans}
150
- spanLogCounts={spanLogCounts}
151
- selectedSpanLogs={selectedSpanLogs}
152
- contentWidth={contentWidth}
153
- bodyLines={waterfallBodyLines}
154
- selectedSpanIndex={selectedSpanIndex}
131
+ <WaterfallTimeline
132
+ trace={trace}
133
+ filteredSpans={filteredSpans}
134
+ contentWidth={contentWidth}
135
+ bodyLines={waterfallBodyLines}
136
+ selectedSpanIndex={selectedSpanIndex}
155
137
  collapsedSpanIds={collapsedSpanIds}
156
138
  matchingSpanIds={matchingSpanIds}
157
139
  onSelectSpan={onSelectSpan}
@@ -1,4 +1,4 @@
1
- import { memo, useLayoutEffect, useRef, useState } from "react"
1
+ import { memo, useLayoutEffect, useState } from "react"
2
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"
@@ -280,8 +280,6 @@ export const SpanPreview = ({
280
280
  export const WaterfallTimeline = ({
281
281
  trace,
282
282
  filteredSpans,
283
- spanLogCounts,
284
- selectedSpanLogs,
285
283
  contentWidth,
286
284
  bodyLines,
287
285
  selectedSpanIndex,
@@ -291,8 +289,6 @@ export const WaterfallTimeline = ({
291
289
  }: {
292
290
  trace: TraceItem
293
291
  filteredSpans: readonly TraceSpanItem[]
294
- spanLogCounts: ReadonlyMap<string, number>
295
- selectedSpanLogs: readonly LogItem[]
296
292
  contentWidth: number
297
293
  bodyLines: number
298
294
  selectedSpanIndex: number | null
@@ -320,13 +316,12 @@ export const WaterfallTimeline = ({
320
316
  const viewportSize = Math.max(1, bodyLines)
321
317
  const maxOffset = Math.max(0, filteredSpans.length - viewportSize)
322
318
  const [scrollOffset, setScrollOffset] = useState(0)
323
- const lastTraceIdRef = useRef<string | null>(null)
324
319
 
325
- // Reset scroll offset when the trace changes.
326
- if (trace.traceId !== lastTraceIdRef.current) {
320
+ // Reset scroll offset when the trace changes. Keep this out of render so
321
+ // a trace switch doesn't force a render-phase state update on hot paths.
322
+ useLayoutEffect(() => {
327
323
  setScrollOffset(0)
328
- lastTraceIdRef.current = trace.traceId
329
- }
324
+ }, [trace.traceId])
330
325
 
331
326
  // Auto-follow selection: only if the selected span would be hidden
332
327
  // by the current window, shift just enough to bring it back. Runs in
@@ -328,6 +328,50 @@ describe("buildChatListRows", () => {
328
328
  expect(rows[0]!.text).toBe("hello there")
329
329
  expect(rows[1]!.text).toContain("bash")
330
330
  })
331
+
332
+ it("uses matching tool-call context for tool-result rows", () => {
333
+ const chunks = buildChunks(
334
+ makeDetail([
335
+ {
336
+ role: "assistant",
337
+ content: [
338
+ { type: "tool-call", toolCallId: "tc-1", toolName: "bash", input: { command: "git status --short --branch" } },
339
+ ],
340
+ },
341
+ {
342
+ role: "tool",
343
+ content: [
344
+ { type: "tool-result", toolCallId: "tc-1", toolName: "bash", output: { type: "text", value: "## dev...origin/dev [ahead 8, behind 11]\n M src/file.ts" } },
345
+ ],
346
+ },
347
+ ]),
348
+ )
349
+ const rows = buildChatListRows(chunks).filter((r) => r.kind === "chunk")
350
+ expect(rows[1]!.text).toContain("bash git status --short --branch")
351
+ expect(rows[1]!.meta).toContain("## dev...origin/dev")
352
+ })
353
+
354
+ it("shows read result rows with the originating file path inline", () => {
355
+ const chunks = buildChunks(
356
+ makeDetail([
357
+ {
358
+ role: "assistant",
359
+ content: [
360
+ { type: "tool-call", toolCallId: "tc-2", toolName: "read", input: { filePath: "/src/formatter.ts", offset: 40, limit: 80 } },
361
+ ],
362
+ },
363
+ {
364
+ role: "tool",
365
+ content: [
366
+ { type: "tool-result", toolCallId: "tc-2", toolName: "read", output: { type: "text", value: "1: export const x = 1" } },
367
+ ],
368
+ },
369
+ ]),
370
+ )
371
+ const rows = buildChatListRows(chunks).filter((r) => r.kind === "chunk")
372
+ expect(rows[1]!.text).toContain("read /src/formatter.ts @40 +80")
373
+ expect(rows[1]!.meta).toContain("1: export const x = 1")
374
+ })
331
375
  })
332
376
 
333
377
  describe("chunkDetailTitle + renderChunkDetailLines", () => {
@@ -479,6 +479,13 @@ const firstBodyLine = (body: string) => {
479
479
  return line.replace(/\s+/g, " ").trim()
480
480
  }
481
481
 
482
+ const stripTransportGlyph = (text: string) => text.replace(/^[→←]\s+/, "")
483
+
484
+ const toolRowPreview = (text: string, width = 40) => {
485
+ const compact = firstBodyLine(text)
486
+ return compact.length > 0 ? shorten(compact, width) : null
487
+ }
488
+
482
489
  /**
483
490
  * Stable list rows for the main chat pane. One role divider per turn,
484
491
  * one selectable row per chunk. Plain text chunks use their first body
@@ -487,6 +494,10 @@ const firstBodyLine = (body: string) => {
487
494
  */
488
495
  export const buildChatListRows = (chunks: readonly Chunk[]): readonly ChatListRow[] => {
489
496
  const rows: ChatListRow[] = []
497
+ const toolCallById = new Map<string, Chunk>()
498
+ for (const chunk of chunks) {
499
+ if (chunk.kind === "tool-call" && chunk.toolCallId) toolCallById.set(chunk.toolCallId, chunk)
500
+ }
490
501
  let prevRole: Role | null = null
491
502
  let prevMessageIndex = -1
492
503
 
@@ -522,7 +533,33 @@ export const buildChatListRows = (chunks: readonly Chunk[]): readonly ChatListRo
522
533
  // structured chunk headers here. Otherwise tool rows render as
523
534
  // `→ → bash ...` and results as `← ← read ...`.
524
535
  if (chunk.kind === "tool-call" || chunk.kind === "tool-result") {
525
- text = text.replace(/^[→←]\s+/, "")
536
+ text = stripTransportGlyph(text)
537
+ }
538
+
539
+ if (chunk.kind === "tool-call") {
540
+ const preview = toolRowPreview(chunk.body)
541
+ // Keep row text focused on the primary action (already encoded in
542
+ // `header`) and use the dim right column for "there is more here"
543
+ // metadata only when it adds signal. For JSON-heavy tool args this
544
+ // is usually just noise, so we currently leave meta alone.
545
+ if (preview && preview !== text) {
546
+ meta = meta ?? null
547
+ }
548
+ }
549
+
550
+ if (chunk.kind === "tool-result") {
551
+ const matchingCall = chunk.toolCallId ? toolCallById.get(chunk.toolCallId) ?? null : null
552
+ if (matchingCall) {
553
+ // Carry the originating call summary into the result row so the
554
+ // list can answer "result of what?" without opening the modal.
555
+ // Example: `← bash git status --short --branch`,
556
+ // `← read /src/formatter.ts @40 +80`.
557
+ text = stripTransportGlyph(matchingCall.header)
558
+ }
559
+ const preview = toolRowPreview(chunk.body)
560
+ if (preview) {
561
+ meta = chunk.headerMeta ? `${chunk.headerMeta} · ${preview}` : preview
562
+ }
526
563
  }
527
564
  if (chunk.kind === "system") {
528
565
  text = "prompt"
@@ -21,7 +21,7 @@ interface SharedTraceDetailsProps {
21
21
  readonly traceSummary: TraceSummaryItem | null
22
22
  readonly traceStatus: TraceDetailState["status"]
23
23
  readonly traceError: string | null
24
- readonly traceLogsState: LogState
24
+ readonly traceLogCount: number
25
25
  readonly selectedSpanIndex: number | null
26
26
  readonly collapsedSpanIds: ReadonlySet<string>
27
27
  readonly waterfallFilterMode: boolean
@@ -33,7 +33,6 @@ interface TraceDetailsSceneProps extends SharedTraceDetailsProps {
33
33
  readonly contentWidth: number
34
34
  readonly bodyLines: number
35
35
  readonly paneWidth: number
36
- readonly focused: boolean
37
36
  }
38
37
 
39
38
  const TraceDetailsScene = ({
@@ -41,13 +40,12 @@ const TraceDetailsScene = ({
41
40
  traceSummary,
42
41
  traceStatus,
43
42
  traceError,
44
- traceLogsState,
43
+ traceLogCount,
45
44
  contentWidth,
46
45
  bodyLines,
47
46
  paneWidth,
48
47
  selectedSpanIndex,
49
48
  collapsedSpanIds,
50
- focused,
51
49
  waterfallFilterMode,
52
50
  waterfallFilterText,
53
51
  onSelectSpan,
@@ -57,13 +55,12 @@ const TraceDetailsScene = ({
57
55
  traceSummary={traceSummary}
58
56
  traceStatus={traceStatus}
59
57
  traceError={traceError}
60
- traceLogsState={traceLogsState}
58
+ traceLogCount={traceLogCount}
61
59
  contentWidth={contentWidth}
62
60
  bodyLines={bodyLines}
63
61
  paneWidth={paneWidth}
64
62
  selectedSpanIndex={selectedSpanIndex}
65
63
  collapsedSpanIds={collapsedSpanIds}
66
- focused={focused}
67
64
  waterfallFilterMode={waterfallFilterMode}
68
65
  waterfallFilterText={waterfallFilterText}
69
66
  onSelectSpan={onSelectSpan}
@@ -292,7 +289,7 @@ export const TraceWorkspace = ({
292
289
  traceSummary: selectedTraceSummary,
293
290
  traceStatus: traceDetailState.status,
294
291
  traceError: traceDetailState.error,
295
- traceLogsState: logState,
292
+ traceLogCount: logState.data.length,
296
293
  selectedSpanIndex,
297
294
  collapsedSpanIds,
298
295
  waterfallFilterMode,
@@ -336,7 +333,6 @@ export const TraceWorkspace = ({
336
333
  contentWidth={rightContentWidth}
337
334
  bodyLines={wideBodyLines}
338
335
  paneWidth={rightPaneWidth}
339
- focused={false}
340
336
  />
341
337
  </box>
342
338
  </box>
@@ -352,7 +348,6 @@ export const TraceWorkspace = ({
352
348
  contentWidth={leftContentWidth}
353
349
  bodyLines={wideBodyLines}
354
350
  paneWidth={leftPaneWidth}
355
- focused={true}
356
351
  />
357
352
  </box>
358
353
  <SeparatorColumn height={wideBodyHeight} junctionChars={separatorCrossChars} />
@@ -412,7 +407,6 @@ export const TraceWorkspace = ({
412
407
  contentWidth={rightContentWidth}
413
408
  bodyLines={narrowBodyLines}
414
409
  paneWidth={contentWidth}
415
- focused={false}
416
410
  />
417
411
  </>
418
412
  )
@@ -432,7 +426,6 @@ export const TraceWorkspace = ({
432
426
  contentWidth={rightContentWidth}
433
427
  bodyLines={narrowFullBodyLines}
434
428
  paneWidth={contentWidth}
435
- focused={true}
436
429
  />
437
430
  ) : (
438
431
  <SpanDrillInScene
@@ -47,7 +47,7 @@ import {
47
47
  import { isAiSpan } from "../../domain.ts"
48
48
  import { buildChunks, type Chunk } from "../aiChatModel.ts"
49
49
  import { parseFilterText } from "../filterParser.ts"
50
- import { getVisibleSpans } from "../Waterfall.tsx"
50
+ import { getVisibleSpans } from "../waterfallModel.ts"
51
51
 
52
52
  const clampSelectionIndex = (index: number, length: number) => {
53
53
  if (length === 0) return 0
@@ -39,7 +39,7 @@ import { filterFacets } from "./AttrFilterModal.tsx"
39
39
  import { G_PREFIX_TIMEOUT_MS } from "./theme.ts"
40
40
  import { cycleThemeName, themeLabel } from "./theme.ts"
41
41
  import { computeMatchingSpanIds, findAdjacentMatch } from "./waterfallFilter.ts"
42
- import { getVisibleSpans } from "./Waterfall.tsx"
42
+ import { getVisibleSpans } from "./waterfallModel.ts"
43
43
  import { resolveCollapseStep } from "./waterfallNav.ts"
44
44
 
45
45
  /**