@agent-native/dispatch 0.2.20 → 0.4.0

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 (79) hide show
  1. package/dist/actions/archive-workspace-app.d.ts +3 -0
  2. package/dist/actions/archive-workspace-app.d.ts.map +1 -0
  3. package/dist/actions/archive-workspace-app.js +15 -0
  4. package/dist/actions/archive-workspace-app.js.map +1 -0
  5. package/dist/actions/get-agent-thread-debug.d.ts +3 -0
  6. package/dist/actions/get-agent-thread-debug.d.ts.map +1 -0
  7. package/dist/actions/get-agent-thread-debug.js +24 -0
  8. package/dist/actions/get-agent-thread-debug.js.map +1 -0
  9. package/dist/actions/index.d.ts.map +1 -1
  10. package/dist/actions/index.js +8 -0
  11. package/dist/actions/index.js.map +1 -1
  12. package/dist/actions/list-agent-thread-sources.d.ts +3 -0
  13. package/dist/actions/list-agent-thread-sources.d.ts.map +1 -0
  14. package/dist/actions/list-agent-thread-sources.js +11 -0
  15. package/dist/actions/list-agent-thread-sources.js.map +1 -0
  16. package/dist/actions/list-available-workspace-templates.d.ts +3 -0
  17. package/dist/actions/list-available-workspace-templates.d.ts.map +1 -0
  18. package/dist/actions/list-available-workspace-templates.js +10 -0
  19. package/dist/actions/list-available-workspace-templates.js.map +1 -0
  20. package/dist/actions/navigate.js +1 -1
  21. package/dist/actions/navigate.js.map +1 -1
  22. package/dist/actions/remove-pending-workspace-app.d.ts +3 -0
  23. package/dist/actions/remove-pending-workspace-app.d.ts.map +1 -0
  24. package/dist/actions/remove-pending-workspace-app.js +15 -0
  25. package/dist/actions/remove-pending-workspace-app.js.map +1 -0
  26. package/dist/actions/scaffold-workspace-app.d.ts +3 -0
  27. package/dist/actions/scaffold-workspace-app.d.ts.map +1 -0
  28. package/dist/actions/scaffold-workspace-app.js +27 -0
  29. package/dist/actions/scaffold-workspace-app.js.map +1 -0
  30. package/dist/actions/search-agent-threads.d.ts +3 -0
  31. package/dist/actions/search-agent-threads.d.ts.map +1 -0
  32. package/dist/actions/search-agent-threads.js +25 -0
  33. package/dist/actions/search-agent-threads.js.map +1 -0
  34. package/dist/actions/unarchive-workspace-app.d.ts +3 -0
  35. package/dist/actions/unarchive-workspace-app.d.ts.map +1 -0
  36. package/dist/actions/unarchive-workspace-app.js +15 -0
  37. package/dist/actions/unarchive-workspace-app.js.map +1 -0
  38. package/dist/actions/view-screen.d.ts.map +1 -1
  39. package/dist/actions/view-screen.js +38 -0
  40. package/dist/actions/view-screen.js.map +1 -1
  41. package/dist/components/layout/Layout.d.ts.map +1 -1
  42. package/dist/components/layout/Layout.js +8 -1
  43. package/dist/components/layout/Layout.js.map +1 -1
  44. package/dist/components/ui/command.d.ts +7 -7
  45. package/dist/hooks/use-navigation-state.js +5 -0
  46. package/dist/hooks/use-navigation-state.js.map +1 -1
  47. package/dist/routes/index.d.ts.map +1 -1
  48. package/dist/routes/index.js +1 -0
  49. package/dist/routes/index.js.map +1 -1
  50. package/dist/routes/pages/thread-debug.d.ts +5 -0
  51. package/dist/routes/pages/thread-debug.d.ts.map +1 -0
  52. package/dist/routes/pages/thread-debug.js +160 -0
  53. package/dist/routes/pages/thread-debug.js.map +1 -0
  54. package/dist/server/lib/app-creation-store.d.ts +40 -0
  55. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  56. package/dist/server/lib/app-creation-store.js +245 -6
  57. package/dist/server/lib/app-creation-store.js.map +1 -1
  58. package/dist/server/lib/thread-debug-store.d.ts +101 -0
  59. package/dist/server/lib/thread-debug-store.d.ts.map +1 -0
  60. package/dist/server/lib/thread-debug-store.js +587 -0
  61. package/dist/server/lib/thread-debug-store.js.map +1 -0
  62. package/package.json +2 -2
  63. package/src/actions/archive-workspace-app.ts +16 -0
  64. package/src/actions/get-agent-thread-debug.ts +25 -0
  65. package/src/actions/index.ts +12 -0
  66. package/src/actions/list-agent-thread-sources.ts +12 -0
  67. package/src/actions/list-available-workspace-templates.ts +11 -0
  68. package/src/actions/navigate.ts +1 -1
  69. package/src/actions/remove-pending-workspace-app.ts +16 -0
  70. package/src/actions/scaffold-workspace-app.ts +34 -0
  71. package/src/actions/search-agent-threads.ts +30 -0
  72. package/src/actions/unarchive-workspace-app.ts +16 -0
  73. package/src/actions/view-screen.ts +41 -0
  74. package/src/components/layout/Layout.tsx +8 -0
  75. package/src/hooks/use-navigation-state.ts +4 -0
  76. package/src/routes/index.ts +1 -0
  77. package/src/routes/pages/thread-debug.tsx +683 -0
  78. package/src/server/lib/app-creation-store.ts +312 -15
  79. package/src/server/lib/thread-debug-store.ts +779 -0
@@ -0,0 +1,683 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { agentNativePath, useActionQuery } from "@agent-native/core/client";
3
+ import {
4
+ IconDatabase,
5
+ IconFileSearch,
6
+ IconRefresh,
7
+ IconSearch,
8
+ } from "@tabler/icons-react";
9
+ import { DispatchShell } from "@/components/dispatch-shell";
10
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
11
+ import { Badge } from "@/components/ui/badge";
12
+ import { Button } from "@/components/ui/button";
13
+ import { Input } from "@/components/ui/input";
14
+ import {
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ } from "@/components/ui/select";
21
+ import { Skeleton } from "@/components/ui/skeleton";
22
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
23
+ import { cn } from "@/lib/utils";
24
+
25
+ export function meta() {
26
+ return [{ title: "Thread Debug — Dispatch" }];
27
+ }
28
+
29
+ interface ThreadDebugSource {
30
+ id: string;
31
+ label: string;
32
+ kind: "current" | "env" | "configured";
33
+ current: boolean;
34
+ connected: boolean;
35
+ databaseUrlEnv: string | null;
36
+ databaseAuthTokenEnv: string | null;
37
+ canInspectAll: boolean;
38
+ }
39
+
40
+ interface ThreadSearchResult {
41
+ id: string;
42
+ ownerEmail: string;
43
+ title: string;
44
+ preview: string;
45
+ messageCount: number;
46
+ createdAt: number;
47
+ updatedAt: number;
48
+ snippet: string;
49
+ }
50
+
51
+ interface ThreadMessage {
52
+ index: number;
53
+ id: string | null;
54
+ role: string;
55
+ createdAt: string | number | null;
56
+ status: unknown;
57
+ text: string;
58
+ contentParts: any[];
59
+ attachments: any[];
60
+ metadata: unknown;
61
+ }
62
+
63
+ interface ThreadRun {
64
+ id: string;
65
+ status: string;
66
+ abortReason: string | null;
67
+ startedAt: number;
68
+ completedAt: number | null;
69
+ heartbeatAt: number | null;
70
+ events: Array<{ seq: number; event: any; rawEventData: string }>;
71
+ }
72
+
73
+ interface ThreadDebugResponse {
74
+ source: {
75
+ id: string;
76
+ label: string;
77
+ kind: string;
78
+ databaseUrlEnv: string | null;
79
+ };
80
+ access: { viewerEmail: string; scope: string; canInspectAll: boolean };
81
+ thread: ThreadSearchResult;
82
+ messages: ThreadMessage[];
83
+ debug: any;
84
+ debugRuns: any[];
85
+ queuedMessages: any[];
86
+ threadData: any;
87
+ rawThreadData: string;
88
+ runs: ThreadRun[];
89
+ traces: { summaries: any[]; spans: any[] };
90
+ feedback: any[];
91
+ satisfaction: any[];
92
+ evals: any[];
93
+ checkpoints: any[];
94
+ }
95
+
96
+ function formatDate(value: number | string | null | undefined): string {
97
+ if (value == null || value === "") return "n/a";
98
+ const numeric = Number(value);
99
+ const date = Number.isFinite(numeric) ? new Date(numeric) : new Date(value);
100
+ if (Number.isNaN(date.getTime())) return "n/a";
101
+ return date.toLocaleString();
102
+ }
103
+
104
+ function json(value: unknown): string {
105
+ try {
106
+ return JSON.stringify(value, null, 2);
107
+ } catch {
108
+ return String(value);
109
+ }
110
+ }
111
+
112
+ function eventLabel(event: any): string {
113
+ if (!event || typeof event !== "object") return "event";
114
+ if (event.type === "tool_start") return `tool_start · ${event.tool}`;
115
+ if (event.type === "tool_done") return `tool_done · ${event.tool}`;
116
+ if (event.type === "text") return "text";
117
+ if (event.type === "error") return `error · ${event.errorCode ?? "agent"}`;
118
+ return String(event.type ?? "event");
119
+ }
120
+
121
+ function messageTitle(message: ThreadMessage): string {
122
+ const role = message.role || "unknown";
123
+ return `${role.charAt(0).toUpperCase()}${role.slice(1)} ${message.index + 1}`;
124
+ }
125
+
126
+ function toolParts(message: ThreadMessage): any[] {
127
+ return message.contentParts.filter((part) => part?.type === "tool-call");
128
+ }
129
+
130
+ function RawBlock({
131
+ value,
132
+ className,
133
+ }: {
134
+ value: unknown;
135
+ className?: string;
136
+ }) {
137
+ return (
138
+ <pre
139
+ className={cn(
140
+ "max-h-[520px] overflow-auto rounded-lg border bg-muted/30 p-3 text-xs leading-relaxed text-foreground",
141
+ "whitespace-pre-wrap break-words",
142
+ className,
143
+ )}
144
+ >
145
+ {typeof value === "string" ? value : json(value)}
146
+ </pre>
147
+ );
148
+ }
149
+
150
+ function SourceBadge({ source }: { source: ThreadDebugSource }) {
151
+ return (
152
+ <Badge variant={source.current ? "default" : "secondary"}>
153
+ {source.current ? "current" : source.kind}
154
+ </Badge>
155
+ );
156
+ }
157
+
158
+ function ResultCard({
159
+ result,
160
+ selected,
161
+ onSelect,
162
+ }: {
163
+ result: ThreadSearchResult;
164
+ selected: boolean;
165
+ onSelect: () => void;
166
+ }) {
167
+ return (
168
+ <button
169
+ type="button"
170
+ onClick={onSelect}
171
+ className={cn(
172
+ "w-full rounded-lg border px-3 py-3 text-left transition-colors",
173
+ selected
174
+ ? "border-foreground bg-muted"
175
+ : "bg-card hover:border-foreground/30 hover:bg-muted/40",
176
+ )}
177
+ >
178
+ <div className="flex items-start justify-between gap-3">
179
+ <div className="min-w-0">
180
+ <div className="truncate text-sm font-medium text-foreground">
181
+ {result.title || result.preview || result.id}
182
+ </div>
183
+ <div className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
184
+ {result.id}
185
+ </div>
186
+ </div>
187
+ <Badge variant="outline" className="shrink-0">
188
+ {result.messageCount}
189
+ </Badge>
190
+ </div>
191
+ <div className="mt-2 line-clamp-3 text-xs leading-relaxed text-muted-foreground">
192
+ {result.snippet || result.preview || "No preview"}
193
+ </div>
194
+ <div className="mt-2 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
195
+ <span className="truncate">{result.ownerEmail}</span>
196
+ <span className="shrink-0">{formatDate(result.updatedAt)}</span>
197
+ </div>
198
+ </button>
199
+ );
200
+ }
201
+
202
+ function MessageBlock({ message }: { message: ThreadMessage }) {
203
+ const tools = toolParts(message);
204
+ return (
205
+ <div className="rounded-lg border bg-card">
206
+ <div className="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
207
+ <div className="flex min-w-0 items-center gap-2">
208
+ <Badge
209
+ variant={message.role === "assistant" ? "default" : "secondary"}
210
+ >
211
+ {message.role}
212
+ </Badge>
213
+ <span className="truncate text-sm font-medium text-foreground">
214
+ {messageTitle(message)}
215
+ </span>
216
+ </div>
217
+ <div className="flex items-center gap-2 text-[11px] text-muted-foreground">
218
+ {message.attachments.length > 0 ? (
219
+ <Badge variant="outline">{message.attachments.length} files</Badge>
220
+ ) : null}
221
+ <span>{formatDate(message.createdAt)}</span>
222
+ </div>
223
+ </div>
224
+ <div className="space-y-3 px-3 py-3">
225
+ {message.text ? (
226
+ <div className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground">
227
+ {message.text}
228
+ </div>
229
+ ) : (
230
+ <div className="text-sm text-muted-foreground">No text content</div>
231
+ )}
232
+ {tools.length > 0 ? (
233
+ <div className="space-y-2">
234
+ {tools.map((tool, index) => (
235
+ <details
236
+ key={`${message.id ?? message.index}-tool-${index}`}
237
+ className="rounded-md border bg-muted/30 px-3 py-2"
238
+ >
239
+ <summary className="cursor-pointer text-xs font-medium text-foreground">
240
+ {tool.toolName ?? tool.name ?? "tool-call"}
241
+ </summary>
242
+ <RawBlock value={tool} className="mt-2 max-h-72" />
243
+ </details>
244
+ ))}
245
+ </div>
246
+ ) : null}
247
+ </div>
248
+ </div>
249
+ );
250
+ }
251
+
252
+ function ThreadDetail({ detail }: { detail: ThreadDebugResponse }) {
253
+ const rawBundle = useMemo(
254
+ () => ({
255
+ thread: detail.thread,
256
+ debug: detail.debug,
257
+ debugRuns: detail.debugRuns,
258
+ queuedMessages: detail.queuedMessages,
259
+ threadData: detail.threadData,
260
+ runs: detail.runs,
261
+ traces: detail.traces,
262
+ feedback: detail.feedback,
263
+ satisfaction: detail.satisfaction,
264
+ evals: detail.evals,
265
+ checkpoints: detail.checkpoints,
266
+ }),
267
+ [detail],
268
+ );
269
+
270
+ return (
271
+ <div className="rounded-lg border bg-card">
272
+ <div className="border-b px-4 py-3">
273
+ <div className="flex flex-wrap items-start justify-between gap-3">
274
+ <div className="min-w-0">
275
+ <div className="truncate text-base font-semibold text-foreground">
276
+ {detail.thread.title || detail.thread.preview || detail.thread.id}
277
+ </div>
278
+ <div className="mt-1 truncate font-mono text-xs text-muted-foreground">
279
+ {detail.thread.id}
280
+ </div>
281
+ </div>
282
+ <div className="flex flex-wrap items-center gap-2">
283
+ <Badge variant="secondary">{detail.messages.length} messages</Badge>
284
+ <Badge variant="secondary">{detail.runs.length} runs</Badge>
285
+ <Badge variant="outline">{detail.source.label}</Badge>
286
+ </div>
287
+ </div>
288
+ <div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
289
+ <div className="truncate">Owner: {detail.thread.ownerEmail}</div>
290
+ <div>Created: {formatDate(detail.thread.createdAt)}</div>
291
+ <div>Updated: {formatDate(detail.thread.updatedAt)}</div>
292
+ </div>
293
+ </div>
294
+
295
+ <Tabs defaultValue="transcript" className="p-4">
296
+ <TabsList>
297
+ <TabsTrigger value="transcript">Transcript</TabsTrigger>
298
+ <TabsTrigger value="runs">Runs</TabsTrigger>
299
+ <TabsTrigger value="internals">Internals</TabsTrigger>
300
+ <TabsTrigger value="raw">Raw</TabsTrigger>
301
+ </TabsList>
302
+
303
+ <TabsContent value="transcript" className="mt-4 space-y-3">
304
+ {detail.messages.length > 0 ? (
305
+ detail.messages.map((message) => (
306
+ <MessageBlock
307
+ key={message.id ?? `message-${message.index}`}
308
+ message={message}
309
+ />
310
+ ))
311
+ ) : (
312
+ <div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
313
+ No persisted messages.
314
+ </div>
315
+ )}
316
+ </TabsContent>
317
+
318
+ <TabsContent value="runs" className="mt-4 space-y-3">
319
+ {detail.runs.length > 0 ? (
320
+ detail.runs.map((run) => (
321
+ <details key={run.id} className="rounded-lg border bg-card">
322
+ <summary className="cursor-pointer px-4 py-3">
323
+ <div className="inline-flex flex-wrap items-center gap-2">
324
+ <Badge variant="outline">{run.status}</Badge>
325
+ <span className="font-mono text-xs text-foreground">
326
+ {run.id}
327
+ </span>
328
+ <span className="text-xs text-muted-foreground">
329
+ {formatDate(run.startedAt)}
330
+ </span>
331
+ </div>
332
+ </summary>
333
+ <div className="space-y-2 border-t px-4 py-3">
334
+ {run.events.map((event) => (
335
+ <details
336
+ key={`${run.id}-${event.seq}`}
337
+ className="rounded-md border bg-muted/30 px-3 py-2"
338
+ >
339
+ <summary className="cursor-pointer text-xs font-medium text-foreground">
340
+ #{event.seq} {eventLabel(event.event)}
341
+ </summary>
342
+ <RawBlock value={event.event} className="mt-2 max-h-72" />
343
+ </details>
344
+ ))}
345
+ {run.events.length === 0 ? (
346
+ <div className="text-sm text-muted-foreground">
347
+ No retained run events.
348
+ </div>
349
+ ) : null}
350
+ </div>
351
+ </details>
352
+ ))
353
+ ) : (
354
+ <div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
355
+ No retained runs.
356
+ </div>
357
+ )}
358
+ </TabsContent>
359
+
360
+ <TabsContent value="internals" className="mt-4 space-y-4">
361
+ <div className="grid gap-4 lg:grid-cols-2">
362
+ <div>
363
+ <div className="mb-2 text-sm font-medium text-foreground">
364
+ Debug Runs
365
+ </div>
366
+ <RawBlock
367
+ value={
368
+ detail.debugRuns.length > 0
369
+ ? detail.debugRuns
370
+ : (detail.debug ?? {})
371
+ }
372
+ />
373
+ </div>
374
+ <div>
375
+ <div className="mb-2 text-sm font-medium text-foreground">
376
+ Trace Summaries
377
+ </div>
378
+ <RawBlock value={detail.traces.summaries} />
379
+ </div>
380
+ <div>
381
+ <div className="mb-2 text-sm font-medium text-foreground">
382
+ Trace Spans
383
+ </div>
384
+ <RawBlock value={detail.traces.spans} />
385
+ </div>
386
+ <div>
387
+ <div className="mb-2 text-sm font-medium text-foreground">
388
+ Feedback And Evals
389
+ </div>
390
+ <RawBlock
391
+ value={{
392
+ feedback: detail.feedback,
393
+ satisfaction: detail.satisfaction,
394
+ evals: detail.evals,
395
+ checkpoints: detail.checkpoints,
396
+ }}
397
+ />
398
+ </div>
399
+ </div>
400
+ </TabsContent>
401
+
402
+ <TabsContent value="raw" className="mt-4 space-y-4">
403
+ <RawBlock value={rawBundle} />
404
+ <RawBlock value={detail.rawThreadData} />
405
+ </TabsContent>
406
+ </Tabs>
407
+ </div>
408
+ );
409
+ }
410
+
411
+ export default function ThreadDebugRoute() {
412
+ const [sourceId, setSourceId] = useState("current");
413
+ const [query, setQuery] = useState("");
414
+ const [ownerEmail, setOwnerEmail] = useState("");
415
+ const [threadId, setThreadId] = useState("");
416
+ const [submittedSearch, setSubmittedSearch] = useState({
417
+ sourceId: "current",
418
+ query: "",
419
+ ownerEmail: "",
420
+ });
421
+ const [selected, setSelected] = useState<{
422
+ sourceId: string;
423
+ threadId: string;
424
+ ownerEmail?: string;
425
+ } | null>(null);
426
+
427
+ const { data: sourcesData, isLoading: sourcesLoading } = useActionQuery<{
428
+ access: {
429
+ viewerEmail: string;
430
+ orgId: string | null;
431
+ role: string | null;
432
+ envAdmin: boolean;
433
+ canInspectAll: boolean;
434
+ memberCount: number;
435
+ };
436
+ sources: ThreadDebugSource[];
437
+ }>("list-agent-thread-sources", {});
438
+
439
+ const sources = sourcesData?.sources ?? [];
440
+ const searchParams = useMemo(
441
+ () => ({
442
+ sourceId: submittedSearch.sourceId,
443
+ query: submittedSearch.query || undefined,
444
+ ownerEmail: submittedSearch.ownerEmail || undefined,
445
+ limit: 25,
446
+ }),
447
+ [submittedSearch],
448
+ );
449
+ const {
450
+ data: searchData,
451
+ isLoading: searchLoading,
452
+ error: searchError,
453
+ refetch: refetchSearch,
454
+ } = useActionQuery<{
455
+ count: number;
456
+ threads: ThreadSearchResult[];
457
+ access: { scope: string; canInspectAll: boolean };
458
+ source: { id: string; label: string };
459
+ }>("search-agent-threads", searchParams);
460
+
461
+ const detailParams = useMemo(
462
+ () => ({
463
+ sourceId: selected?.sourceId ?? "current",
464
+ threadId: selected?.threadId ?? "",
465
+ ownerEmail: selected?.ownerEmail,
466
+ maxRuns: 20,
467
+ maxEvents: 800,
468
+ maxTraceSpans: 600,
469
+ }),
470
+ [selected],
471
+ );
472
+ const {
473
+ data: detail,
474
+ isLoading: detailLoading,
475
+ error: detailError,
476
+ } = useActionQuery<ThreadDebugResponse>(
477
+ "get-agent-thread-debug",
478
+ detailParams,
479
+ {
480
+ enabled: Boolean(selected?.threadId),
481
+ },
482
+ );
483
+
484
+ const selectedSource = sources.find((source) => source.id === sourceId);
485
+
486
+ useEffect(() => {
487
+ fetch(agentNativePath("/_agent-native/application-state/navigation"), {
488
+ method: "PUT",
489
+ keepalive: true,
490
+ headers: { "Content-Type": "application/json" },
491
+ body: JSON.stringify({
492
+ view: "thread-debug",
493
+ path:
494
+ typeof window === "undefined"
495
+ ? "/thread-debug"
496
+ : window.location.pathname,
497
+ sourceId,
498
+ query,
499
+ ownerEmail: ownerEmail.trim() || undefined,
500
+ threadId: selected?.threadId ?? (threadId.trim() || undefined),
501
+ }),
502
+ }).catch(() => {});
503
+ }, [ownerEmail, query, selected?.threadId, sourceId, threadId]);
504
+
505
+ return (
506
+ <DispatchShell
507
+ title="Thread Debug"
508
+ description="Inspect persisted agent chat threads, run events, and AI internals."
509
+ >
510
+ <div className="space-y-4">
511
+ <section className="rounded-lg border bg-card p-4">
512
+ <div className="grid gap-3 lg:grid-cols-[220px_1fr_260px_auto]">
513
+ <Select value={sourceId} onValueChange={setSourceId}>
514
+ <SelectTrigger>
515
+ <SelectValue placeholder="Source" />
516
+ </SelectTrigger>
517
+ <SelectContent>
518
+ {sources.map((source) => (
519
+ <SelectItem key={source.id} value={source.id}>
520
+ {source.label}
521
+ </SelectItem>
522
+ ))}
523
+ {sources.length === 0 ? (
524
+ <SelectItem value="current">Current Dispatch DB</SelectItem>
525
+ ) : null}
526
+ </SelectContent>
527
+ </Select>
528
+ <Input
529
+ value={query}
530
+ onChange={(event) => setQuery(event.target.value)}
531
+ placeholder="Search title, preview, messages, tools"
532
+ />
533
+ <Input
534
+ value={ownerEmail}
535
+ onChange={(event) => setOwnerEmail(event.target.value)}
536
+ placeholder="Owner email"
537
+ />
538
+ <Button
539
+ type="button"
540
+ onClick={() =>
541
+ setSubmittedSearch({
542
+ sourceId,
543
+ query: query.trim(),
544
+ ownerEmail: ownerEmail.trim(),
545
+ })
546
+ }
547
+ >
548
+ <IconSearch size={16} />
549
+ Search
550
+ </Button>
551
+ </div>
552
+
553
+ <div className="mt-3 grid gap-3 lg:grid-cols-[1fr_auto]">
554
+ <Input
555
+ value={threadId}
556
+ onChange={(event) => setThreadId(event.target.value)}
557
+ placeholder="Paste thread ID"
558
+ className="font-mono"
559
+ />
560
+ <Button
561
+ type="button"
562
+ variant="outline"
563
+ onClick={() => {
564
+ const trimmed = threadId.trim();
565
+ if (!trimmed) return;
566
+ setSelected({
567
+ sourceId,
568
+ threadId: trimmed,
569
+ ownerEmail: ownerEmail.trim() || undefined,
570
+ });
571
+ }}
572
+ >
573
+ <IconFileSearch size={16} />
574
+ Inspect
575
+ </Button>
576
+ </div>
577
+
578
+ <div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
579
+ {sourcesLoading ? <Skeleton className="h-5 w-32" /> : null}
580
+ {selectedSource ? <SourceBadge source={selectedSource} /> : null}
581
+ {selectedSource?.databaseUrlEnv ? (
582
+ <Badge variant="outline" className="font-mono">
583
+ {selectedSource.databaseUrlEnv}
584
+ </Badge>
585
+ ) : null}
586
+ {sourcesData?.access ? (
587
+ <span>
588
+ {sourcesData.access.viewerEmail} ·{" "}
589
+ {sourcesData.access.canInspectAll ? "admin scope" : "own scope"}
590
+ </span>
591
+ ) : null}
592
+ </div>
593
+ </section>
594
+
595
+ {searchError ? (
596
+ <Alert variant="destructive">
597
+ <AlertTitle>Search failed</AlertTitle>
598
+ <AlertDescription>{String(searchError.message)}</AlertDescription>
599
+ </Alert>
600
+ ) : null}
601
+
602
+ <div className="grid gap-4 xl:grid-cols-[380px_1fr]">
603
+ <section className="min-h-[520px] rounded-lg border bg-card">
604
+ <div className="flex items-center justify-between border-b px-4 py-3">
605
+ <div>
606
+ <div className="text-sm font-semibold text-foreground">
607
+ Threads
608
+ </div>
609
+ <div className="text-xs text-muted-foreground">
610
+ {searchData?.count ?? 0} results ·{" "}
611
+ {searchData?.access?.scope ?? "current scope"}
612
+ </div>
613
+ </div>
614
+ <Button
615
+ type="button"
616
+ variant="ghost"
617
+ size="icon"
618
+ onClick={() => refetchSearch()}
619
+ aria-label="Refresh threads"
620
+ >
621
+ <IconRefresh size={16} />
622
+ </Button>
623
+ </div>
624
+ <div className="max-h-[760px] space-y-2 overflow-auto p-3">
625
+ {searchLoading ? (
626
+ <>
627
+ <Skeleton className="h-28 w-full rounded-lg" />
628
+ <Skeleton className="h-28 w-full rounded-lg" />
629
+ <Skeleton className="h-28 w-full rounded-lg" />
630
+ </>
631
+ ) : null}
632
+ {!searchLoading && (searchData?.threads?.length ?? 0) === 0 ? (
633
+ <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">
634
+ <IconDatabase className="mb-2 h-5 w-5" />
635
+ No threads found.
636
+ </div>
637
+ ) : null}
638
+ {searchData?.threads?.map((result) => (
639
+ <ResultCard
640
+ key={result.id}
641
+ result={result}
642
+ selected={selected?.threadId === result.id}
643
+ onSelect={() =>
644
+ setSelected({
645
+ sourceId: submittedSearch.sourceId,
646
+ threadId: result.id,
647
+ ownerEmail: submittedSearch.ownerEmail || undefined,
648
+ })
649
+ }
650
+ />
651
+ ))}
652
+ </div>
653
+ </section>
654
+
655
+ <section className="min-w-0">
656
+ {detailError ? (
657
+ <Alert variant="destructive">
658
+ <AlertTitle>Thread lookup failed</AlertTitle>
659
+ <AlertDescription>
660
+ {String(detailError.message)}
661
+ </AlertDescription>
662
+ </Alert>
663
+ ) : null}
664
+ {detailLoading ? (
665
+ <div className="rounded-lg border bg-card p-4">
666
+ <Skeleton className="h-6 w-72" />
667
+ <Skeleton className="mt-3 h-4 w-96" />
668
+ <Skeleton className="mt-6 h-[520px] w-full" />
669
+ </div>
670
+ ) : detail ? (
671
+ <ThreadDetail detail={detail} />
672
+ ) : (
673
+ <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">
674
+ <IconFileSearch className="mb-2 h-5 w-5" />
675
+ Select or inspect a thread.
676
+ </div>
677
+ )}
678
+ </section>
679
+ </div>
680
+ </div>
681
+ </DispatchShell>
682
+ );
683
+ }