@agent-native/dispatch 0.21.0 → 0.22.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.
Files changed (56) hide show
  1. package/dist/actions/get-agent-thread-debug.d.ts +2 -0
  2. package/dist/actions/provider-api-register.d.ts +8 -8
  3. package/dist/actions/search-agent-threads.js +2 -2
  4. package/dist/actions/search-agent-threads.js.map +1 -1
  5. package/dist/components/layout/HeaderActions.d.ts +1 -17
  6. package/dist/components/layout/HeaderActions.d.ts.map +1 -1
  7. package/dist/components/layout/HeaderActions.js +1 -51
  8. package/dist/components/layout/HeaderActions.js.map +1 -1
  9. package/dist/components/layout/Layout.d.ts.map +1 -1
  10. package/dist/components/layout/Layout.js +22 -14
  11. package/dist/components/layout/Layout.js.map +1 -1
  12. package/dist/components/ui/dialog.d.ts +1 -19
  13. package/dist/components/ui/dialog.d.ts.map +1 -1
  14. package/dist/components/ui/dialog.js +1 -22
  15. package/dist/components/ui/dialog.js.map +1 -1
  16. package/dist/components/ui/dropdown-menu.d.ts +1 -27
  17. package/dist/components/ui/dropdown-menu.d.ts.map +1 -1
  18. package/dist/components/ui/dropdown-menu.js +1 -35
  19. package/dist/components/ui/dropdown-menu.js.map +1 -1
  20. package/dist/components/ui/popover.d.ts +2 -1
  21. package/dist/components/ui/popover.d.ts.map +1 -1
  22. package/dist/components/ui/popover.js +2 -1
  23. package/dist/components/ui/popover.js.map +1 -1
  24. package/dist/components/ui/select.d.ts +1 -13
  25. package/dist/components/ui/select.d.ts.map +1 -1
  26. package/dist/components/ui/select.js +1 -26
  27. package/dist/components/ui/select.js.map +1 -1
  28. package/dist/hooks/use-mobile.d.ts +1 -1
  29. package/dist/hooks/use-mobile.d.ts.map +1 -1
  30. package/dist/hooks/use-mobile.js +1 -15
  31. package/dist/hooks/use-mobile.js.map +1 -1
  32. package/dist/lib/automation-display.js +1 -1
  33. package/dist/lib/automation-display.js.map +1 -1
  34. package/dist/routes/pages/thread-debug.d.ts.map +1 -1
  35. package/dist/routes/pages/thread-debug.js +288 -118
  36. package/dist/routes/pages/thread-debug.js.map +1 -1
  37. package/dist/server/lib/thread-debug-store.d.ts +2 -0
  38. package/dist/server/lib/thread-debug-store.d.ts.map +1 -1
  39. package/dist/server/lib/thread-debug-store.js +6 -2
  40. package/dist/server/lib/thread-debug-store.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/actions/search-agent-threads.ts +2 -2
  43. package/src/components/layout/HeaderActions.tsx +1 -84
  44. package/src/components/layout/Layout.spec.tsx +94 -10
  45. package/src/components/layout/Layout.tsx +107 -101
  46. package/src/components/ui/dialog.tsx +1 -120
  47. package/src/components/ui/dropdown-menu.tsx +1 -198
  48. package/src/components/ui/popover.tsx +3 -1
  49. package/src/components/ui/select.tsx +1 -158
  50. package/src/hooks/use-mobile.tsx +1 -21
  51. package/src/lib/automation-display.spec.ts +1 -1
  52. package/src/lib/automation-display.ts +1 -1
  53. package/src/routes/pages/thread-debug.spec.tsx +103 -5
  54. package/src/routes/pages/thread-debug.tsx +1051 -451
  55. package/src/server/lib/thread-debug-store.spec.ts +17 -0
  56. package/src/server/lib/thread-debug-store.ts +9 -2
@@ -1,12 +1,16 @@
1
1
  import { useActionQuery } from "@agent-native/core/client/hooks";
2
2
  import { useT } from "@agent-native/core/client/i18n";
3
3
  import {
4
+ IconActivity,
5
+ IconAdjustmentsHorizontal,
4
6
  IconAlertTriangle,
5
- IconClock,
7
+ IconCopy,
6
8
  IconDatabase,
7
9
  IconFileSearch,
10
+ IconInfoCircle,
8
11
  IconRefresh,
9
12
  IconSearch,
13
+ IconTool,
10
14
  } from "@tabler/icons-react";
11
15
  import { useMemo, useState } from "react";
12
16
  import { useSearchParams } from "react-router";
@@ -16,6 +20,11 @@ import { DispatchShell } from "../../components/dispatch-shell";
16
20
  import { Badge } from "../../components/ui/badge";
17
21
  import { Button } from "../../components/ui/button";
18
22
  import { Input } from "../../components/ui/input";
23
+ import {
24
+ Popover,
25
+ PopoverAnchor,
26
+ PopoverContent,
27
+ } from "../../components/ui/popover";
19
28
  import {
20
29
  Select,
21
30
  SelectContent,
@@ -87,13 +96,38 @@ interface ThreadRun {
87
96
  lastProgressAt?: number | null;
88
97
  durationMs?: number | null;
89
98
  peakRssMb?: number | null;
99
+ inFlightSince?: number | null;
100
+ hasDispatchPayload?: boolean;
90
101
  events: Array<{ seq: number; event: any; rawEventData: string }>;
91
102
  }
92
103
 
93
104
  type ThreadDebugMode = "failures" | "threads";
94
105
  type FailureStatus = "all" | "errored" | "aborted" | "truncated";
106
+ type FailureRegime = "all" | "interactive" | "scheduled";
95
107
  type FailureRange = "24h" | "7d" | "30d";
96
108
 
109
+ interface FailureTaxonomy {
110
+ code: string;
111
+ label: string;
112
+ regime: "interactive" | "scheduled";
113
+ source: "error_code" | "error_detail" | "unknown";
114
+ }
115
+
116
+ interface RunFailureLike {
117
+ id?: string;
118
+ status?: string | null;
119
+ errorCode?: string | null;
120
+ errorDetail?: string | null;
121
+ terminalReason?: string | null;
122
+ abortReason?: string | null;
123
+ dispatchMode?: string | null;
124
+ diagStage?: string | null;
125
+ workerStage?: string | null;
126
+ durationMs?: number | null;
127
+ heartbeatAt?: number | null;
128
+ lastProgressAt?: number | null;
129
+ }
130
+
97
131
  interface AgentRunFailure {
98
132
  id: string;
99
133
  threadId: string;
@@ -106,6 +140,7 @@ interface AgentRunFailure {
106
140
  databaseUrlEnv?: string | null;
107
141
  };
108
142
  ownerEmail: string;
143
+ turnId?: string | null;
109
144
  threadTitle: string;
110
145
  threadPreview: string;
111
146
  status: string;
@@ -113,12 +148,16 @@ interface AgentRunFailure {
113
148
  errorDetail: string | null;
114
149
  terminalReason: string | null;
115
150
  abortReason: string | null;
151
+ heartbeatAt?: number | null;
152
+ lastProgressAt?: number | null;
116
153
  dispatchMode: string | null;
117
154
  diagStage: string | null;
118
155
  workerStage?: string | null;
119
156
  startedAt: number;
120
157
  completedAt: number | null;
121
158
  durationMs: number | null;
159
+ regime?: "interactive" | "scheduled";
160
+ failureTaxonomy?: FailureTaxonomy;
122
161
  }
123
162
 
124
163
  interface AgentRunFailuresResponse {
@@ -175,6 +214,8 @@ const FAILURE_RANGE_HOURS: Record<FailureRange, number> = {
175
214
  "30d": 30 * 24,
176
215
  };
177
216
 
217
+ const EMPTY_FAILURES: AgentRunFailure[] = [];
218
+
178
219
  function parseMode(value: string | null): ThreadDebugMode {
179
220
  return value === "threads" ? "threads" : "failures";
180
221
  }
@@ -185,6 +226,10 @@ function parseFailureStatus(value: string | null): FailureStatus {
185
226
  : "all";
186
227
  }
187
228
 
229
+ function parseFailureRegime(value: string | null): FailureRegime {
230
+ return value === "interactive" || value === "scheduled" ? value : "all";
231
+ }
232
+
188
233
  function parseFailureRange(value: string | null): FailureRange {
189
234
  return value === "7d" || value === "30d" ? value : "24h";
190
235
  }
@@ -199,6 +244,39 @@ function failureSourceLabel(failure: AgentRunFailure): string {
199
244
  );
200
245
  }
201
246
 
247
+ function isTechnicalIdentifier(value: string): boolean {
248
+ return /^(?:job|run|thread|turn|request|req)-[a-z0-9][a-z0-9_-]*$/i.test(
249
+ value.trim(),
250
+ );
251
+ }
252
+
253
+ function displayListTitle(
254
+ value: string | null | undefined,
255
+ fallbackValue: string | null | undefined,
256
+ fallbackLabel: string,
257
+ ): string {
258
+ const candidate = [value, fallbackValue]
259
+ .map((entry) => entry?.trim() || "")
260
+ .find((entry) => entry && !isTechnicalIdentifier(entry));
261
+ if (!candidate) return fallbackLabel;
262
+
263
+ const withoutDate = candidate
264
+ .replace(/\s+[—-]\s+\d{1,2}\/\d{1,2}\/\d{4}\s*$/, "")
265
+ .trim();
266
+ if (!withoutDate) return fallbackLabel;
267
+
268
+ if (/^job:/i.test(withoutDate)) {
269
+ return withoutDate
270
+ .replace(/^job:\s*/i, "")
271
+ .replace(/[-_]+/g, " ")
272
+ .replace(/\s+/g, " ")
273
+ .replace(/\b\w/g, (character) => character.toUpperCase())
274
+ .trim();
275
+ }
276
+
277
+ return withoutDate;
278
+ }
279
+
202
280
  function formatDate(value: number | string | null | undefined): string {
203
281
  if (value == null || value === "") return "n/a";
204
282
  const numeric = Number(value);
@@ -207,6 +285,21 @@ function formatDate(value: number | string | null | undefined): string {
207
285
  return date.toLocaleString();
208
286
  }
209
287
 
288
+ function formatRelativeDate(value: number | string | null | undefined): string {
289
+ if (value == null || value === "") return "n/a";
290
+ const numeric = Number(value);
291
+ const timestamp = Number.isFinite(numeric)
292
+ ? numeric
293
+ : new Date(value).getTime();
294
+ if (!Number.isFinite(timestamp)) return "n/a";
295
+ const elapsed = Date.now() - timestamp;
296
+ if (elapsed < 0) return "in a moment";
297
+ if (elapsed < 60_000) return "just now";
298
+ if (elapsed < 3_600_000) return `${Math.floor(elapsed / 60_000)}m ago`;
299
+ if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)}h ago`;
300
+ return `${Math.floor(elapsed / 86_400_000)}d ago`;
301
+ }
302
+
210
303
  function formatDuration(value: number | null | undefined): string {
211
304
  if (value == null || !Number.isFinite(value)) return "n/a";
212
305
  if (value < 1_000) return `${Math.round(value)}ms`;
@@ -214,6 +307,130 @@ function formatDuration(value: number | null | undefined): string {
214
307
  return `${(value / 60_000).toFixed(1)}m`;
215
308
  }
216
309
 
310
+ function humanizeIdentifier(value: string | null | undefined): string {
311
+ if (!value) return "Unknown failure";
312
+ return value
313
+ .replace(/^(error:|failure:)/, "")
314
+ .replace(/[_-]+/g, " ")
315
+ .replace(/\b\w/g, (character) => character.toUpperCase());
316
+ }
317
+
318
+ function failureLabel(run: RunFailureLike): string {
319
+ const code = run.errorCode || run.terminalReason || run.abortReason;
320
+ const labels: Record<string, string> = {
321
+ stale_run: "Worker heartbeat stopped",
322
+ background_worker_never_started: "Background worker never started",
323
+ background_worker_failed: "Background worker setup failed",
324
+ builder_gateway_network_error: "Gateway stream ended early",
325
+ provider_timeout: "Provider timed out",
326
+ provider_network_error: "Provider connection dropped",
327
+ provider_config_error: "Provider configuration rejected",
328
+ authentication_error: "Provider authentication failed",
329
+ overloaded_error: "Provider was overloaded",
330
+ "aborted:user": "Stopped by user",
331
+ };
332
+ return (code && labels[code]) || humanizeIdentifier(code);
333
+ }
334
+
335
+ function runDiagnosis(run: RunFailureLike): {
336
+ title: string;
337
+ summary: string;
338
+ nextStep: string;
339
+ code: string;
340
+ } {
341
+ const code =
342
+ run.errorCode || run.terminalReason || run.abortReason || "unknown";
343
+ if (code === "stale_run") {
344
+ const workerStarted = run.dispatchMode === "background-processing";
345
+ return {
346
+ title: "Worker stopped reporting",
347
+ summary: workerStarted
348
+ ? "The worker claimed this run, then stopped writing heartbeat or progress signals before it finished. This is a liveness failure, not proof that the provider failed."
349
+ : "The run stayed active until liveness recovery closed it. No completed result was recorded, so inspect the handoff and retained evidence before blaming a provider.",
350
+ nextStep: workerStarted
351
+ ? "Check the last worker stage and database heartbeat writes."
352
+ : "Check the scheduled background handoff and whether a worker claimed it.",
353
+ code,
354
+ };
355
+ }
356
+ if (code === "background_worker_never_started") {
357
+ return {
358
+ title: "Background worker never started",
359
+ summary:
360
+ "The handoff was acknowledged, but no background worker claimed the run.",
361
+ nextStep:
362
+ "Check the background route, authentication, and function logs.",
363
+ code,
364
+ };
365
+ }
366
+ if (code === "background_worker_failed") {
367
+ return {
368
+ title: "Background worker failed during setup",
369
+ summary:
370
+ "The worker claimed the run but stopped before it could start the turn.",
371
+ nextStep:
372
+ "Use the recorded setup stage and worker logs to find the first failure.",
373
+ code,
374
+ };
375
+ }
376
+ if (code === "builder_gateway_network_error") {
377
+ return {
378
+ title: "Gateway stream ended early",
379
+ summary:
380
+ run.errorDetail ||
381
+ "The model stream ended before the run emitted a terminal event.",
382
+ nextStep:
383
+ "Retry once; if it repeats, inspect gateway and provider transport health.",
384
+ code,
385
+ };
386
+ }
387
+ if (code === "aborted:user") {
388
+ return {
389
+ title: "Stopped by user",
390
+ summary: "This run was explicitly aborted and is not a system failure.",
391
+ nextStep: "No recovery action is required.",
392
+ code,
393
+ };
394
+ }
395
+ return {
396
+ title: failureLabel(run),
397
+ summary:
398
+ run.errorDetail || "The run ended without a more specific explanation.",
399
+ nextStep: "Open the timeline and inspect the last recorded stage or event.",
400
+ code,
401
+ };
402
+ }
403
+
404
+ function eventType(event: any): string {
405
+ return typeof event?.type === "string" ? event.type : "event";
406
+ }
407
+
408
+ function eventIsNoise(event: any): boolean {
409
+ return [
410
+ "thinking",
411
+ "text",
412
+ "tool_input_delta",
413
+ "stream_keepalive",
414
+ "activity",
415
+ ].includes(eventType(event));
416
+ }
417
+
418
+ function summarizeEvents(events: ThreadRun["events"]) {
419
+ const toolNames = new Map<string, number>();
420
+ let toolStarts = 0;
421
+ for (const entry of events) {
422
+ if (eventType(entry.event) !== "tool_start") continue;
423
+ toolStarts += 1;
424
+ const name = String(entry.event?.tool ?? "tool");
425
+ toolNames.set(name, (toolNames.get(name) ?? 0) + 1);
426
+ }
427
+ return {
428
+ toolStarts,
429
+ toolNames: [...toolNames.entries()],
430
+ meaningful: events.filter((entry) => !eventIsNoise(entry.event)).slice(-12),
431
+ };
432
+ }
433
+
217
434
  function json(value: unknown): string {
218
435
  try {
219
436
  return JSON.stringify(value, null, 2);
@@ -278,14 +495,6 @@ function RawBlock({
278
495
  );
279
496
  }
280
497
 
281
- function SourceBadge({ source }: { source: ThreadDebugSource }) {
282
- return (
283
- <Badge variant={source.current ? "default" : "secondary"}>
284
- {source.current ? "current" : source.kind}
285
- </Badge>
286
- );
287
- }
288
-
289
498
  function ResultCard({
290
499
  result,
291
500
  selected,
@@ -295,36 +504,36 @@ function ResultCard({
295
504
  selected: boolean;
296
505
  onSelect: () => void;
297
506
  }) {
507
+ const title = displayListTitle(
508
+ result.title,
509
+ result.preview,
510
+ "Untitled thread",
511
+ );
512
+
298
513
  return (
299
514
  <button
300
515
  type="button"
301
516
  onClick={onSelect}
517
+ aria-current={selected ? "true" : undefined}
302
518
  className={cn(
303
- "w-full rounded-lg border px-3 py-3 text-left transition-colors",
304
- selected
305
- ? "border-foreground bg-muted"
306
- : "bg-card hover:border-foreground/30 hover:bg-muted/40",
519
+ "group w-full border-b px-4 py-3 text-left transition-colors last:border-b-0",
520
+ selected ? "bg-accent/70" : "hover:bg-muted/50",
307
521
  )}
308
522
  >
309
523
  <div className="flex items-start justify-between gap-3">
310
524
  <div className="min-w-0">
311
525
  <div className="truncate text-sm font-medium text-foreground">
312
- {result.title || result.preview || result.id}
313
- </div>
314
- <div className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
315
- {result.id}
526
+ {title}
316
527
  </div>
317
528
  </div>
318
529
  <Badge variant="outline" className="shrink-0">
319
- {result.messageCount}
530
+ {result.messageCount}{" "}
531
+ {result.messageCount === 1 ? "message" : "messages"}
320
532
  </Badge>
321
533
  </div>
322
- <div className="mt-2 line-clamp-3 text-xs leading-relaxed text-muted-foreground">
323
- {result.snippet || result.preview || "No preview"}
324
- </div>
325
534
  <div className="mt-2 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
326
- <span className="truncate">{result.ownerEmail}</span>
327
- <span className="shrink-0">{formatDate(result.updatedAt)}</span>
535
+ <span className="min-w-0 truncate">{result.ownerEmail}</span>
536
+ <span className="shrink-0">{formatRelativeDate(result.updatedAt)}</span>
328
537
  </div>
329
538
  </button>
330
539
  );
@@ -340,11 +549,12 @@ function FailureCard({
340
549
  onSelect: () => void;
341
550
  }) {
342
551
  const t = useT();
343
- const summary =
344
- failure.errorCode ||
345
- failure.terminalReason ||
346
- failure.abortReason ||
347
- failure.status;
552
+ const diagnosis = runDiagnosis(failure);
553
+ const title = displayListTitle(
554
+ failure.threadTitle,
555
+ failure.threadPreview,
556
+ "Agent run",
557
+ );
348
558
  const statusLabel =
349
559
  failure.status === "errored"
350
560
  ? t("dispatch.pages.threadDebugErrored", {
@@ -364,46 +574,51 @@ function FailureCard({
364
574
  <button
365
575
  type="button"
366
576
  onClick={onSelect}
577
+ aria-current={selected ? "true" : undefined}
367
578
  className={cn(
368
- "w-full rounded-lg border px-3 py-3 text-left transition-colors",
369
- selected
370
- ? "border-foreground bg-muted"
371
- : "bg-card hover:border-foreground/30 hover:bg-muted/40",
579
+ "group w-full border-b px-4 py-3 text-left transition-[background-color,border-color] last:border-b-0",
580
+ selected ? "bg-accent/70" : "hover:bg-muted/50",
372
581
  )}
373
582
  >
374
- <div className="flex items-start justify-between gap-3">
375
- <div className="min-w-0">
376
- <div className="truncate text-sm font-medium text-foreground">
377
- {failure.threadTitle || failure.threadPreview || failure.threadId}
583
+ <div className="flex items-start gap-3">
584
+ <span
585
+ aria-hidden="true"
586
+ className={cn(
587
+ "mt-1.5 size-2 shrink-0 rounded-full",
588
+ failure.status === "aborted"
589
+ ? "bg-muted-foreground/50"
590
+ : "bg-destructive",
591
+ )}
592
+ />
593
+ <div className="min-w-0 flex-1">
594
+ <div className="flex items-start justify-between gap-3">
595
+ <div className="min-w-0">
596
+ <div className="truncate text-sm font-medium text-foreground">
597
+ {title}
598
+ </div>
599
+ <div className="mt-0.5 truncate text-xs text-muted-foreground">
600
+ {failureSourceLabel(failure)}
601
+ </div>
602
+ </div>
603
+ <span className="shrink-0 text-[11px] text-muted-foreground">
604
+ {formatRelativeDate(failure.completedAt ?? failure.startedAt)}
605
+ </span>
378
606
  </div>
379
- <div className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
380
- {failure.id}
607
+ <div className="mt-2 flex min-w-0 items-center gap-2 text-xs">
608
+ <span className="truncate font-medium text-foreground">
609
+ {diagnosis.title}
610
+ </span>
611
+ <span className="shrink-0 text-muted-foreground">·</span>
612
+ <span className="shrink-0 text-muted-foreground">
613
+ {statusLabel}
614
+ </span>
381
615
  </div>
616
+ {failure.durationMs == null ? null : (
617
+ <div className="mt-1 text-[11px] text-muted-foreground">
618
+ {formatDuration(failure.durationMs)}
619
+ </div>
620
+ )}
382
621
  </div>
383
- <Badge variant="outline" className="shrink-0">
384
- {statusLabel}
385
- </Badge>
386
- </div>
387
- <div className="mt-2 flex items-center gap-1.5 text-xs text-foreground">
388
- <IconAlertTriangle className="size-3.5 shrink-0 text-muted-foreground" />
389
- <span className="truncate">{summary}</span>
390
- </div>
391
- {failure.errorDetail ? (
392
- <div className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
393
- {failure.errorDetail}
394
- </div>
395
- ) : null}
396
- <div className="mt-2 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
397
- <span className="truncate">
398
- {failureSourceLabel(failure)} · {failure.ownerEmail}
399
- </span>
400
- <span className="inline-flex shrink-0 items-center gap-1">
401
- <IconClock className="size-3" />
402
- {formatDate(failure.completedAt ?? failure.startedAt)}
403
- {failure.durationMs == null
404
- ? null
405
- : ` · ${formatDuration(failure.durationMs)}`}
406
- </span>
407
622
  </div>
408
623
  <span className="sr-only">
409
624
  {t("dispatch.pages.threadDebugInspectFailure", {
@@ -464,8 +679,209 @@ function MessageBlock({ message }: { message: ThreadMessage }) {
464
679
  );
465
680
  }
466
681
 
682
+ function EvidenceStat({ label, value }: { label: string; value: string }) {
683
+ return (
684
+ <div className="min-w-0">
685
+ <div className="text-[11px] text-muted-foreground">{label}</div>
686
+ <div className="mt-1 truncate text-sm font-medium text-foreground">
687
+ {value}
688
+ </div>
689
+ </div>
690
+ );
691
+ }
692
+
693
+ function DiagnosisPanel({
694
+ run,
695
+ eventCount,
696
+ toolCount,
697
+ }: {
698
+ run: ThreadRun;
699
+ eventCount: number;
700
+ toolCount: number;
701
+ }) {
702
+ const diagnosis = runDiagnosis(run);
703
+ const isStoppedByUser = diagnosis.code === "aborted:user";
704
+ const isScheduled =
705
+ run.id?.startsWith("job-") || run.dispatchMode === "background-processing";
706
+ const workerClaimed = run.dispatchMode === "background-processing";
707
+ const staleThreshold = isScheduled
708
+ ? workerClaimed
709
+ ? "45s after claim"
710
+ : "90s before claim"
711
+ : "15s";
712
+ return (
713
+ <section
714
+ className={cn(
715
+ "border-b px-5 py-4",
716
+ isStoppedByUser ? "bg-muted/20" : "bg-destructive/[0.04]",
717
+ )}
718
+ aria-label="Run diagnosis"
719
+ >
720
+ <div className="flex items-start gap-3">
721
+ <span
722
+ aria-hidden="true"
723
+ className={cn(
724
+ "mt-1.5 size-2 shrink-0 rounded-full",
725
+ isStoppedByUser ? "bg-muted-foreground/50" : "bg-destructive",
726
+ )}
727
+ />
728
+ <div className="min-w-0 flex-1">
729
+ <div className="flex flex-wrap items-start justify-between gap-2">
730
+ <div>
731
+ <h2 className="text-sm font-semibold text-foreground">
732
+ {diagnosis.title}
733
+ </h2>
734
+ <p className="mt-1 max-w-3xl text-sm leading-relaxed text-muted-foreground">
735
+ {diagnosis.summary}
736
+ </p>
737
+ </div>
738
+ <span className="font-mono text-[11px] text-muted-foreground">
739
+ {diagnosis.code}
740
+ </span>
741
+ </div>
742
+ <div className="mt-3 flex items-start gap-2 text-xs text-foreground">
743
+ <IconInfoCircle className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
744
+ <span>
745
+ <span className="font-medium">Next check:</span>{" "}
746
+ {diagnosis.nextStep}
747
+ </span>
748
+ </div>
749
+ </div>
750
+ </div>
751
+ <div className="mt-4 grid gap-3 border-t pt-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-7">
752
+ <EvidenceStat
753
+ label="Run type"
754
+ value={isScheduled ? "Scheduled job" : "Interactive chat"}
755
+ />
756
+ <EvidenceStat
757
+ label="Worker claim"
758
+ value={workerClaimed ? "Claimed" : "Not recorded"}
759
+ />
760
+ <EvidenceStat
761
+ label="Last heartbeat"
762
+ value={formatDate(run.heartbeatAt)}
763
+ />
764
+ <EvidenceStat
765
+ label="Last progress"
766
+ value={formatDate(run.lastProgressAt)}
767
+ />
768
+ <EvidenceStat label="Stale threshold" value={staleThreshold} />
769
+ <EvidenceStat
770
+ label="Recovery payload"
771
+ value={run.hasDispatchPayload ? "Retained" : "Not retained"}
772
+ />
773
+ <EvidenceStat
774
+ label="Worker stage"
775
+ value={
776
+ diagnosticStage(run.workerStage) ||
777
+ diagnosticStage(run.diagStage) ||
778
+ "Not recorded"
779
+ }
780
+ />
781
+ </div>
782
+ <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
783
+ <span>
784
+ Evidence:{" "}
785
+ <span className="font-medium text-foreground">
786
+ {eventCount.toLocaleString()}
787
+ </span>{" "}
788
+ retained events
789
+ </span>
790
+ <span aria-hidden="true">·</span>
791
+ <span>
792
+ <span className="font-medium text-foreground">
793
+ {toolCount.toLocaleString()}
794
+ </span>{" "}
795
+ tool starts
796
+ </span>
797
+ <span aria-hidden="true">·</span>
798
+ <span>Liveness uses the newer heartbeat or progress timestamp.</span>
799
+ </div>
800
+ </section>
801
+ );
802
+ }
803
+
804
+ function RunTimeline({ run }: { run: ThreadRun }) {
805
+ const summary = summarizeEvents(run.events);
806
+ return (
807
+ <div className="space-y-4">
808
+ <div className="flex flex-wrap items-center justify-between gap-3 border-b pb-3">
809
+ <div className="flex items-center gap-2">
810
+ <IconActivity className="size-4 text-muted-foreground" />
811
+ <span className="text-sm font-medium text-foreground">
812
+ {run.events.length.toLocaleString()} events retained
813
+ </span>
814
+ <span className="text-xs text-muted-foreground">
815
+ {summary.toolStarts} tool starts
816
+ </span>
817
+ </div>
818
+ <span className="text-xs text-muted-foreground">
819
+ Started {formatDate(run.startedAt)}
820
+ </span>
821
+ </div>
822
+
823
+ {summary.toolNames.length > 0 ? (
824
+ <div className="flex flex-wrap gap-2">
825
+ {summary.toolNames.map(([name, count]) => (
826
+ <span
827
+ key={name}
828
+ className="inline-flex items-center gap-1.5 rounded-md bg-muted/60 px-2 py-1 text-xs text-foreground"
829
+ >
830
+ <IconTool className="size-3.5 text-muted-foreground" />
831
+ {name} <span className="text-muted-foreground">×{count}</span>
832
+ </span>
833
+ ))}
834
+ </div>
835
+ ) : null}
836
+
837
+ <div className="divide-y rounded-lg border">
838
+ {summary.meaningful.length > 0 ? (
839
+ summary.meaningful.map((entry) => (
840
+ <div
841
+ key={`${run.id}-${entry.seq}`}
842
+ className="flex items-start gap-3 px-3 py-2.5 text-xs"
843
+ >
844
+ <span className="w-8 shrink-0 font-mono text-muted-foreground">
845
+ #{entry.seq}
846
+ </span>
847
+ <span className="min-w-0 flex-1 break-words text-foreground">
848
+ {eventLabel(entry.event)}
849
+ </span>
850
+ </div>
851
+ ))
852
+ ) : (
853
+ <div className="px-3 py-4 text-sm text-muted-foreground">
854
+ No summarized events were retained for this run.
855
+ </div>
856
+ )}
857
+ </div>
858
+
859
+ <details className="rounded-lg border">
860
+ <summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-2.5 text-xs font-medium text-foreground">
861
+ <span>Show raw event stream</span>
862
+ <span className="font-normal text-muted-foreground">
863
+ {run.events.length.toLocaleString()} records
864
+ </span>
865
+ </summary>
866
+ <div className="space-y-2 border-t p-3">
867
+ {run.events.map((entry) => (
868
+ <details
869
+ key={`${run.id}-raw-${entry.seq}`}
870
+ className="rounded-md border bg-muted/20 px-3 py-2"
871
+ >
872
+ <summary className="cursor-pointer text-xs text-foreground">
873
+ #{entry.seq} {eventLabel(entry.event)}
874
+ </summary>
875
+ <RawBlock value={entry.event} className="mt-2 max-h-72" />
876
+ </details>
877
+ ))}
878
+ </div>
879
+ </details>
880
+ </div>
881
+ );
882
+ }
883
+
467
884
  function ThreadDetail({ detail }: { detail: ThreadDebugResponse }) {
468
- const t = useT();
469
885
  const rawBundle = useMemo(
470
886
  () => ({
471
887
  thread: detail.thread,
@@ -482,40 +898,166 @@ function ThreadDetail({ detail }: { detail: ThreadDebugResponse }) {
482
898
  }),
483
899
  [detail],
484
900
  );
901
+ const primaryRun =
902
+ detail.runs.find((run) => run.id === detail.lookup?.runId) ??
903
+ detail.runs[0] ??
904
+ null;
905
+ const eventCount = detail.runs.reduce(
906
+ (total, run) => total + run.events.length,
907
+ 0,
908
+ );
909
+ const toolCount = detail.runs.reduce(
910
+ (total, run) => total + summarizeEvents(run.events).toolStarts,
911
+ 0,
912
+ );
485
913
 
486
914
  return (
487
- <div className="rounded-lg bg-card">
488
- <div className="border-b px-4 py-3">
915
+ <div className="min-w-0">
916
+ <div className="border-b px-5 py-4">
489
917
  <div className="flex flex-wrap items-start justify-between gap-3">
490
918
  <div className="min-w-0">
491
919
  <div className="truncate text-base font-semibold text-foreground">
492
920
  {detail.thread.title || detail.thread.preview || detail.thread.id}
493
921
  </div>
494
- <div className="mt-1 truncate font-mono text-xs text-muted-foreground">
495
- {detail.thread.id}
922
+ <div className="mt-1 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
923
+ <span className="truncate font-mono">
924
+ {detail.lookup?.runId || detail.thread.id}
925
+ </span>
926
+ {detail.lookup?.runId ? (
927
+ <Button
928
+ type="button"
929
+ variant="ghost"
930
+ size="icon"
931
+ className="size-7 shrink-0"
932
+ title="Copy run ID"
933
+ aria-label="Copy run ID"
934
+ onClick={() =>
935
+ void navigator.clipboard?.writeText(
936
+ detail.lookup?.runId ?? "",
937
+ )
938
+ }
939
+ >
940
+ <IconCopy className="size-3.5" />
941
+ </Button>
942
+ ) : null}
496
943
  </div>
497
944
  </div>
498
945
  <div className="flex flex-wrap items-center gap-2">
499
- <Badge variant="secondary">{detail.messages.length} messages</Badge>
500
- <Badge variant="secondary">{detail.runs.length} runs</Badge>
946
+ {primaryRun ? (
947
+ <Badge
948
+ variant={
949
+ primaryRun.status === "errored" ? "destructive" : "secondary"
950
+ }
951
+ >
952
+ {primaryRun.status}
953
+ </Badge>
954
+ ) : null}
501
955
  <Badge variant="outline">{detail.source.label}</Badge>
502
956
  </div>
503
957
  </div>
504
- <div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
505
- <div className="truncate">Owner: {detail.thread.ownerEmail}</div>
506
- <div>Created: {formatDate(detail.thread.createdAt)}</div>
507
- <div>Updated: {formatDate(detail.thread.updatedAt)}</div>
958
+ <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
959
+ <span className="truncate">{detail.thread.ownerEmail}</span>
960
+ <span aria-hidden="true">·</span>
961
+ <span>{detail.messages.length} messages</span>
962
+ <span aria-hidden="true">·</span>
963
+ <span>{detail.runs.length} runs</span>
964
+ <span aria-hidden="true">·</span>
965
+ <span>updated {formatDate(detail.thread.updatedAt)}</span>
508
966
  </div>
509
967
  </div>
510
968
 
511
- <Tabs defaultValue="transcript" className="p-4">
512
- <TabsList>
969
+ {primaryRun ? (
970
+ <DiagnosisPanel
971
+ run={primaryRun}
972
+ eventCount={eventCount}
973
+ toolCount={toolCount}
974
+ />
975
+ ) : null}
976
+
977
+ <Tabs defaultValue="overview" className="p-5">
978
+ <TabsList className="h-9">
979
+ <TabsTrigger value="overview">Overview</TabsTrigger>
980
+ <TabsTrigger value="timeline">Timeline</TabsTrigger>
513
981
  <TabsTrigger value="transcript">Transcript</TabsTrigger>
514
- <TabsTrigger value="runs">Runs</TabsTrigger>
515
- <TabsTrigger value="internals">Internals</TabsTrigger>
516
- <TabsTrigger value="raw">Raw</TabsTrigger>
982
+ <TabsTrigger value="technical">Technical</TabsTrigger>
517
983
  </TabsList>
518
984
 
985
+ <TabsContent value="overview" className="mt-4 space-y-4">
986
+ <div className="flex items-start gap-2 border-b pb-4 text-sm text-muted-foreground">
987
+ <IconInfoCircle className="mt-0.5 size-4 shrink-0" />
988
+ <p className="max-w-2xl leading-relaxed">
989
+ This is the compact readout. Open Timeline for the last meaningful
990
+ signals, Transcript for persisted messages and tool calls, or
991
+ Technical for raw records.
992
+ </p>
993
+ </div>
994
+ <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
995
+ <EvidenceStat
996
+ label="Created"
997
+ value={formatDate(detail.thread.createdAt)}
998
+ />
999
+ <EvidenceStat
1000
+ label="Updated"
1001
+ value={formatDate(detail.thread.updatedAt)}
1002
+ />
1003
+ <EvidenceStat label="Source" value={detail.source.label} />
1004
+ <EvidenceStat
1005
+ label="Messages"
1006
+ value={detail.messages.length.toLocaleString()}
1007
+ />
1008
+ <EvidenceStat
1009
+ label="Retained events"
1010
+ value={eventCount.toLocaleString()}
1011
+ />
1012
+ <EvidenceStat
1013
+ label="Tool starts"
1014
+ value={toolCount.toLocaleString()}
1015
+ />
1016
+ </div>
1017
+ {detail.messages.length === 0 && eventCount > 0 ? (
1018
+ <div className="flex items-start gap-2 rounded-lg border bg-muted/20 px-3 py-3 text-sm text-muted-foreground">
1019
+ <IconActivity className="mt-0.5 size-4 shrink-0" />
1020
+ <span>
1021
+ No persisted messages are available, but this run retained{" "}
1022
+ {eventCount.toLocaleString()} execution events. The timeline is
1023
+ the authoritative audit trail for this run.
1024
+ </span>
1025
+ </div>
1026
+ ) : null}
1027
+ </TabsContent>
1028
+
1029
+ <TabsContent value="timeline" className="mt-4 space-y-5">
1030
+ {detail.runs.length > 0 ? (
1031
+ detail.runs.map((run) => (
1032
+ <section
1033
+ key={run.id}
1034
+ className="border-b pb-5 last:border-b-0 last:pb-0"
1035
+ >
1036
+ <div className="mb-3 flex flex-wrap items-center gap-2">
1037
+ <Badge
1038
+ variant={
1039
+ run.status === "errored" ? "destructive" : "outline"
1040
+ }
1041
+ >
1042
+ {run.status}
1043
+ </Badge>
1044
+ <span className="font-mono text-xs text-muted-foreground">
1045
+ {run.id}
1046
+ </span>
1047
+ <span className="text-xs text-muted-foreground">
1048
+ {formatDuration(run.durationMs)}
1049
+ </span>
1050
+ </div>
1051
+ <RunTimeline run={run} />
1052
+ </section>
1053
+ ))
1054
+ ) : (
1055
+ <div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
1056
+ No retained runs.
1057
+ </div>
1058
+ )}
1059
+ </TabsContent>
1060
+
519
1061
  <TabsContent value="transcript" className="mt-4 space-y-3">
520
1062
  {detail.messages.length > 0 ? (
521
1063
  detail.messages.map((message) => (
@@ -526,177 +1068,162 @@ function ThreadDetail({ detail }: { detail: ThreadDebugResponse }) {
526
1068
  ))
527
1069
  ) : (
528
1070
  <div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
529
- No persisted messages.
1071
+ <div>No persisted messages.</div>
1072
+ {eventCount > 0 ? (
1073
+ <div className="mt-1">
1074
+ Open Timeline to audit the retained execution events.
1075
+ </div>
1076
+ ) : null}
530
1077
  </div>
531
1078
  )}
532
1079
  </TabsContent>
533
1080
 
534
- <TabsContent value="runs" className="mt-4 space-y-3">
535
- {detail.runs.length > 0 ? (
536
- detail.runs.map((run) => (
537
- <details key={run.id} className="rounded-lg bg-card">
538
- <summary className="cursor-pointer px-4 py-3">
539
- <div className="inline-flex flex-wrap items-center gap-2">
540
- <Badge variant="outline">{run.status}</Badge>
541
- <span className="font-mono text-xs text-foreground">
542
- {run.id}
543
- </span>
544
- {run.errorCode ? (
545
- <span className="font-mono text-xs text-destructive">
546
- {run.errorCode}
1081
+ <TabsContent value="technical" className="mt-4 space-y-3">
1082
+ <details className="rounded-lg border">
1083
+ <summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-3 text-sm font-medium text-foreground">
1084
+ <span>Run records</span>
1085
+ <span className="text-xs font-normal text-muted-foreground">
1086
+ {detail.runs.length} {detail.runs.length === 1 ? "run" : "runs"}
1087
+ </span>
1088
+ </summary>
1089
+ <div className="space-y-4 border-t p-3">
1090
+ {detail.runs.length > 0 ? (
1091
+ detail.runs.map((run) => (
1092
+ <div key={run.id} className="space-y-3">
1093
+ <div className="flex flex-wrap items-center gap-2">
1094
+ <Badge
1095
+ variant={
1096
+ run.status === "errored" ? "destructive" : "outline"
1097
+ }
1098
+ >
1099
+ {run.status}
1100
+ </Badge>
1101
+ <span className="break-all font-mono text-xs text-foreground">
1102
+ {run.id}
547
1103
  </span>
548
- ) : null}
549
- <span className="text-xs text-muted-foreground">
550
- {formatDate(run.startedAt)}
551
- </span>
552
- </div>
553
- </summary>
554
- <div className="space-y-3 border-t px-4 py-3">
555
- <div className="grid gap-2 text-xs sm:grid-cols-2 xl:grid-cols-3">
556
- <div>
557
- <div className="text-muted-foreground">
558
- {t("dispatch.pages.threadDebugFailureCode", {
559
- defaultValue: "Failure code",
560
- })}
561
- </div>
562
- <div className="mt-0.5 break-words font-mono text-foreground">
563
- {run.errorCode || "n/a"}
564
- </div>
565
- </div>
566
- <div>
567
- <div className="text-muted-foreground">
568
- {t("dispatch.pages.threadDebugTerminalReason", {
569
- defaultValue: "Terminal reason",
570
- })}
571
- </div>
572
- <div className="mt-0.5 break-words font-mono text-foreground">
573
- {run.terminalReason || run.abortReason || "n/a"}
574
- </div>
575
- </div>
576
- <div>
577
- <div className="text-muted-foreground">
578
- {t("dispatch.pages.threadDebugDispatchMode", {
579
- defaultValue: "Dispatch mode",
580
- })}
581
- </div>
582
- <div className="mt-0.5 break-words font-mono text-foreground">
583
- {run.dispatchMode || "foreground"}
584
- </div>
585
1104
  </div>
586
- <div>
587
- <div className="text-muted-foreground">
588
- {t("dispatch.pages.threadDebugLastStage", {
589
- defaultValue: "Last stage",
590
- })}
591
- </div>
592
- <div className="mt-0.5 break-words font-mono text-foreground">
593
- {diagnosticStage(run.workerStage) ||
1105
+ <div className="grid gap-3 text-xs sm:grid-cols-2 xl:grid-cols-3">
1106
+ <EvidenceStat
1107
+ label="Failure code"
1108
+ value={run.errorCode || "n/a"}
1109
+ />
1110
+ <EvidenceStat
1111
+ label="Terminal reason"
1112
+ value={run.terminalReason || run.abortReason || "n/a"}
1113
+ />
1114
+ <EvidenceStat
1115
+ label="Dispatch mode"
1116
+ value={run.dispatchMode || "foreground"}
1117
+ />
1118
+ <EvidenceStat
1119
+ label="Last stage"
1120
+ value={
1121
+ diagnosticStage(run.workerStage) ||
594
1122
  diagnosticStage(run.diagStage) ||
595
- "n/a"}
596
- </div>
597
- </div>
598
- <div>
599
- <div className="text-muted-foreground">
600
- {t("dispatch.pages.threadDebugDuration", {
601
- defaultValue: "Duration",
602
- })}
603
- </div>
604
- <div className="mt-0.5 text-foreground">
605
- {formatDuration(
1123
+ "n/a"
1124
+ }
1125
+ />
1126
+ <EvidenceStat
1127
+ label="Duration"
1128
+ value={formatDuration(
606
1129
  run.durationMs ??
607
1130
  (run.completedAt == null
608
1131
  ? null
609
1132
  : run.completedAt - run.startedAt),
610
1133
  )}
611
- </div>
1134
+ />
1135
+ <EvidenceStat
1136
+ label="Last progress"
1137
+ value={formatDate(
1138
+ run.lastProgressAt ?? run.heartbeatAt,
1139
+ )}
1140
+ />
1141
+ <EvidenceStat
1142
+ label="In-flight marker"
1143
+ value={formatDate(run.inFlightSince)}
1144
+ />
1145
+ <EvidenceStat
1146
+ label="Recovery payload"
1147
+ value={
1148
+ run.hasDispatchPayload ? "retained" : "not retained"
1149
+ }
1150
+ />
612
1151
  </div>
613
- <div>
614
- <div className="text-muted-foreground">
615
- {t("dispatch.pages.threadDebugLastProgress", {
616
- defaultValue: "Last progress",
617
- })}
618
- </div>
619
- <div className="mt-0.5 text-foreground">
620
- {formatDate(run.lastProgressAt ?? run.heartbeatAt)}
1152
+ {run.errorDetail ? (
1153
+ <div className="rounded-md bg-destructive/10 px-3 py-2 text-xs leading-relaxed text-destructive">
1154
+ {run.errorDetail}
621
1155
  </div>
622
- </div>
1156
+ ) : null}
623
1157
  </div>
624
- {run.errorDetail ? (
625
- <div className="rounded-md bg-destructive/10 px-3 py-2 text-xs leading-relaxed text-destructive">
626
- {run.errorDetail}
627
- </div>
628
- ) : null}
629
- {run.events.map((event) => (
630
- <details
631
- key={`${run.id}-${event.seq}`}
632
- className="rounded-md border bg-muted/30 px-3 py-2"
633
- >
634
- <summary className="cursor-pointer text-xs font-medium text-foreground">
635
- #{event.seq} {eventLabel(event.event)}
636
- </summary>
637
- <RawBlock value={event.event} className="mt-2 max-h-72" />
638
- </details>
639
- ))}
640
- {run.events.length === 0 ? (
641
- <div className="text-sm text-muted-foreground">
642
- No retained run events.
643
- </div>
644
- ) : null}
1158
+ ))
1159
+ ) : (
1160
+ <div className="text-sm text-muted-foreground">
1161
+ No retained run records.
645
1162
  </div>
646
- </details>
647
- ))
648
- ) : (
649
- <div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
650
- No retained runs.
1163
+ )}
651
1164
  </div>
652
- )}
653
- </TabsContent>
1165
+ </details>
654
1166
 
655
- <TabsContent value="internals" className="mt-4 space-y-4">
656
- <div className="grid gap-4 lg:grid-cols-2">
657
- <div>
658
- <div className="mb-2 text-sm font-medium text-foreground">
659
- Debug Runs
1167
+ <details className="rounded-lg border">
1168
+ <summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-3 text-sm font-medium text-foreground">
1169
+ <span>Traces, feedback, and evaluations</span>
1170
+ <span className="text-xs font-normal text-muted-foreground">
1171
+ diagnostic records
1172
+ </span>
1173
+ </summary>
1174
+ <div className="grid gap-4 border-t p-3 lg:grid-cols-2">
1175
+ <div>
1176
+ <div className="mb-2 text-xs font-medium text-foreground">
1177
+ Debug runs
1178
+ </div>
1179
+ <RawBlock
1180
+ value={
1181
+ detail.debugRuns.length > 0
1182
+ ? detail.debugRuns
1183
+ : (detail.debug ?? {})
1184
+ }
1185
+ />
660
1186
  </div>
661
- <RawBlock
662
- value={
663
- detail.debugRuns.length > 0
664
- ? detail.debugRuns
665
- : (detail.debug ?? {})
666
- }
667
- />
668
- </div>
669
- <div>
670
- <div className="mb-2 text-sm font-medium text-foreground">
671
- Trace Summaries
1187
+ <div>
1188
+ <div className="mb-2 text-xs font-medium text-foreground">
1189
+ Trace summaries
1190
+ </div>
1191
+ <RawBlock value={detail.traces.summaries} />
672
1192
  </div>
673
- <RawBlock value={detail.traces.summaries} />
674
- </div>
675
- <div>
676
- <div className="mb-2 text-sm font-medium text-foreground">
677
- Trace Spans
1193
+ <div>
1194
+ <div className="mb-2 text-xs font-medium text-foreground">
1195
+ Trace spans
1196
+ </div>
1197
+ <RawBlock value={detail.traces.spans} />
678
1198
  </div>
679
- <RawBlock value={detail.traces.spans} />
680
- </div>
681
- <div>
682
- <div className="mb-2 text-sm font-medium text-foreground">
683
- Feedback And Evals
1199
+ <div>
1200
+ <div className="mb-2 text-xs font-medium text-foreground">
1201
+ Feedback and evals
1202
+ </div>
1203
+ <RawBlock
1204
+ value={{
1205
+ feedback: detail.feedback,
1206
+ satisfaction: detail.satisfaction,
1207
+ evals: detail.evals,
1208
+ checkpoints: detail.checkpoints,
1209
+ }}
1210
+ />
684
1211
  </div>
685
- <RawBlock
686
- value={{
687
- feedback: detail.feedback,
688
- satisfaction: detail.satisfaction,
689
- evals: detail.evals,
690
- checkpoints: detail.checkpoints,
691
- }}
692
- />
693
1212
  </div>
694
- </div>
695
- </TabsContent>
1213
+ </details>
696
1214
 
697
- <TabsContent value="raw" className="mt-4 space-y-4">
698
- <RawBlock value={rawBundle} />
699
- <RawBlock value={detail.rawThreadData} />
1215
+ <details className="rounded-lg border">
1216
+ <summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-3 py-3 text-sm font-medium text-foreground">
1217
+ <span>Raw thread bundle</span>
1218
+ <span className="text-xs font-normal text-muted-foreground">
1219
+ JSON
1220
+ </span>
1221
+ </summary>
1222
+ <div className="space-y-3 border-t p-3">
1223
+ <RawBlock value={rawBundle} />
1224
+ <RawBlock value={detail.rawThreadData} />
1225
+ </div>
1226
+ </details>
700
1227
  </TabsContent>
701
1228
  </Tabs>
702
1229
  </div>
@@ -713,11 +1240,13 @@ export default function ThreadDebugRoute() {
713
1240
  const ownerEmail = routeSearchParams.get("owner") || "";
714
1241
  const query = routeSearchParams.get("query") || "";
715
1242
  const status = parseFailureStatus(routeSearchParams.get("status"));
1243
+ const regime = parseFailureRegime(routeSearchParams.get("regime"));
716
1244
  const range = parseFailureRange(routeSearchParams.get("range"));
717
1245
  const runId = routeSearchParams.get("runId") || "";
718
1246
  const threadId = routeSearchParams.get("threadId") || "";
719
1247
  const inspectSourceId = routeSearchParams.get("inspectSource") || "";
720
1248
  const [lookupId, setLookupId] = useState("");
1249
+ const [searchFocused, setSearchFocused] = useState(false);
721
1250
 
722
1251
  function updateRouteState(
723
1252
  updates: Record<string, string | null | undefined>,
@@ -741,7 +1270,7 @@ export default function ThreadDebugRoute() {
741
1270
  };
742
1271
  sources: ThreadDebugSource[];
743
1272
  }>("list-agent-thread-sources", {});
744
- const { data: sourcesData, isLoading: sourcesLoading } = sourcesQuery;
1273
+ const { data: sourcesData } = sourcesQuery;
745
1274
 
746
1275
  const sources: ThreadDebugSource[] = sourcesData?.sources ?? [];
747
1276
  const failureParams = useMemo(
@@ -749,10 +1278,11 @@ export default function ThreadDebugRoute() {
749
1278
  sourceId,
750
1279
  ownerEmail: ownerEmail.trim() || undefined,
751
1280
  status,
1281
+ regime,
752
1282
  lookbackHours: FAILURE_RANGE_HOURS[range],
753
1283
  limit: 25,
754
1284
  }),
755
- [ownerEmail, range, sourceId, status],
1285
+ [ownerEmail, range, regime, sourceId, status],
756
1286
  );
757
1287
  const {
758
1288
  data: failuresData,
@@ -764,7 +1294,19 @@ export default function ThreadDebugRoute() {
764
1294
  failureParams,
765
1295
  { enabled: mode === "failures" },
766
1296
  );
767
- const failures = failuresData?.failures ?? [];
1297
+ const failures = failuresData?.failures ?? EMPTY_FAILURES;
1298
+ const failurePatterns = useMemo(() => {
1299
+ const patterns = new Map<string, { label: string; count: number }>();
1300
+ for (const failure of failures) {
1301
+ const diagnosis = runDiagnosis(failure);
1302
+ const current = patterns.get(diagnosis.code);
1303
+ patterns.set(diagnosis.code, {
1304
+ label: diagnosis.title,
1305
+ count: (current?.count ?? 0) + 1,
1306
+ });
1307
+ }
1308
+ return [...patterns.values()].sort((a, b) => b.count - a.count);
1309
+ }, [failures]);
768
1310
  const unavailableFailureSources = (failuresData?.sources ?? []).filter(
769
1311
  (source) => source.status !== "ok",
770
1312
  );
@@ -788,10 +1330,9 @@ export default function ThreadDebugRoute() {
788
1330
  () => ({
789
1331
  sourceId: threadSourceId,
790
1332
  query: query.trim() || undefined,
791
- ownerEmail: ownerEmail.trim() || undefined,
792
1333
  limit: 25,
793
1334
  }),
794
- [ownerEmail, query, threadSourceId],
1335
+ [query, threadSourceId],
795
1336
  );
796
1337
  const {
797
1338
  data: searchData,
@@ -805,6 +1346,16 @@ export default function ThreadDebugRoute() {
805
1346
  source: { id: string; label: string };
806
1347
  }>("search-agent-threads", searchParams, { enabled: mode === "threads" });
807
1348
  const searchThreads: ThreadSearchResult[] = searchData?.threads ?? [];
1349
+ const ownerEmailSuggestions = useMemo(() => {
1350
+ const emailQuery = query.trim().includes("@")
1351
+ ? query.trim().toLowerCase()
1352
+ : "";
1353
+ return [...new Set(searchThreads.map((thread) => thread.ownerEmail))]
1354
+ .filter(
1355
+ (email) => !emailQuery || email.toLowerCase().includes(emailQuery),
1356
+ )
1357
+ .slice(0, 8);
1358
+ }, [query, searchThreads]);
808
1359
 
809
1360
  const detailParams = useMemo(
810
1361
  () => ({
@@ -830,7 +1381,6 @@ export default function ThreadDebugRoute() {
830
1381
  },
831
1382
  );
832
1383
 
833
- const selectedSource = sources.find((source) => source.id === threadSourceId);
834
1384
  const detailPane = (
835
1385
  <section className="min-w-0">
836
1386
  {detailError ? (
@@ -840,7 +1390,7 @@ export default function ThreadDebugRoute() {
840
1390
  />
841
1391
  ) : null}
842
1392
  {detailLoading ? (
843
- <div className="rounded-lg bg-card p-4">
1393
+ <div className="p-5">
844
1394
  <Skeleton className="h-6 w-72" />
845
1395
  <Skeleton className="mt-3 h-4 w-96" />
846
1396
  <Skeleton className="mt-6 h-[520px] w-full" />
@@ -848,11 +1398,15 @@ export default function ThreadDebugRoute() {
848
1398
  ) : detail ? (
849
1399
  <ThreadDetail detail={detail} />
850
1400
  ) : (
851
- <div className="flex min-h-[520px] flex-col items-center justify-center rounded-lg border border-dashed bg-card px-4 text-center text-sm text-muted-foreground">
1401
+ <div className="flex min-h-[520px] flex-col items-center justify-center px-5 text-center text-sm text-muted-foreground">
852
1402
  <IconFileSearch className="mb-2 size-5" />
853
- {t("dispatch.pages.threadDebugSelectPrompt", {
854
- defaultValue: "Select a failed run or thread to inspect.",
855
- })}
1403
+ <div className="font-medium text-foreground">
1404
+ Choose a run to inspect
1405
+ </div>
1406
+ <div className="mt-1 max-w-xs leading-relaxed">
1407
+ See the diagnosis first, then open the timeline or technical
1408
+ evidence.
1409
+ </div>
856
1410
  </div>
857
1411
  )}
858
1412
  </section>
@@ -864,8 +1418,7 @@ export default function ThreadDebugRoute() {
864
1418
  defaultValue: "Thread Debug",
865
1419
  })}
866
1420
  description={t("dispatch.pages.threadDebugDescription", {
867
- defaultValue:
868
- "Inspect failed agent runs, persisted threads, run events, and AI internals.",
1421
+ defaultValue: "Find the failure pattern, then inspect the evidence.",
869
1422
  })}
870
1423
  >
871
1424
  <div className="space-y-4">
@@ -905,8 +1458,29 @@ export default function ThreadDebugRoute() {
905
1458
  </TabsList>
906
1459
 
907
1460
  <TabsContent value="failures" className="mt-4 space-y-4">
908
- <section className="rounded-lg bg-card p-4">
909
- <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-[220px_1fr_180px_150px]">
1461
+ <section className="border-b pb-4">
1462
+ <div className="flex flex-wrap items-start justify-between gap-3">
1463
+ <div>
1464
+ <h2 className="text-sm font-semibold text-foreground">
1465
+ Run health
1466
+ </h2>
1467
+ <p className="mt-1 text-xs text-muted-foreground">
1468
+ Start with the dominant failure pattern, then open one run.
1469
+ </p>
1470
+ </div>
1471
+ <Button
1472
+ type="button"
1473
+ variant="ghost"
1474
+ size="icon"
1475
+ onClick={() => void refetchFailures()}
1476
+ aria-label={t("dispatch.pages.threadDebugRefreshFailures", {
1477
+ defaultValue: "Refresh failed runs",
1478
+ })}
1479
+ >
1480
+ <IconRefresh className="size-4" />
1481
+ </Button>
1482
+ </div>
1483
+ <div className="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-[200px_minmax(180px,1fr)_180px_160px_150px]">
910
1484
  <Select
911
1485
  value={sourceId}
912
1486
  onValueChange={(value) =>
@@ -943,6 +1517,9 @@ export default function ThreadDebugRoute() {
943
1517
  onChange={(event) =>
944
1518
  updateRouteState({ owner: event.target.value })
945
1519
  }
1520
+ aria-label={t("dispatch.pages.threadDebugOwner", {
1521
+ defaultValue: "Owner email",
1522
+ })}
946
1523
  placeholder={t("dispatch.pages.threadDebugOwner", {
947
1524
  defaultValue: "Owner email",
948
1525
  })}
@@ -987,6 +1564,27 @@ export default function ThreadDebugRoute() {
987
1564
  </SelectItem>
988
1565
  </SelectContent>
989
1566
  </Select>
1567
+ <Select
1568
+ value={regime}
1569
+ onValueChange={(value) =>
1570
+ updateRouteState({
1571
+ regime: parseFailureRegime(value),
1572
+ runId: null,
1573
+ inspectSource: null,
1574
+ })
1575
+ }
1576
+ >
1577
+ <SelectTrigger aria-label="Run type">
1578
+ <SelectValue placeholder="Run type" />
1579
+ </SelectTrigger>
1580
+ <SelectContent>
1581
+ <SelectItem value="all">All run types</SelectItem>
1582
+ <SelectItem value="interactive">
1583
+ Interactive chats
1584
+ </SelectItem>
1585
+ <SelectItem value="scheduled">Scheduled jobs</SelectItem>
1586
+ </SelectContent>
1587
+ </Select>
990
1588
  <Select
991
1589
  value={range}
992
1590
  onValueChange={(value) =>
@@ -1024,11 +1622,9 @@ export default function ThreadDebugRoute() {
1024
1622
  </Select>
1025
1623
  </div>
1026
1624
  <div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
1027
- <span>
1028
- {failuresData?.count ?? failures.length}{" "}
1029
- {t("dispatch.pages.threadDebugFailureResults", {
1030
- defaultValue: "failed runs",
1031
- })}
1625
+ <span className="font-medium text-foreground">
1626
+ {failures.length} loaded{" "}
1627
+ {failures.length === 1 ? "run" : "runs"}
1032
1628
  </span>
1033
1629
  <span>·</span>
1034
1630
  <span>
@@ -1037,6 +1633,11 @@ export default function ThreadDebugRoute() {
1037
1633
  defaultValue: "current scope",
1038
1634
  })}
1039
1635
  </span>
1636
+ {failurePatterns.slice(0, 3).map((pattern) => (
1637
+ <Badge key={pattern.label} variant="outline">
1638
+ {pattern.count} {pattern.label}
1639
+ </Badge>
1640
+ ))}
1040
1641
  {failuresData?.partial ? (
1041
1642
  <Badge variant="outline">
1042
1643
  {t("dispatch.pages.threadDebugPartialResults", {
@@ -1070,68 +1671,67 @@ export default function ThreadDebugRoute() {
1070
1671
  />
1071
1672
  ) : null}
1072
1673
 
1073
- <div className="grid gap-4 xl:grid-cols-[380px_1fr]">
1074
- <section className="min-h-[520px] rounded-lg bg-card">
1075
- <div className="flex items-center justify-between border-b px-4 py-3">
1076
- <div className="text-sm font-semibold text-foreground">
1077
- {t("dispatch.pages.threadDebugFailedRuns", {
1078
- defaultValue: "Failed runs",
1079
- })}
1080
- </div>
1081
- <Button
1082
- type="button"
1083
- variant="ghost"
1084
- size="icon"
1085
- onClick={() => void refetchFailures()}
1086
- aria-label={t("dispatch.pages.threadDebugRefreshFailures", {
1087
- defaultValue: "Refresh failed runs",
1088
- })}
1089
- >
1090
- <IconRefresh className="size-4" />
1091
- </Button>
1092
- </div>
1093
- <div className="max-h-[760px] space-y-2 overflow-auto p-3">
1094
- {failuresLoading ? (
1095
- <>
1096
- <Skeleton className="h-32 w-full rounded-lg" />
1097
- <Skeleton className="h-32 w-full rounded-lg" />
1098
- <Skeleton className="h-32 w-full rounded-lg" />
1099
- </>
1100
- ) : null}
1101
- {!failuresLoading && failures.length === 0 ? (
1102
- <div className="flex min-h-64 flex-col items-center justify-center rounded-lg border border-dashed px-4 text-center text-sm text-muted-foreground">
1103
- <IconDatabase className="mb-2 size-5" />
1104
- {t("dispatch.pages.threadDebugNoFailures", {
1105
- defaultValue: "No failed runs found.",
1106
- })}
1674
+ <div className="overflow-hidden rounded-xl border bg-card">
1675
+ <div className="grid xl:grid-cols-[360px_minmax(0,1fr)]">
1676
+ <section className="min-h-[560px] border-b xl:border-b-0 xl:border-r">
1677
+ <div className="flex items-center justify-between border-b px-4 py-3">
1678
+ <div>
1679
+ <div className="text-sm font-semibold text-foreground">
1680
+ Needs attention
1681
+ </div>
1682
+ <div className="mt-0.5 text-xs text-muted-foreground">
1683
+ {failurePatterns[0]
1684
+ ? `${failurePatterns[0].count} ${failurePatterns[0].label.toLowerCase()}`
1685
+ : "No active failure pattern"}
1686
+ </div>
1107
1687
  </div>
1108
- ) : null}
1109
- {failures.map((failure) => (
1110
- <FailureCard
1111
- key={`${failureSourceId(failure)}:${failure.id}`}
1112
- failure={failure}
1113
- selected={
1114
- runId === failure.id &&
1115
- detailSourceId === failureSourceId(failure)
1116
- }
1117
- onSelect={() =>
1118
- updateRouteState({
1119
- inspectSource: failureSourceId(failure),
1120
- runId: failure.id,
1121
- threadId: null,
1122
- })
1123
- }
1124
- />
1125
- ))}
1126
- </div>
1127
- </section>
1128
- {detailPane}
1688
+ <span className="text-xs text-muted-foreground">
1689
+ {failures.length}
1690
+ </span>
1691
+ </div>
1692
+ <div className="max-h-[760px] overflow-auto">
1693
+ {failuresLoading ? (
1694
+ <>
1695
+ <Skeleton className="mx-4 mt-4 h-16 w-[calc(100%-2rem)]" />
1696
+ <Skeleton className="mx-4 mt-2 h-16 w-[calc(100%-2rem)]" />
1697
+ <Skeleton className="mx-4 mt-2 h-16 w-[calc(100%-2rem)]" />
1698
+ </>
1699
+ ) : null}
1700
+ {!failuresLoading && failures.length === 0 ? (
1701
+ <div className="flex min-h-64 flex-col items-center justify-center px-4 text-center text-sm text-muted-foreground">
1702
+ <IconDatabase className="mb-2 size-5" />
1703
+ {t("dispatch.pages.threadDebugNoFailures", {
1704
+ defaultValue: "No failed runs found.",
1705
+ })}
1706
+ </div>
1707
+ ) : null}
1708
+ {failures.map((failure) => (
1709
+ <FailureCard
1710
+ key={`${failureSourceId(failure)}:${failure.id}`}
1711
+ failure={failure}
1712
+ selected={
1713
+ runId === failure.id &&
1714
+ detailSourceId === failureSourceId(failure)
1715
+ }
1716
+ onSelect={() =>
1717
+ updateRouteState({
1718
+ inspectSource: failureSourceId(failure),
1719
+ runId: failure.id,
1720
+ threadId: null,
1721
+ })
1722
+ }
1723
+ />
1724
+ ))}
1725
+ </div>
1726
+ </section>
1727
+ <div className="min-w-0">{detailPane}</div>
1728
+ </div>
1129
1729
  </div>
1130
1730
  </TabsContent>
1131
1731
 
1132
1732
  <TabsContent value="threads" className="mt-4 space-y-4">
1133
- <section className="rounded-lg bg-card p-4">
1134
- <div className="grid gap-3 lg:grid-cols-[220px_1fr_260px_auto]">
1733
+ <section className="border-b pb-4">
1734
+ <div className="grid gap-2 pt-3 lg:grid-cols-2 xl:grid-cols-[auto_180px_minmax(220px,1fr)_auto_auto]">
1135
1735
  <Select
1136
1736
  value={threadSourceId}
1137
1737
  onValueChange={(value) =>
@@ -1165,97 +1765,126 @@ export default function ThreadDebugRoute() {
1165
1765
  ) : null}
1166
1766
  </SelectContent>
1167
1767
  </Select>
1168
- <Input
1169
- value={query}
1170
- onChange={(event) =>
1171
- updateRouteState({ query: event.target.value })
1172
- }
1173
- placeholder={t(
1174
- "dispatch.pages.threadDebugSearchPlaceholder",
1175
- {
1176
- defaultValue: "Search title, preview, messages, tools",
1177
- },
1178
- )}
1179
- />
1180
- <Input
1181
- value={ownerEmail}
1182
- onChange={(event) =>
1183
- updateRouteState({ owner: event.target.value })
1184
- }
1185
- placeholder={t("dispatch.pages.threadDebugOwner", {
1186
- defaultValue: "Owner email",
1187
- })}
1188
- />
1189
- <Button type="button" onClick={() => void refetchSearch()}>
1768
+ <Popover
1769
+ open={searchFocused && ownerEmailSuggestions.length > 0}
1770
+ onOpenChange={setSearchFocused}
1771
+ >
1772
+ <PopoverAnchor asChild>
1773
+ <span className="block">
1774
+ <Input
1775
+ value={query}
1776
+ onChange={(event) =>
1777
+ updateRouteState({ query: event.target.value })
1778
+ }
1779
+ onFocus={() => setSearchFocused(true)}
1780
+ placeholder={t(
1781
+ "dispatch.pages.threadDebugSearchPlaceholder",
1782
+ {
1783
+ defaultValue: "Search threads or email",
1784
+ },
1785
+ )}
1786
+ aria-label="Search threads or email"
1787
+ />
1788
+ </span>
1789
+ </PopoverAnchor>
1790
+ <PopoverContent
1791
+ align="start"
1792
+ sideOffset={6}
1793
+ className="w-[var(--radix-popover-trigger-width)] p-1"
1794
+ onOpenAutoFocus={(event) => event.preventDefault()}
1795
+ >
1796
+ <div role="listbox" aria-label="Owner email suggestions">
1797
+ {ownerEmailSuggestions.map((email) => (
1798
+ <button
1799
+ key={email}
1800
+ type="button"
1801
+ role="option"
1802
+ aria-selected="false"
1803
+ className="flex w-full items-center rounded-sm px-2 py-1.5 text-left text-sm text-foreground outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground"
1804
+ onMouseDown={(event) => event.preventDefault()}
1805
+ onClick={() => {
1806
+ updateRouteState({ query: email });
1807
+ setSearchFocused(false);
1808
+ }}
1809
+ >
1810
+ {email}
1811
+ </button>
1812
+ ))}
1813
+ </div>
1814
+ </PopoverContent>
1815
+ </Popover>
1816
+ <Button
1817
+ type="button"
1818
+ size="icon"
1819
+ onClick={() => void refetchSearch()}
1820
+ aria-label="Search threads or email"
1821
+ title="Search threads or email"
1822
+ >
1190
1823
  <IconSearch className="size-4" />
1191
- {t("dispatch.pages.threadDebugSearch", {
1192
- defaultValue: "Search",
1193
- })}
1194
1824
  </Button>
1195
- </div>
1196
-
1197
- <div className="mt-3 grid gap-3 lg:grid-cols-[1fr_auto]">
1198
- <Input
1199
- value={lookupId}
1200
- onChange={(event) => setLookupId(event.target.value)}
1201
- placeholder={t(
1202
- "dispatch.pages.threadDebugLookupPlaceholder",
1203
- {
1204
- defaultValue: "Paste thread or request/run ID",
1205
- },
1206
- )}
1207
- className="font-mono"
1208
- />
1209
1825
  <Button
1210
1826
  type="button"
1211
- variant="outline"
1212
- onClick={() => {
1213
- const trimmed = lookupId.trim();
1214
- if (!trimmed) return;
1215
- updateRouteState(
1216
- trimmed.startsWith("run-")
1217
- ? {
1218
- runId: trimmed,
1219
- threadId: null,
1220
- inspectSource: null,
1221
- }
1222
- : {
1223
- threadId: trimmed,
1224
- runId: null,
1225
- inspectSource: null,
1226
- },
1227
- );
1228
- }}
1229
- >
1230
- <IconFileSearch className="size-4" />
1231
- {t("dispatch.pages.threadDebugInspect", {
1232
- defaultValue: "Inspect",
1827
+ variant="ghost"
1828
+ size="icon"
1829
+ onClick={() => void refetchSearch()}
1830
+ aria-label={t("dispatch.pages.threadDebugRefreshThreads", {
1831
+ defaultValue: "Refresh threads",
1233
1832
  })}
1833
+ >
1834
+ <IconRefresh className="size-4" />
1234
1835
  </Button>
1235
- </div>
1236
-
1237
- <div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
1238
- {sourcesLoading ? <Skeleton className="h-5 w-32" /> : null}
1239
- {selectedSource ? (
1240
- <SourceBadge source={selectedSource} />
1241
- ) : null}
1242
- {selectedSource?.databaseUrlEnv ? (
1243
- <Badge variant="outline" className="font-mono">
1244
- {selectedSource.databaseUrlEnv}
1245
- </Badge>
1246
- ) : null}
1247
- {sourcesData?.access ? (
1248
- <span>
1249
- {sourcesData.access.viewerEmail} ·{" "}
1250
- {sourcesData.access.canInspectAll
1251
- ? t("dispatch.pages.threadDebugAdminScope", {
1252
- defaultValue: "admin scope",
1253
- })
1254
- : t("dispatch.pages.threadDebugOwnScope", {
1255
- defaultValue: "own scope",
1256
- })}
1257
- </span>
1258
- ) : null}
1836
+ <details className="order-first justify-self-start rounded-lg border">
1837
+ <summary
1838
+ aria-label="Advanced lookup"
1839
+ title="Advanced lookup"
1840
+ className="flex size-10 cursor-pointer list-none items-center justify-center text-foreground"
1841
+ >
1842
+ <IconAdjustmentsHorizontal
1843
+ aria-hidden="true"
1844
+ className="size-4"
1845
+ />
1846
+ <span className="sr-only">Advanced lookup</span>
1847
+ </summary>
1848
+ <div className="grid gap-3 border-t p-3 lg:w-80">
1849
+ <Input
1850
+ value={lookupId}
1851
+ onChange={(event) => setLookupId(event.target.value)}
1852
+ placeholder={t(
1853
+ "dispatch.pages.threadDebugLookupPlaceholder",
1854
+ {
1855
+ defaultValue: "Paste thread or request/run ID",
1856
+ },
1857
+ )}
1858
+ className="font-mono"
1859
+ />
1860
+ <Button
1861
+ type="button"
1862
+ variant="outline"
1863
+ onClick={() => {
1864
+ const trimmed = lookupId.trim();
1865
+ if (!trimmed) return;
1866
+ updateRouteState(
1867
+ trimmed.startsWith("run-")
1868
+ ? {
1869
+ runId: trimmed,
1870
+ threadId: null,
1871
+ inspectSource: null,
1872
+ }
1873
+ : {
1874
+ threadId: trimmed,
1875
+ runId: null,
1876
+ inspectSource: null,
1877
+ },
1878
+ );
1879
+ }}
1880
+ >
1881
+ <IconFileSearch className="size-4" />
1882
+ {t("dispatch.pages.threadDebugInspect", {
1883
+ defaultValue: "Inspect",
1884
+ })}
1885
+ </Button>
1886
+ </div>
1887
+ </details>
1259
1888
  </div>
1260
1889
  </section>
1261
1890
 
@@ -1266,72 +1895,43 @@ export default function ThreadDebugRoute() {
1266
1895
  />
1267
1896
  ) : null}
1268
1897
 
1269
- <div className="grid gap-4 xl:grid-cols-[380px_1fr]">
1270
- <section className="min-h-[520px] rounded-lg bg-card">
1271
- <div className="flex items-center justify-between border-b px-4 py-3">
1272
- <div>
1273
- <div className="text-sm font-semibold text-foreground">
1274
- {t("dispatch.pages.threadDebugThreads", {
1275
- defaultValue: "Threads",
1276
- })}
1277
- </div>
1278
- <div className="text-xs text-muted-foreground">
1279
- {searchData?.count ?? 0}{" "}
1280
- {t("dispatch.pages.threadDebugResults", {
1281
- defaultValue: "results",
1282
- })}{" "}
1283
- ·{" "}
1284
- {searchData?.access?.scope ??
1285
- t("dispatch.pages.threadDebugCurrentScope", {
1286
- defaultValue: "current scope",
1898
+ <div className="overflow-hidden rounded-xl border bg-card">
1899
+ <div className="grid xl:grid-cols-[360px_minmax(0,1fr)]">
1900
+ <section className="min-h-[560px] border-b xl:border-b-0 xl:border-r">
1901
+ <div className="max-h-[760px] overflow-auto">
1902
+ {searchLoading ? (
1903
+ <>
1904
+ <Skeleton className="mx-4 mt-4 h-16 w-[calc(100%-2rem)]" />
1905
+ <Skeleton className="mx-4 mt-2 h-16 w-[calc(100%-2rem)]" />
1906
+ <Skeleton className="mx-4 mt-2 h-16 w-[calc(100%-2rem)]" />
1907
+ </>
1908
+ ) : null}
1909
+ {!searchLoading && searchThreads.length === 0 ? (
1910
+ <div className="flex min-h-64 flex-col items-center justify-center px-4 text-center text-sm text-muted-foreground">
1911
+ <IconDatabase className="mb-2 size-5" />
1912
+ {t("dispatch.pages.threadDebugNoThreads", {
1913
+ defaultValue: "No threads found.",
1287
1914
  })}
1288
- </div>
1915
+ </div>
1916
+ ) : null}
1917
+ {searchThreads.map((result) => (
1918
+ <ResultCard
1919
+ key={result.id}
1920
+ result={result}
1921
+ selected={threadId === result.id}
1922
+ onSelect={() =>
1923
+ updateRouteState({
1924
+ threadId: result.id,
1925
+ runId: null,
1926
+ inspectSource: null,
1927
+ })
1928
+ }
1929
+ />
1930
+ ))}
1289
1931
  </div>
1290
- <Button
1291
- type="button"
1292
- variant="ghost"
1293
- size="icon"
1294
- onClick={() => void refetchSearch()}
1295
- aria-label={t("dispatch.pages.threadDebugRefreshThreads", {
1296
- defaultValue: "Refresh threads",
1297
- })}
1298
- >
1299
- <IconRefresh className="size-4" />
1300
- </Button>
1301
- </div>
1302
- <div className="max-h-[760px] space-y-2 overflow-auto p-3">
1303
- {searchLoading ? (
1304
- <>
1305
- <Skeleton className="h-28 w-full rounded-lg" />
1306
- <Skeleton className="h-28 w-full rounded-lg" />
1307
- <Skeleton className="h-28 w-full rounded-lg" />
1308
- </>
1309
- ) : null}
1310
- {!searchLoading && searchThreads.length === 0 ? (
1311
- <div className="flex min-h-64 flex-col items-center justify-center rounded-lg border border-dashed px-4 text-center text-sm text-muted-foreground">
1312
- <IconDatabase className="mb-2 size-5" />
1313
- {t("dispatch.pages.threadDebugNoThreads", {
1314
- defaultValue: "No threads found.",
1315
- })}
1316
- </div>
1317
- ) : null}
1318
- {searchThreads.map((result) => (
1319
- <ResultCard
1320
- key={result.id}
1321
- result={result}
1322
- selected={threadId === result.id}
1323
- onSelect={() =>
1324
- updateRouteState({
1325
- threadId: result.id,
1326
- runId: null,
1327
- inspectSource: null,
1328
- })
1329
- }
1330
- />
1331
- ))}
1332
- </div>
1333
- </section>
1334
- {detailPane}
1932
+ </section>
1933
+ <div className="min-w-0">{detailPane}</div>
1934
+ </div>
1335
1935
  </div>
1336
1936
  </TabsContent>
1337
1937
  </Tabs>