spiracha 2.0.0 → 2.2.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 (162) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +42 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +36 -11
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +51 -11
  7. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  8. package/apps/ui/src/components/app-shell.tsx +46 -3
  9. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  10. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  11. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  12. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  13. package/apps/ui/src/components/data-table.tsx +23 -5
  14. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  15. package/apps/ui/src/components/export-dialog.tsx +79 -74
  16. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  17. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  18. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  19. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  20. package/apps/ui/src/components/page-header.tsx +15 -9
  21. package/apps/ui/src/components/projects-table.tsx +4 -1
  22. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  23. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  24. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  25. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  26. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  27. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  28. package/apps/ui/src/components/threads-table.tsx +13 -47
  29. package/apps/ui/src/components/transcript-view.tsx +148 -52
  30. package/apps/ui/src/components/ui/select.tsx +1 -1
  31. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  32. package/apps/ui/src/lib/antigravity-server.ts +209 -11
  33. package/apps/ui/src/lib/antigravity-transcript-events.ts +57 -73
  34. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  35. package/apps/ui/src/lib/claude-code-server.ts +185 -7
  36. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  37. package/apps/ui/src/lib/codex-queries.ts +16 -0
  38. package/apps/ui/src/lib/codex-server.ts +134 -52
  39. package/apps/ui/src/lib/cursor-server.ts +68 -53
  40. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  41. package/apps/ui/src/lib/delete-batch.ts +52 -0
  42. package/apps/ui/src/lib/error-presentation.ts +43 -0
  43. package/apps/ui/src/lib/export-mutation.ts +20 -0
  44. package/apps/ui/src/lib/export-options.ts +15 -0
  45. package/apps/ui/src/lib/formatters.ts +3 -5
  46. package/apps/ui/src/lib/grok-queries.ts +22 -0
  47. package/apps/ui/src/lib/grok-server.ts +168 -0
  48. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  49. package/apps/ui/src/lib/kiro-server.ts +97 -7
  50. package/apps/ui/src/lib/mutation-error.ts +15 -0
  51. package/apps/ui/src/lib/opencode-server.ts +96 -7
  52. package/apps/ui/src/lib/path-utils.ts +1 -1
  53. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  54. package/apps/ui/src/lib/qoder-server.ts +67 -11
  55. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  56. package/apps/ui/src/lib/route-search.ts +114 -0
  57. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  58. package/apps/ui/src/lib/settings-server.ts +33 -0
  59. package/apps/ui/src/lib/settings-store.tsx +82 -38
  60. package/apps/ui/src/lib/settings.ts +65 -0
  61. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  62. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  63. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  64. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  65. package/apps/ui/src/lib/workspace-delete-navigation.ts +20 -0
  66. package/apps/ui/src/routeTree.gen.ts +341 -234
  67. package/apps/ui/src/routes/__root.tsx +12 -15
  68. package/apps/ui/src/routes/analytics.tsx +10 -61
  69. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +149 -29
  70. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +262 -42
  71. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  72. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  73. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  74. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  75. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +168 -28
  76. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +179 -45
  77. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  78. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  79. package/apps/ui/src/routes/codex.index.tsx +5 -10
  80. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +31 -18
  81. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +44 -28
  82. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  83. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +401 -0
  84. package/apps/ui/src/routes/grok.$workspaceKey.tsx +295 -0
  85. package/apps/ui/src/routes/grok.index.tsx +48 -0
  86. package/apps/ui/src/routes/index.tsx +2 -18
  87. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +91 -27
  88. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +213 -51
  89. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  90. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +108 -29
  91. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +231 -55
  92. package/apps/ui/src/routes/opencode.index.tsx +6 -2
  93. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +19 -15
  94. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +89 -48
  95. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  96. package/apps/ui/src/routes/threads.$threadId.tsx +620 -105
  97. package/apps/ui/vite.config.ts +22 -12
  98. package/bin/spiracha.ts +28 -1
  99. package/package.json +37 -27
  100. package/src/client.ts +183 -5
  101. package/src/lib/antigravity-db.ts +424 -94
  102. package/src/lib/antigravity-exporter-types.ts +3 -0
  103. package/src/lib/antigravity-keychain.ts +7 -8
  104. package/src/lib/antigravity-projects.ts +201 -0
  105. package/src/lib/antigravity-transcript-phase.ts +47 -0
  106. package/src/lib/claude-code-db.ts +534 -42
  107. package/src/lib/claude-code-exporter-types.ts +5 -0
  108. package/src/lib/claude-code-transcript-phase.ts +60 -1
  109. package/src/lib/claude-code-transcript.ts +8 -5
  110. package/src/lib/codex-analytics.ts +32 -5
  111. package/src/lib/codex-browser-db.ts +540 -137
  112. package/src/lib/codex-browser-export.ts +18 -27
  113. package/src/lib/codex-browser-types.ts +21 -1
  114. package/src/lib/codex-thread-cache.ts +106 -23
  115. package/src/lib/codex-thread-parser.ts +149 -55
  116. package/src/lib/codex-thread-recovery.ts +38 -11
  117. package/src/lib/codex-transcript-filter.ts +31 -0
  118. package/src/lib/codex-transcript-renderer.ts +87 -79
  119. package/src/lib/concurrency.ts +56 -3
  120. package/src/lib/conversation-api.ts +410 -27
  121. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  122. package/src/lib/conversation-data/antigravity-adapter.ts +70 -26
  123. package/src/lib/conversation-data/claude-code-adapter.ts +69 -12
  124. package/src/lib/conversation-data/codex-adapter.ts +108 -73
  125. package/src/lib/conversation-data/cursor-adapter.ts +77 -29
  126. package/src/lib/conversation-data/grok-adapter.ts +223 -0
  127. package/src/lib/conversation-data/index.ts +105 -11
  128. package/src/lib/conversation-data/kiro-adapter.ts +59 -12
  129. package/src/lib/conversation-data/message-selector.ts +2 -5
  130. package/src/lib/conversation-data/opencode-adapter.ts +67 -17
  131. package/src/lib/conversation-data/path-match.ts +1 -13
  132. package/src/lib/conversation-data/qoder-adapter.ts +67 -37
  133. package/src/lib/conversation-data/types.ts +43 -0
  134. package/src/lib/conversation-zip-export.ts +75 -0
  135. package/src/lib/cursor-db.ts +68 -28
  136. package/src/lib/cursor-id.ts +17 -0
  137. package/src/lib/cursor-recovery.ts +146 -35
  138. package/src/lib/cursor-transcript-phase.ts +40 -0
  139. package/src/lib/cursor-transcript.ts +21 -5
  140. package/src/lib/grok-db.ts +1068 -0
  141. package/src/lib/grok-exporter-types.ts +110 -0
  142. package/src/lib/grok-transcript-phase.ts +61 -0
  143. package/src/lib/grok-transcript.ts +167 -0
  144. package/src/lib/kiro-db.ts +187 -43
  145. package/src/lib/kiro-transcript.ts +0 -4
  146. package/src/lib/model-label.ts +11 -3
  147. package/src/lib/opencode-db.ts +599 -60
  148. package/src/lib/opencode-think-tags.ts +17 -3
  149. package/src/lib/opencode-transcript.ts +8 -5
  150. package/src/lib/portable-path.ts +9 -0
  151. package/src/lib/qoder-acp-client.ts +11 -2
  152. package/src/lib/qoder-db.ts +51 -17
  153. package/src/lib/qoder-transcript-phase.ts +64 -0
  154. package/src/lib/qoder-transcript.ts +30 -2
  155. package/src/lib/shared.ts +63 -15
  156. package/src/lib/sqlite-error.ts +31 -5
  157. package/src/lib/transcript-load-limiter.ts +87 -0
  158. package/src/lib/ui-cache.ts +185 -24
  159. package/src/lib/ui-export-archive.ts +58 -39
  160. package/src/lib/ui-export-files.ts +75 -15
  161. package/src/lib/ui-export-zip.ts +40 -0
  162. package/apps/ui/package.json +0 -65
@@ -1,7 +1,14 @@
1
+ import type {
2
+ DynamicToolDefinition,
3
+ MessageEvent,
4
+ ParsedCodexTranscript,
5
+ ThreadEvent,
6
+ } from '@spiracha/lib/codex-browser-types';
1
7
  import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
2
8
  import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
3
- import { Download, Trash2 } from 'lucide-react';
4
- import { useState } from 'react';
9
+ import { ChevronDown, ChevronUp, Download, Search, Trash2 } from 'lucide-react';
10
+ import type { KeyboardEvent } from 'react';
11
+ import { startTransition, useEffect, useMemo, useState } from 'react';
5
12
  import { Breadcrumbs } from '#/components/breadcrumbs';
6
13
  import { DeleteConfirmDialog } from '#/components/delete-confirm-dialog';
7
14
  import { ExportDialog } from '#/components/export-dialog';
@@ -10,18 +17,168 @@ import { LoadingPanel } from '#/components/loading-panel';
10
17
  import { MetadataSection } from '#/components/metadata-section';
11
18
  import { MetricCard } from '#/components/metric-card';
12
19
  import { PageHeader } from '#/components/page-header';
13
- import { TranscriptView } from '#/components/transcript-view';
20
+ import { RouteErrorPanel } from '#/components/route-error-panel';
21
+ import { ThreadGoalsPanel } from '#/components/thread-goals-panel';
22
+ import { ThreadToolsPanel } from '#/components/thread-tools-panel';
23
+ import {
24
+ getTranscriptEventKey,
25
+ shouldShowEvent,
26
+ type TranscriptSortOrder,
27
+ TranscriptView,
28
+ } from '#/components/transcript-view';
14
29
  import { Badge } from '#/components/ui/badge';
15
30
  import { Button } from '#/components/ui/button';
16
31
  import { Checkbox } from '#/components/ui/checkbox';
32
+ import { Input } from '#/components/ui/input';
17
33
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
18
- import { threadSnapshotQueryOptions, threadTranscriptQueryOptions } from '#/lib/codex-queries';
34
+ import {
35
+ threadSnapshotQueryOptions,
36
+ threadTranscriptPreviewQueryOptions,
37
+ threadTranscriptQueryOptions,
38
+ } from '#/lib/codex-queries';
19
39
  import { deleteThreadFn, exportThreadFn, type getThreadSnapshotFn } from '#/lib/codex-server';
20
40
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
21
- import { formatBooleanLabel, formatBytes, formatDateTime, formatList, formatTokens } from '#/lib/formatters';
41
+ import {
42
+ formatBooleanLabel,
43
+ formatBytes,
44
+ formatDateTime,
45
+ formatList,
46
+ formatModelLabel,
47
+ formatTokens,
48
+ } from '#/lib/formatters';
49
+ import { getMutationErrorMessage } from '#/lib/mutation-error';
50
+ import { applyPathTransforms } from '#/lib/path-utils';
51
+ import {
52
+ parseThreadTranscriptSearch,
53
+ type ThreadTranscriptSearch,
54
+ withThreadTranscriptSearch,
55
+ } from '#/lib/route-search';
56
+ import { RouteStateResetBoundary } from '#/lib/route-state-reset';
22
57
  import { useSettings } from '#/lib/settings-store';
58
+ import { formatSandboxPolicy } from '#/lib/thread-metadata';
59
+ import { shouldLoadFullThreadTranscript } from '#/lib/thread-transcript-load';
23
60
 
24
- type ThreadSnapshot = Awaited<ReturnType<typeof getThreadSnapshotFn>>;
61
+ type ThreadSnapshotResponse = Awaited<ReturnType<typeof getThreadSnapshotFn>>;
62
+ type ThreadTranscript = ParsedCodexTranscript;
63
+ type ThreadSnapshot = Omit<ThreadSnapshotResponse, 'availableTools' | 'transcript'> & {
64
+ availableTools: DynamicToolDefinition[];
65
+ transcript: ThreadTranscript | null;
66
+ };
67
+
68
+ type TranscriptSearchResult = {
69
+ event: MessageEvent;
70
+ eventIndex: number;
71
+ eventKey: string;
72
+ messageNumber: number;
73
+ phase: string | null;
74
+ roleLabel: string;
75
+ snippet: string;
76
+ };
77
+
78
+ type TranscriptSearchPanelProps = {
79
+ activeResultIndex: number;
80
+ query: string;
81
+ results: TranscriptSearchResult[];
82
+ onJumpToResult: (index: number) => void;
83
+ onQueryChange: (value: string) => void;
84
+ };
85
+
86
+ type TranscriptSearchFilters = {
87
+ showCommentary: boolean;
88
+ showExtraEvents: boolean;
89
+ showToolCalls: boolean;
90
+ showUserMessages: boolean;
91
+ };
92
+
93
+ const SEARCH_SNIPPET_RADIUS = 72;
94
+
95
+ const getTranscriptFiltersFromSearch = (search: ThreadTranscriptSearch) => ({
96
+ showCommentary: search.commentary === true,
97
+ showExtraEvents: search.extra === true,
98
+ showToolCalls: search.tools === true,
99
+ showUserMessages: search.user === true,
100
+ });
101
+
102
+ type ThreadTranscriptFilters = ReturnType<typeof getTranscriptFiltersFromSearch>;
103
+
104
+ const normalizeTranscriptSearchText = (value: string) => value.replace(/\s+/gu, ' ').trim();
105
+
106
+ const getTranscriptSearchRoleLabel = (event: MessageEvent, assistantModel: string | null) => {
107
+ if (event.role === 'assistant') {
108
+ return formatModelLabel(event.model ?? assistantModel);
109
+ }
110
+
111
+ return event.role === 'system' ? 'System' : 'User';
112
+ };
113
+
114
+ const buildTranscriptSearchSnippet = (text: string, query: string) => {
115
+ const normalizedText = normalizeTranscriptSearchText(text);
116
+ const normalizedQuery = normalizeTranscriptSearchText(query).toLowerCase();
117
+ const matchIndex = normalizedText.toLowerCase().indexOf(normalizedQuery);
118
+
119
+ if (matchIndex < 0) {
120
+ return normalizedText.slice(0, SEARCH_SNIPPET_RADIUS * 2);
121
+ }
122
+
123
+ const start = Math.max(0, matchIndex - SEARCH_SNIPPET_RADIUS);
124
+ const end = Math.min(normalizedText.length, matchIndex + normalizedQuery.length + SEARCH_SNIPPET_RADIUS);
125
+ const prefix = start > 0 ? '...' : '';
126
+ const suffix = end < normalizedText.length ? '...' : '';
127
+
128
+ return `${prefix}${normalizedText.slice(start, end)}${suffix}`;
129
+ };
130
+
131
+ const buildTranscriptSearchResults = (
132
+ events: ThreadEvent[],
133
+ query: string,
134
+ assistantModel: string | null,
135
+ filters: TranscriptSearchFilters,
136
+ transform: (text: string) => string,
137
+ ): TranscriptSearchResult[] => {
138
+ const normalizedQuery = normalizeTranscriptSearchText(query).toLowerCase();
139
+ if (!normalizedQuery) {
140
+ return [];
141
+ }
142
+
143
+ const results: TranscriptSearchResult[] = [];
144
+ let messageNumber = 0;
145
+
146
+ events.forEach((event, index) => {
147
+ if (event.kind !== 'message') {
148
+ return;
149
+ }
150
+
151
+ if (
152
+ !shouldShowEvent(
153
+ event,
154
+ filters.showToolCalls,
155
+ filters.showExtraEvents,
156
+ filters.showCommentary,
157
+ filters.showUserMessages,
158
+ )
159
+ ) {
160
+ return;
161
+ }
162
+
163
+ messageNumber += 1;
164
+ const searchText = normalizeTranscriptSearchText(transform(event.text));
165
+ if (!searchText.toLowerCase().includes(normalizedQuery)) {
166
+ return;
167
+ }
168
+
169
+ results.push({
170
+ event,
171
+ eventIndex: index,
172
+ eventKey: getTranscriptEventKey(event, index),
173
+ messageNumber,
174
+ phase: event.phase,
175
+ roleLabel: getTranscriptSearchRoleLabel(event, assistantModel),
176
+ snippet: buildTranscriptSearchSnippet(searchText, query),
177
+ });
178
+ });
179
+
180
+ return results;
181
+ };
25
182
 
26
183
  type TranscriptControlsProps = {
27
184
  rawJsonDisabled?: boolean;
@@ -91,6 +248,7 @@ const buildRuntimeItems = (snapshot: ThreadSnapshot) => {
91
248
  { label: 'Reasoning effort', value: snapshot.thread.reasoning_effort ?? 'n/a' },
92
249
  { label: 'CLI version', value: snapshot.thread.cli_version },
93
250
  { label: 'Approval mode', value: snapshot.thread.approval_mode },
251
+ { label: 'Sandbox policy', value: formatSandboxPolicy(snapshot.thread.sandbox_policy) },
94
252
  { label: 'Memory mode', value: snapshot.thread.memory_mode },
95
253
  { label: 'Has user event', value: formatBooleanLabel(Boolean(snapshot.thread.has_user_event)) },
96
254
  ];
@@ -162,7 +320,7 @@ const buildTranscriptStatsItems = (snapshot: ThreadSnapshot) => {
162
320
  return [
163
321
  { label: 'Transcript load', value: 'Deferred for oversized rollout' },
164
322
  { label: 'Rollout size', value: formatBytes(snapshot.rollout.fileSizeBytes) },
165
- { label: 'Preview mode', value: 'Load the transcript manually to inspect it.' },
323
+ { label: 'Deferred load', value: 'Load the transcript manually to inspect it.' },
166
324
  ];
167
325
  }
168
326
 
@@ -240,6 +398,98 @@ function TranscriptControls({
240
398
  );
241
399
  }
242
400
 
401
+ function TranscriptSearchPanel({
402
+ activeResultIndex,
403
+ query,
404
+ results,
405
+ onJumpToResult,
406
+ onQueryChange,
407
+ }: TranscriptSearchPanelProps) {
408
+ const hasQuery = normalizeTranscriptSearchText(query).length > 0;
409
+ const hasResults = results.length > 0;
410
+ const statusLabel = hasQuery
411
+ ? hasResults
412
+ ? `${activeResultIndex + 1} / ${results.length}`
413
+ : 'No matches'
414
+ : 'Search';
415
+
416
+ const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
417
+ if (event.key !== 'Enter' || !hasResults) {
418
+ return;
419
+ }
420
+
421
+ event.preventDefault();
422
+ onJumpToResult(event.shiftKey ? activeResultIndex - 1 : activeResultIndex);
423
+ };
424
+
425
+ return (
426
+ <section className="rounded-xl border border-[var(--border)] bg-[var(--panel)] px-4 py-3 shadow-[var(--panel-shadow)]">
427
+ <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
428
+ <div className="relative min-w-0 flex-1">
429
+ <Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[var(--muted-foreground)]" />
430
+ <Input
431
+ aria-label="Search transcript messages"
432
+ className="h-10 rounded-full border-[var(--border)] bg-[var(--panel-secondary)] pr-4 pl-9"
433
+ placeholder="Search transcript messages"
434
+ value={query}
435
+ onChange={(event) => onQueryChange(event.target.value)}
436
+ onKeyDown={handleKeyDown}
437
+ />
438
+ </div>
439
+
440
+ <div className="flex flex-wrap items-center gap-2">
441
+ <span className="min-w-20 text-right text-[var(--muted-foreground)] text-sm">{statusLabel}</span>
442
+ <Button
443
+ className="rounded-full"
444
+ disabled={!hasResults}
445
+ size="sm"
446
+ type="button"
447
+ variant="outline"
448
+ onClick={() => onJumpToResult(activeResultIndex - 1)}
449
+ >
450
+ <ChevronUp className="size-4" />
451
+ Prev
452
+ </Button>
453
+ <Button
454
+ className="rounded-full"
455
+ disabled={!hasResults}
456
+ size="sm"
457
+ type="button"
458
+ variant="outline"
459
+ onClick={() => onJumpToResult(activeResultIndex + 1)}
460
+ >
461
+ <ChevronDown className="size-4" />
462
+ Next
463
+ </Button>
464
+ </div>
465
+ </div>
466
+
467
+ {hasQuery && hasResults ? (
468
+ <div className="mt-3 max-h-72 overflow-auto rounded-xl border border-[var(--border)] bg-[var(--panel-secondary)]">
469
+ {results.map((result, index) => (
470
+ <button
471
+ key={result.eventKey}
472
+ aria-current={index === activeResultIndex ? 'true' : undefined}
473
+ className="block w-full border-[var(--border)] border-b px-3 py-2.5 text-left transition last:border-b-0 hover:bg-[var(--panel)] aria-current:bg-[var(--panel)]"
474
+ type="button"
475
+ onClick={() => onJumpToResult(index)}
476
+ >
477
+ <span className="flex flex-wrap items-center gap-2">
478
+ <span className="font-medium text-sm">Message {result.messageNumber}</span>
479
+ <Badge variant="outline">{result.roleLabel}</Badge>
480
+ {result.phase ? <Badge variant="outline">{result.phase}</Badge> : null}
481
+ </span>
482
+ <span className="mt-1 block min-w-0 break-words text-[var(--muted-foreground)] text-sm leading-5 [overflow-wrap:anywhere]">
483
+ {result.snippet}
484
+ </span>
485
+ </button>
486
+ ))}
487
+ </div>
488
+ ) : null}
489
+ </section>
490
+ );
491
+ }
492
+
243
493
  function ThreadMetadataPanels({ snapshot }: ThreadMetadataProps) {
244
494
  return (
245
495
  <div className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
@@ -251,29 +501,7 @@ function ThreadMetadataPanels({ snapshot }: ThreadMetadataProps) {
251
501
 
252
502
  <div className="space-y-4">
253
503
  <MetadataSection items={buildRelationItems(snapshot)} title="Relations and summary" />
254
-
255
- <section className="rounded-[1.6rem] border border-[var(--border)] bg-[var(--panel)] p-5 shadow-[var(--panel-shadow)]">
256
- <h3 className="font-semibold text-[var(--muted-foreground)] text-sm uppercase tracking-[0.18em]">
257
- Available tools
258
- </h3>
259
- <div className="mt-4 space-y-3">
260
- {snapshot.availableTools.map((tool) => (
261
- <div
262
- key={`${tool.name}-${tool.namespace ?? 'global'}`}
263
- className="rounded-xl border border-[var(--border)] bg-[var(--panel-secondary)] p-3.5"
264
- >
265
- <div className="flex flex-wrap items-center gap-2">
266
- <p className="font-medium font-mono text-sm">{tool.name}</p>
267
- {tool.namespace ? <Badge variant="outline">{tool.namespace}</Badge> : null}
268
- </div>
269
- <p className="mt-1.5 text-[var(--muted-foreground)] text-sm">
270
- {tool.description || 'No description.'}
271
- </p>
272
- </div>
273
- ))}
274
- </div>
275
- </section>
276
-
504
+ <ThreadGoalsPanel goals={snapshot.goals} />
277
505
  <MetadataSection items={buildTranscriptStatsItems(snapshot)} title="Transcript stats" />
278
506
  </div>
279
507
  </div>
@@ -319,12 +547,12 @@ function DeferredTranscriptNotice({
319
547
  <p className="mt-2 text-[var(--muted-foreground)] text-sm leading-6">
320
548
  {missing
321
549
  ? 'The rollout JSONL referenced by this thread is no longer present on disk. Export may still work if the file is restored, but transcript browsing is unavailable right now.'
322
- : `Spiracha skipped loading the transcript automatically because the rollout file is ${formatBytes(fileSizeBytes)}. Export still works immediately. If you need to inspect the thread here, load a bounded preview manually.`}
550
+ : `Spiracha skipped loading the transcript automatically because the rollout file is ${formatBytes(fileSizeBytes)}. Export still works immediately. Load the full transcript when you need to inspect it here.`}
323
551
  </p>
324
552
  {missing ? null : (
325
553
  <div className="mt-4">
326
554
  <Button disabled={pending} variant="outline" onClick={onLoad}>
327
- {pending ? 'Loading preview...' : 'Load transcript preview'}
555
+ {pending ? 'Loading full thread...' : 'Load Full Thread'}
328
556
  </Button>
329
557
  </div>
330
558
  )}
@@ -332,27 +560,43 @@ function DeferredTranscriptNotice({
332
560
  );
333
561
  }
334
562
 
335
- function ThreadErrorComponent({ error }: { error: Error }) {
336
- const isSqlite = error.message.includes('unable to open database') || error.message.includes('database is locked');
563
+ function LargeThreadPreviewNotice({
564
+ fileSizeBytes,
565
+ fullTranscriptLoaded,
566
+ pending,
567
+ onLoad,
568
+ }: {
569
+ fileSizeBytes: number | null;
570
+ fullTranscriptLoaded: boolean;
571
+ pending: boolean;
572
+ onLoad: () => void;
573
+ }) {
574
+ const buttonLabel = fullTranscriptLoaded
575
+ ? 'Full thread loaded'
576
+ : pending
577
+ ? 'Loading full thread...'
578
+ : 'Load Full Thread';
579
+
337
580
  return (
338
- <div className="rounded-xl border border-[var(--border)] bg-[var(--panel)] px-6 py-10 text-center">
339
- <p className="font-medium text-[var(--destructive)] text-sm">
340
- {isSqlite ? 'Database unavailable' : 'Failed to load thread'}
341
- </p>
342
- <p className="mt-2 text-[var(--muted-foreground)] text-sm">
343
- {isSqlite ? 'Codex may have an exclusive lock on the database. Reload to retry.' : error.message}
581
+ <section className="rounded-[1.6rem] border border-[var(--border)] bg-[var(--panel)] p-5 shadow-[var(--panel-shadow)]">
582
+ <h3 className="font-semibold text-base">Showing the latest matching messages</h3>
583
+ <p className="mt-2 text-[var(--muted-foreground)] text-sm leading-6">
584
+ This rollout is {formatBytes(fileSizeBytes)}, so Spiracha loaded a small window from the end of the
585
+ thread using the current transcript filters. Load the full thread when you need earlier messages.
344
586
  </p>
345
- <button
346
- className="mt-4 text-[var(--accent)] text-sm underline-offset-2 hover:underline"
347
- type="button"
348
- onClick={() => window.location.reload()}
349
- >
350
- Reload
351
- </button>
352
- </div>
587
+ <div className="mt-4">
588
+ <Button disabled={pending || fullTranscriptLoaded} variant="outline" onClick={onLoad}>
589
+ {buttonLabel}
590
+ </Button>
591
+ </div>
592
+ </section>
353
593
  );
354
594
  }
355
595
 
596
+ function ThreadErrorComponent({ error }: { error: Error }) {
597
+ return <RouteErrorPanel error={error} title="Failed to load thread" />;
598
+ }
599
+
356
600
  const getThreadExportErrorMessage = (transcriptMissing: boolean, error: unknown): string | null => {
357
601
  if (transcriptMissing) {
358
602
  return 'The rollout JSONL file is missing from disk, so this thread cannot be exported right now.';
@@ -371,10 +615,199 @@ export const Route = createFileRoute('/threads/$threadId')({
371
615
  title="Loading thread"
372
616
  />
373
617
  ),
618
+ validateSearch: parseThreadTranscriptSearch,
374
619
  });
375
620
 
376
- function ThreadDetailPage() {
377
- const navigate = useNavigate();
621
+ const useThreadTranscriptRouteSearch = () => {
622
+ const navigate = useNavigate({ from: Route.fullPath });
623
+ const search = Route.useSearch();
624
+ const transcriptFilters = useMemo(() => getTranscriptFiltersFromSearch(search), [search]);
625
+
626
+ const updateTranscriptSearch = (patch: Partial<ThreadTranscriptSearch>) => {
627
+ startTransition(() => {
628
+ void navigate({
629
+ params: true,
630
+ replace: true,
631
+ search: (previous: Record<string, unknown>) => withThreadTranscriptSearch(previous, patch),
632
+ });
633
+ });
634
+ };
635
+
636
+ return {
637
+ navigate,
638
+ search,
639
+ transcriptFilters,
640
+ updateTranscriptSearch,
641
+ };
642
+ };
643
+
644
+ const useTranscriptSearchResults = ({
645
+ assistantModel,
646
+ filters,
647
+ projectPath,
648
+ query,
649
+ settings,
650
+ transcript,
651
+ }: {
652
+ assistantModel: string | null;
653
+ filters: TranscriptSearchFilters;
654
+ projectPath: string;
655
+ query: string;
656
+ settings: ReturnType<typeof useSettings>['settings'];
657
+ transcript: ThreadTranscript | null;
658
+ }) =>
659
+ useMemo(
660
+ () =>
661
+ transcript
662
+ ? buildTranscriptSearchResults(transcript.events, query, assistantModel, filters, (text) =>
663
+ applyPathTransforms(text, settings, projectPath),
664
+ )
665
+ : [],
666
+ [assistantModel, filters, projectPath, query, settings, transcript],
667
+ );
668
+
669
+ function ThreadTranscriptTab({
670
+ activeEventJumpSignal,
671
+ activeSearchResultIndex,
672
+ activeTranscriptEventKey,
673
+ fullTranscriptLoaded,
674
+ loadError,
675
+ loadingFullTranscript,
676
+ searchInput,
677
+ searchResults,
678
+ showRawJson,
679
+ sortOrder,
680
+ snapshot,
681
+ transcript,
682
+ transcriptFilters,
683
+ onJumpToSearchResult,
684
+ onLoadFullThread,
685
+ onSearchInputChange,
686
+ onSortOrderChange,
687
+ onTranscriptFilterChange,
688
+ }: {
689
+ activeEventJumpSignal: number;
690
+ activeSearchResultIndex: number;
691
+ activeTranscriptEventKey: string | null;
692
+ fullTranscriptLoaded: boolean;
693
+ loadError: unknown;
694
+ loadingFullTranscript: boolean;
695
+ searchInput: string;
696
+ searchResults: TranscriptSearchResult[];
697
+ showRawJson: boolean;
698
+ sortOrder: TranscriptSortOrder;
699
+ snapshot: ThreadSnapshot;
700
+ transcript: ThreadTranscript | null;
701
+ transcriptFilters: ThreadTranscriptFilters;
702
+ onJumpToSearchResult: (index: number) => void;
703
+ onLoadFullThread: () => void;
704
+ onSearchInputChange: (value: string) => void;
705
+ onSortOrderChange: (value: TranscriptSortOrder) => void;
706
+ onTranscriptFilterChange: (patch: Partial<ThreadTranscriptSearch>) => void;
707
+ }) {
708
+ return (
709
+ <TabsContent className="space-y-3" value="transcript">
710
+ <TranscriptControls
711
+ rawJsonDisabled={!transcript?.rawIncluded}
712
+ showCommentary={transcriptFilters.showCommentary}
713
+ showExtraEvents={transcriptFilters.showExtraEvents}
714
+ showRawJson={showRawJson}
715
+ showToolCalls={transcriptFilters.showToolCalls}
716
+ showUserMessages={transcriptFilters.showUserMessages}
717
+ onShowCommentaryChange={(commentary) => onTranscriptFilterChange({ commentary })}
718
+ onShowExtraEventsChange={(extra) => onTranscriptFilterChange({ extra })}
719
+ onShowRawJsonChange={(raw) => onTranscriptFilterChange({ raw })}
720
+ onShowToolCallsChange={(tools) => onTranscriptFilterChange({ tools })}
721
+ onShowUserMessagesChange={(user) => onTranscriptFilterChange({ user })}
722
+ />
723
+ {transcript?.isPartial && snapshot.rollout.shouldDeferTranscriptLoad ? (
724
+ <LargeThreadPreviewNotice
725
+ fileSizeBytes={snapshot.rollout.fileSizeBytes}
726
+ fullTranscriptLoaded={fullTranscriptLoaded}
727
+ pending={loadingFullTranscript}
728
+ onLoad={onLoadFullThread}
729
+ />
730
+ ) : null}
731
+ {transcript ? (
732
+ <TranscriptSearchPanel
733
+ activeResultIndex={activeSearchResultIndex}
734
+ query={searchInput}
735
+ results={searchResults}
736
+ onJumpToResult={onJumpToSearchResult}
737
+ onQueryChange={onSearchInputChange}
738
+ />
739
+ ) : null}
740
+ {transcript ? (
741
+ <TranscriptView
742
+ activeEventJumpSignal={activeEventJumpSignal}
743
+ activeEventKey={activeTranscriptEventKey}
744
+ assistantModel={snapshot.thread.model}
745
+ events={transcript.events}
746
+ projectPath={snapshot.thread.cwd}
747
+ showCommentary={transcriptFilters.showCommentary}
748
+ showExtraEvents={transcriptFilters.showExtraEvents}
749
+ showRawJson={showRawJson && transcript.rawIncluded}
750
+ showToolCalls={transcriptFilters.showToolCalls}
751
+ showUserMessages={transcriptFilters.showUserMessages}
752
+ sortOrder={sortOrder}
753
+ onSortOrderChange={onSortOrderChange}
754
+ />
755
+ ) : (
756
+ <DeferredTranscriptNotice
757
+ fileSizeBytes={snapshot.rollout.fileSizeBytes}
758
+ missing={snapshot.transcriptState === 'missing'}
759
+ pending={loadingFullTranscript}
760
+ onLoad={onLoadFullThread}
761
+ />
762
+ )}
763
+ {loadError ? (
764
+ <p className="text-[var(--destructive)] text-sm">
765
+ Failed to load transcript preview:{' '}
766
+ {loadError instanceof Error ? loadError.message : 'Unknown error'}
767
+ </p>
768
+ ) : null}
769
+ </TabsContent>
770
+ );
771
+ }
772
+
773
+ function ThreadToolsTab({
774
+ loadingTranscript,
775
+ showRawJson,
776
+ snapshot,
777
+ sortOrder,
778
+ transcript,
779
+ onLoadTranscript,
780
+ onSortOrderChange,
781
+ }: {
782
+ loadingTranscript: boolean;
783
+ showRawJson: boolean;
784
+ snapshot: ThreadSnapshot;
785
+ sortOrder: TranscriptSortOrder;
786
+ transcript: ThreadTranscript | null;
787
+ onLoadTranscript: () => void;
788
+ onSortOrderChange: (value: TranscriptSortOrder) => void;
789
+ }) {
790
+ return (
791
+ <TabsContent value="tools">
792
+ <ThreadToolsPanel
793
+ assistantModel={snapshot.thread.model}
794
+ availableTools={snapshot.availableTools}
795
+ events={transcript?.events ?? null}
796
+ loadingTranscript={loadingTranscript}
797
+ projectPath={snapshot.thread.cwd}
798
+ showRawJson={showRawJson && Boolean(transcript?.rawIncluded)}
799
+ sortOrder={sortOrder}
800
+ transcriptIsPartial={transcript?.isPartial}
801
+ transcriptState={transcript ? 'available' : snapshot.transcriptState}
802
+ onLoadTranscript={onLoadTranscript}
803
+ onSortOrderChange={onSortOrderChange}
804
+ />
805
+ </TabsContent>
806
+ );
807
+ }
808
+
809
+ function ThreadDetailPageContent() {
810
+ const { navigate, search, transcriptFilters, updateTranscriptSearch } = useThreadTranscriptRouteSearch();
378
811
  const queryClient = useQueryClient();
379
812
  const params = Route.useParams();
380
813
  const snapshot = useSuspenseQuery(threadSnapshotQueryOptions(params.threadId)).data;
@@ -383,19 +816,82 @@ function ThreadDetailPage() {
383
816
  const [shouldLoadTranscript, setShouldLoadTranscript] = useState(
384
817
  !snapshot.rollout.shouldDeferTranscriptLoad && !transcriptMissing,
385
818
  );
386
- const [showToolCalls, setShowToolCalls] = useState(false);
387
- const [showCommentary, setShowCommentary] = useState(false);
388
- const [showExtraEvents, setShowExtraEvents] = useState(false);
389
- const [showRawJson, setShowRawJson] = useState(false);
390
- const [showUserMessages, setShowUserMessages] = useState(true);
819
+ const showRawJson = search.raw === true;
820
+ const sortOrder: TranscriptSortOrder = search.sort ?? 'earliest';
821
+ const transcriptSearchInput = search.q ?? '';
822
+ const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0);
823
+ const [activeTranscriptEventKey, setActiveTranscriptEventKey] = useState<string | null>(null);
824
+ const [activeEventJumpSignal, setActiveEventJumpSignal] = useState(0);
391
825
  const [exportOpen, setExportOpen] = useState(false);
392
826
  const [deleteOpen, setDeleteOpen] = useState(false);
827
+ const transcriptPreviewQuery = useQuery({
828
+ ...threadTranscriptPreviewQueryOptions(params.threadId, transcriptFilters),
829
+ enabled: snapshot.rollout.shouldDeferTranscriptLoad && !shouldLoadTranscript && !transcriptMissing,
830
+ });
393
831
  const transcriptQuery = useQuery({
394
832
  ...threadTranscriptQueryOptions(params.threadId),
395
- enabled: shouldLoadTranscript && snapshot.transcript === null && !transcriptMissing,
833
+ enabled: shouldLoadFullThreadTranscript({
834
+ shouldLoadTranscript,
835
+ snapshotTranscript: snapshot.transcript,
836
+ transcriptMissing,
837
+ }),
838
+ });
839
+ const transcript = transcriptQuery.data ?? transcriptPreviewQuery.data ?? snapshot.transcript ?? null;
840
+ const viewSnapshot = {
841
+ ...snapshot,
842
+ availableTools:
843
+ snapshot.availableTools.length > 0 ? snapshot.availableTools : (transcript?.sessionMeta.dynamicTools ?? []),
844
+ transcript,
845
+ };
846
+ const fullTranscriptLoaded = Boolean(transcriptQuery.data && !transcriptQuery.data.isPartial);
847
+ const transcriptSearchResults = useTranscriptSearchResults({
848
+ assistantModel: snapshot.thread.model,
849
+ filters: transcriptFilters,
850
+ projectPath: snapshot.thread.cwd,
851
+ query: transcriptSearchInput,
852
+ settings,
853
+ transcript,
396
854
  });
397
- const transcript = snapshot.transcript ?? transcriptQuery.data ?? null;
398
- const viewSnapshot = { ...snapshot, transcript };
855
+
856
+ useEffect(() => {
857
+ setActiveSearchResultIndex((current) =>
858
+ transcriptSearchResults.length === 0 ? 0 : Math.min(current, transcriptSearchResults.length - 1),
859
+ );
860
+ setActiveTranscriptEventKey((current) =>
861
+ current && transcriptSearchResults.some((result) => result.eventKey === current) ? current : null,
862
+ );
863
+ }, [transcriptSearchResults]);
864
+
865
+ const updateTranscriptSearchInput = (value: string) => {
866
+ setActiveSearchResultIndex(0);
867
+ setActiveTranscriptEventKey(null);
868
+ updateTranscriptSearch({ q: value });
869
+ };
870
+
871
+ const updateTranscriptFilter = (patch: Partial<ThreadTranscriptSearch>) => {
872
+ setActiveSearchResultIndex(0);
873
+ setActiveTranscriptEventKey(null);
874
+ updateTranscriptSearch(patch);
875
+ };
876
+
877
+ const updateSortOrder = (sort: TranscriptSortOrder) => {
878
+ updateTranscriptSearch({ sort });
879
+ };
880
+
881
+ const jumpToTranscriptSearchResult = (index: number) => {
882
+ if (transcriptSearchResults.length === 0) {
883
+ return;
884
+ }
885
+
886
+ const wrappedIndex =
887
+ ((index % transcriptSearchResults.length) + transcriptSearchResults.length) %
888
+ transcriptSearchResults.length;
889
+ const result = transcriptSearchResults[wrappedIndex]!;
890
+
891
+ setActiveSearchResultIndex(wrappedIndex);
892
+ setActiveTranscriptEventKey(result.eventKey);
893
+ setActiveEventJumpSignal((current) => current + 1);
894
+ };
399
895
 
400
896
  const exportThreadMutation = useMutation({
401
897
  mutationFn: async (options: {
@@ -458,6 +954,9 @@ function ThreadDetailPage() {
458
954
  },
459
955
  }),
460
956
  onSuccess: async () => {
957
+ queryClient.removeQueries({ queryKey: ['thread', snapshot.thread.id] });
958
+ queryClient.removeQueries({ queryKey: ['thread-transcript-preview', snapshot.thread.id] });
959
+ queryClient.removeQueries({ queryKey: ['thread-transcript', snapshot.thread.id] });
461
960
  await Promise.all([
462
961
  queryClient.invalidateQueries({ queryKey: ['analytics'] }),
463
962
  queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
@@ -502,13 +1001,10 @@ function ThreadDetailPage() {
502
1001
  params: { project: snapshot.project },
503
1002
  to: '/codex/$project',
504
1003
  },
505
- { label: snapshot.thread.title },
1004
+ { label: snapshot.thread.title, title: snapshot.thread.title, truncate: true },
506
1005
  ]}
507
1006
  />
508
1007
  }
509
- eyebrow={snapshot.project}
510
- subtitle={snapshot.thread.preview}
511
- title={snapshot.thread.title}
512
1008
  />
513
1009
 
514
1010
  <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
@@ -529,10 +1025,13 @@ function ThreadDetailPage() {
529
1025
  </div>
530
1026
 
531
1027
  <Tabs className="space-y-4" defaultValue="transcript">
532
- <TabsList className="grid w-fit min-w-[24rem] grid-cols-3 rounded-full border border-[var(--border)] bg-[var(--panel)] p-1">
1028
+ <TabsList className="grid w-full grid-cols-4 rounded-full border border-[var(--border)] bg-[var(--panel)] p-1 sm:w-fit sm:min-w-[30rem]">
533
1029
  <TabsTrigger className="rounded-full px-5 text-sm" value="transcript">
534
1030
  Transcript
535
1031
  </TabsTrigger>
1032
+ <TabsTrigger className="rounded-full px-5 text-sm" value="tools">
1033
+ Tools
1034
+ </TabsTrigger>
536
1035
  <TabsTrigger className="rounded-full px-5 text-sm" value="metadata">
537
1036
  Metadata
538
1037
  </TabsTrigger>
@@ -541,46 +1040,42 @@ function ThreadDetailPage() {
541
1040
  </TabsTrigger>
542
1041
  </TabsList>
543
1042
 
544
- <TabsContent className="space-y-3" value="transcript">
545
- <TranscriptControls
546
- rawJsonDisabled={!transcript?.rawIncluded}
547
- showCommentary={showCommentary}
548
- showExtraEvents={showExtraEvents}
549
- showRawJson={showRawJson}
550
- showToolCalls={showToolCalls}
551
- showUserMessages={showUserMessages}
552
- onShowCommentaryChange={setShowCommentary}
553
- onShowExtraEventsChange={setShowExtraEvents}
554
- onShowRawJsonChange={setShowRawJson}
555
- onShowToolCallsChange={setShowToolCalls}
556
- onShowUserMessagesChange={setShowUserMessages}
557
- />
558
- {transcript ? (
559
- <TranscriptView
560
- assistantModel={snapshot.thread.model}
561
- events={transcript.events}
562
- projectPath={snapshot.thread.cwd}
563
- showCommentary={showCommentary}
564
- showExtraEvents={showExtraEvents}
565
- showRawJson={showRawJson && transcript.rawIncluded}
566
- showToolCalls={showToolCalls}
567
- showUserMessages={showUserMessages}
568
- />
569
- ) : (
570
- <DeferredTranscriptNotice
571
- fileSizeBytes={snapshot.rollout.fileSizeBytes}
572
- missing={snapshot.transcriptState === 'missing'}
573
- pending={transcriptQuery.isFetching}
574
- onLoad={() => setShouldLoadTranscript(true)}
575
- />
576
- )}
577
- {transcriptQuery.isError ? (
578
- <p className="text-[var(--destructive)] text-sm">
579
- Failed to load transcript preview:{' '}
580
- {transcriptQuery.error instanceof Error ? transcriptQuery.error.message : 'Unknown error'}
581
- </p>
582
- ) : null}
583
- </TabsContent>
1043
+ <ThreadTranscriptTab
1044
+ activeEventJumpSignal={activeEventJumpSignal}
1045
+ activeSearchResultIndex={activeSearchResultIndex}
1046
+ activeTranscriptEventKey={activeTranscriptEventKey}
1047
+ fullTranscriptLoaded={fullTranscriptLoaded}
1048
+ loadError={
1049
+ transcriptQuery.isError
1050
+ ? transcriptQuery.error
1051
+ : transcriptPreviewQuery.isError
1052
+ ? transcriptPreviewQuery.error
1053
+ : null
1054
+ }
1055
+ loadingFullTranscript={transcriptQuery.isFetching}
1056
+ searchInput={transcriptSearchInput}
1057
+ searchResults={transcriptSearchResults}
1058
+ showRawJson={showRawJson}
1059
+ sortOrder={sortOrder}
1060
+ snapshot={snapshot}
1061
+ transcript={transcript}
1062
+ transcriptFilters={transcriptFilters}
1063
+ onJumpToSearchResult={jumpToTranscriptSearchResult}
1064
+ onLoadFullThread={() => setShouldLoadTranscript(true)}
1065
+ onSearchInputChange={updateTranscriptSearchInput}
1066
+ onSortOrderChange={updateSortOrder}
1067
+ onTranscriptFilterChange={updateTranscriptFilter}
1068
+ />
1069
+
1070
+ <ThreadToolsTab
1071
+ loadingTranscript={transcriptQuery.isFetching}
1072
+ showRawJson={showRawJson}
1073
+ snapshot={viewSnapshot}
1074
+ sortOrder={sortOrder}
1075
+ transcript={transcript}
1076
+ onLoadTranscript={() => setShouldLoadTranscript(true)}
1077
+ onSortOrderChange={updateSortOrder}
1078
+ />
584
1079
 
585
1080
  <TabsContent value="metadata">
586
1081
  <ThreadMetadataPanels snapshot={viewSnapshot} />
@@ -595,11 +1090,17 @@ function ThreadDetailPage() {
595
1090
  confirmLabel={deleteThreadMutation.isPending ? 'Deleting...' : 'Delete thread'}
596
1091
  defaultDeleteSessionFiles
597
1092
  description="Delete this thread from the Codex database. Enable Delete Session files if you also want to remove the rollout JSONL from disk."
1093
+ errorMessage={getMutationErrorMessage(deleteThreadMutation.error, 'Thread delete failed')}
598
1094
  open={deleteOpen}
599
1095
  showDeleteSessionFilesOption
600
1096
  title="Delete this thread from Codex DB?"
601
1097
  onConfirm={({ deleteSessionFiles }) => deleteThreadMutation.mutate({ deleteSessionFiles })}
602
- onOpenChange={setDeleteOpen}
1098
+ onOpenChange={(open) => {
1099
+ setDeleteOpen(open);
1100
+ if (!open) {
1101
+ deleteThreadMutation.reset();
1102
+ }
1103
+ }}
603
1104
  />
604
1105
 
605
1106
  <ExportDialog
@@ -612,8 +1113,22 @@ function ThreadDetailPage() {
612
1113
  exportThreadMutation.mutate(options);
613
1114
  }
614
1115
  }}
615
- onOpenChange={setExportOpen}
1116
+ onOpenChange={(open) => {
1117
+ setExportOpen(open);
1118
+ if (!open) {
1119
+ exportThreadMutation.reset();
1120
+ }
1121
+ }}
616
1122
  />
617
1123
  </div>
618
1124
  );
619
1125
  }
1126
+
1127
+ function ThreadDetailPage() {
1128
+ const { threadId } = Route.useParams();
1129
+ return (
1130
+ <RouteStateResetBoundary routeKey={threadId}>
1131
+ <ThreadDetailPageContent />
1132
+ </RouteStateResetBoundary>
1133
+ );
1134
+ }