spiracha 2.0.0 → 2.1.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 (104) hide show
  1. package/README.md +6 -1
  2. package/apps/ui/README.md +20 -2
  3. package/apps/ui/package.json +2 -2
  4. package/apps/ui/src/components/antigravity-conversations-table.tsx +50 -11
  5. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  6. package/apps/ui/src/components/app-shell.tsx +1 -0
  7. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  8. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  9. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  10. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  11. package/apps/ui/src/components/data-table.tsx +7 -5
  12. package/apps/ui/src/components/export-dialog.tsx +2 -12
  13. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  14. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  15. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  16. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  17. package/apps/ui/src/components/page-header.tsx +15 -9
  18. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  19. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  20. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  21. package/apps/ui/src/components/threads-table.tsx +9 -46
  22. package/apps/ui/src/components/transcript-view.tsx +148 -52
  23. package/apps/ui/src/components/ui/select.tsx +1 -1
  24. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  25. package/apps/ui/src/lib/antigravity-server.ts +152 -7
  26. package/apps/ui/src/lib/antigravity-transcript-events.ts +11 -3
  27. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  28. package/apps/ui/src/lib/claude-code-server.ts +175 -7
  29. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  30. package/apps/ui/src/lib/codex-queries.ts +19 -3
  31. package/apps/ui/src/lib/codex-server.ts +135 -53
  32. package/apps/ui/src/lib/cursor-server.ts +48 -5
  33. package/apps/ui/src/lib/formatters.ts +3 -5
  34. package/apps/ui/src/lib/grok-queries.ts +22 -0
  35. package/apps/ui/src/lib/grok-server.ts +169 -0
  36. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  37. package/apps/ui/src/lib/kiro-server.ts +85 -7
  38. package/apps/ui/src/lib/opencode-server.ts +85 -7
  39. package/apps/ui/src/lib/qoder-server.ts +67 -11
  40. package/apps/ui/src/lib/route-search.ts +114 -0
  41. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  42. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  43. package/apps/ui/src/lib/workspace-delete-navigation.ts +12 -0
  44. package/apps/ui/src/routeTree.gen.ts +107 -0
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +76 -10
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +269 -31
  47. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  48. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  49. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  50. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +143 -17
  51. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +165 -26
  52. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +2 -2
  53. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +11 -1
  54. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +388 -0
  55. package/apps/ui/src/routes/grok.$workspaceKey.tsx +301 -0
  56. package/apps/ui/src/routes/grok.index.tsx +48 -0
  57. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +66 -15
  58. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +199 -32
  59. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +105 -19
  60. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +236 -44
  61. package/apps/ui/src/routes/opencode.index.tsx +16 -3
  62. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +7 -4
  63. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +75 -28
  64. package/apps/ui/src/routes/threads.$threadId.tsx +548 -64
  65. package/package.json +19 -18
  66. package/src/client.ts +134 -1
  67. package/src/lib/antigravity-db.ts +208 -32
  68. package/src/lib/antigravity-exporter-types.ts +3 -0
  69. package/src/lib/antigravity-projects.ts +201 -0
  70. package/src/lib/claude-code-db.ts +498 -32
  71. package/src/lib/claude-code-exporter-types.ts +5 -0
  72. package/src/lib/claude-code-transcript-phase.ts +60 -1
  73. package/src/lib/claude-code-transcript.ts +8 -5
  74. package/src/lib/codex-browser-export.ts +23 -27
  75. package/src/lib/codex-thread-cache.ts +41 -13
  76. package/src/lib/codex-thread-parser.ts +86 -35
  77. package/src/lib/codex-transcript-filter.ts +31 -0
  78. package/src/lib/codex-transcript-renderer.ts +39 -19
  79. package/src/lib/concurrency.ts +41 -0
  80. package/src/lib/conversation-api.ts +399 -19
  81. package/src/lib/conversation-data/antigravity-adapter.ts +21 -2
  82. package/src/lib/conversation-data/claude-code-adapter.ts +37 -6
  83. package/src/lib/conversation-data/codex-adapter.ts +28 -4
  84. package/src/lib/conversation-data/cursor-adapter.ts +35 -1
  85. package/src/lib/conversation-data/grok-adapter.ts +210 -0
  86. package/src/lib/conversation-data/index.ts +76 -1
  87. package/src/lib/conversation-data/kiro-adapter.ts +41 -7
  88. package/src/lib/conversation-data/opencode-adapter.ts +24 -2
  89. package/src/lib/conversation-data/qoder-adapter.ts +44 -19
  90. package/src/lib/conversation-data/types.ts +43 -0
  91. package/src/lib/conversation-zip-export.ts +57 -0
  92. package/src/lib/cursor-db.ts +34 -5
  93. package/src/lib/grok-db.ts +1026 -0
  94. package/src/lib/grok-exporter-types.ts +110 -0
  95. package/src/lib/grok-transcript-phase.ts +52 -0
  96. package/src/lib/grok-transcript.ts +154 -0
  97. package/src/lib/kiro-db.ts +52 -1
  98. package/src/lib/model-label.ts +11 -3
  99. package/src/lib/opencode-db.ts +598 -55
  100. package/src/lib/opencode-transcript.ts +8 -5
  101. package/src/lib/shared.ts +12 -0
  102. package/src/lib/transcript-load-limiter.ts +82 -0
  103. package/src/lib/ui-cache.ts +43 -17
  104. package/src/lib/ui-export-archive.ts +60 -0
@@ -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,162 @@ 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 {
21
+ getTranscriptEventKey,
22
+ shouldShowEvent,
23
+ type TranscriptSortOrder,
24
+ TranscriptView,
25
+ } from '#/components/transcript-view';
14
26
  import { Badge } from '#/components/ui/badge';
15
27
  import { Button } from '#/components/ui/button';
16
28
  import { Checkbox } from '#/components/ui/checkbox';
29
+ import { Input } from '#/components/ui/input';
17
30
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
18
- import { threadSnapshotQueryOptions, threadTranscriptQueryOptions } from '#/lib/codex-queries';
31
+ import {
32
+ threadSnapshotQueryOptions,
33
+ threadTranscriptPreviewQueryOptions,
34
+ threadTranscriptQueryOptions,
35
+ } from '#/lib/codex-queries';
19
36
  import { deleteThreadFn, exportThreadFn, type getThreadSnapshotFn } from '#/lib/codex-server';
20
37
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
21
- import { formatBooleanLabel, formatBytes, formatDateTime, formatList, formatTokens } from '#/lib/formatters';
38
+ import {
39
+ formatBooleanLabel,
40
+ formatBytes,
41
+ formatDateTime,
42
+ formatList,
43
+ formatModelLabel,
44
+ formatTokens,
45
+ } from '#/lib/formatters';
46
+ import { applyPathTransforms } from '#/lib/path-utils';
47
+ import {
48
+ parseThreadTranscriptSearch,
49
+ type ThreadTranscriptSearch,
50
+ withThreadTranscriptSearch,
51
+ } from '#/lib/route-search';
22
52
  import { useSettings } from '#/lib/settings-store';
53
+ import { shouldLoadFullThreadTranscript } from '#/lib/thread-transcript-load';
23
54
 
24
- type ThreadSnapshot = Awaited<ReturnType<typeof getThreadSnapshotFn>>;
55
+ type ThreadSnapshotResponse = Awaited<ReturnType<typeof getThreadSnapshotFn>>;
56
+ type ThreadTranscript = ParsedCodexTranscript;
57
+ type ThreadSnapshot = Omit<ThreadSnapshotResponse, 'availableTools' | 'transcript'> & {
58
+ availableTools: DynamicToolDefinition[];
59
+ transcript: ThreadTranscript | null;
60
+ };
61
+
62
+ type TranscriptSearchResult = {
63
+ event: MessageEvent;
64
+ eventIndex: number;
65
+ eventKey: string;
66
+ messageNumber: number;
67
+ phase: string | null;
68
+ roleLabel: string;
69
+ snippet: string;
70
+ };
71
+
72
+ type TranscriptSearchPanelProps = {
73
+ activeResultIndex: number;
74
+ query: string;
75
+ results: TranscriptSearchResult[];
76
+ onJumpToResult: (index: number) => void;
77
+ onQueryChange: (value: string) => void;
78
+ };
79
+
80
+ type TranscriptSearchFilters = {
81
+ showCommentary: boolean;
82
+ showExtraEvents: boolean;
83
+ showToolCalls: boolean;
84
+ showUserMessages: boolean;
85
+ };
86
+
87
+ const SEARCH_SNIPPET_RADIUS = 72;
88
+
89
+ const getTranscriptFiltersFromSearch = (search: ThreadTranscriptSearch) => ({
90
+ showCommentary: search.commentary === true,
91
+ showExtraEvents: search.extra === true,
92
+ showToolCalls: search.tools === true,
93
+ showUserMessages: search.user === true,
94
+ });
95
+
96
+ type ThreadTranscriptFilters = ReturnType<typeof getTranscriptFiltersFromSearch>;
97
+
98
+ const normalizeTranscriptSearchText = (value: string) => value.replace(/\s+/gu, ' ').trim();
99
+
100
+ const getTranscriptSearchRoleLabel = (event: MessageEvent, assistantModel: string | null) => {
101
+ if (event.role === 'assistant') {
102
+ return formatModelLabel(event.model ?? assistantModel);
103
+ }
104
+
105
+ return event.role === 'system' ? 'System' : 'User';
106
+ };
107
+
108
+ const buildTranscriptSearchSnippet = (text: string, query: string) => {
109
+ const normalizedText = normalizeTranscriptSearchText(text);
110
+ const normalizedQuery = normalizeTranscriptSearchText(query).toLowerCase();
111
+ const matchIndex = normalizedText.toLowerCase().indexOf(normalizedQuery);
112
+
113
+ if (matchIndex < 0) {
114
+ return normalizedText.slice(0, SEARCH_SNIPPET_RADIUS * 2);
115
+ }
116
+
117
+ const start = Math.max(0, matchIndex - SEARCH_SNIPPET_RADIUS);
118
+ const end = Math.min(normalizedText.length, matchIndex + normalizedQuery.length + SEARCH_SNIPPET_RADIUS);
119
+ const prefix = start > 0 ? '...' : '';
120
+ const suffix = end < normalizedText.length ? '...' : '';
121
+
122
+ return `${prefix}${normalizedText.slice(start, end)}${suffix}`;
123
+ };
124
+
125
+ const buildTranscriptSearchResults = (
126
+ events: ThreadEvent[],
127
+ query: string,
128
+ assistantModel: string | null,
129
+ filters: TranscriptSearchFilters,
130
+ transform: (text: string) => string,
131
+ ): TranscriptSearchResult[] => {
132
+ const normalizedQuery = normalizeTranscriptSearchText(query).toLowerCase();
133
+ if (!normalizedQuery) {
134
+ return [];
135
+ }
136
+
137
+ const results: TranscriptSearchResult[] = [];
138
+ let messageNumber = 0;
139
+
140
+ events.forEach((event, index) => {
141
+ if (event.kind !== 'message') {
142
+ return;
143
+ }
144
+
145
+ if (
146
+ !shouldShowEvent(
147
+ event,
148
+ filters.showToolCalls,
149
+ filters.showExtraEvents,
150
+ filters.showCommentary,
151
+ filters.showUserMessages,
152
+ )
153
+ ) {
154
+ return;
155
+ }
156
+
157
+ messageNumber += 1;
158
+ const searchText = normalizeTranscriptSearchText(transform(event.text));
159
+ if (!searchText.toLowerCase().includes(normalizedQuery)) {
160
+ return;
161
+ }
162
+
163
+ results.push({
164
+ event,
165
+ eventIndex: index,
166
+ eventKey: getTranscriptEventKey(event, index),
167
+ messageNumber,
168
+ phase: event.phase,
169
+ roleLabel: getTranscriptSearchRoleLabel(event, assistantModel),
170
+ snippet: buildTranscriptSearchSnippet(searchText, query),
171
+ });
172
+ });
173
+
174
+ return results;
175
+ };
25
176
 
26
177
  type TranscriptControlsProps = {
27
178
  rawJsonDisabled?: boolean;
@@ -162,7 +313,7 @@ const buildTranscriptStatsItems = (snapshot: ThreadSnapshot) => {
162
313
  return [
163
314
  { label: 'Transcript load', value: 'Deferred for oversized rollout' },
164
315
  { label: 'Rollout size', value: formatBytes(snapshot.rollout.fileSizeBytes) },
165
- { label: 'Preview mode', value: 'Load the transcript manually to inspect it.' },
316
+ { label: 'Deferred load', value: 'Load the transcript manually to inspect it.' },
166
317
  ];
167
318
  }
168
319
 
@@ -240,6 +391,98 @@ function TranscriptControls({
240
391
  );
241
392
  }
242
393
 
394
+ function TranscriptSearchPanel({
395
+ activeResultIndex,
396
+ query,
397
+ results,
398
+ onJumpToResult,
399
+ onQueryChange,
400
+ }: TranscriptSearchPanelProps) {
401
+ const hasQuery = normalizeTranscriptSearchText(query).length > 0;
402
+ const hasResults = results.length > 0;
403
+ const statusLabel = hasQuery
404
+ ? hasResults
405
+ ? `${activeResultIndex + 1} / ${results.length}`
406
+ : 'No matches'
407
+ : 'Search';
408
+
409
+ const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
410
+ if (event.key !== 'Enter' || !hasResults) {
411
+ return;
412
+ }
413
+
414
+ event.preventDefault();
415
+ onJumpToResult(event.shiftKey ? activeResultIndex - 1 : activeResultIndex);
416
+ };
417
+
418
+ return (
419
+ <section className="rounded-xl border border-[var(--border)] bg-[var(--panel)] px-4 py-3 shadow-[var(--panel-shadow)]">
420
+ <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
421
+ <div className="relative min-w-0 flex-1">
422
+ <Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[var(--muted-foreground)]" />
423
+ <Input
424
+ aria-label="Search transcript messages"
425
+ className="h-10 rounded-full border-[var(--border)] bg-[var(--panel-secondary)] pr-4 pl-9"
426
+ placeholder="Search transcript messages"
427
+ value={query}
428
+ onChange={(event) => onQueryChange(event.target.value)}
429
+ onKeyDown={handleKeyDown}
430
+ />
431
+ </div>
432
+
433
+ <div className="flex flex-wrap items-center gap-2">
434
+ <span className="min-w-20 text-right text-[var(--muted-foreground)] text-sm">{statusLabel}</span>
435
+ <Button
436
+ className="rounded-full"
437
+ disabled={!hasResults}
438
+ size="sm"
439
+ type="button"
440
+ variant="outline"
441
+ onClick={() => onJumpToResult(activeResultIndex - 1)}
442
+ >
443
+ <ChevronUp className="size-4" />
444
+ Prev
445
+ </Button>
446
+ <Button
447
+ className="rounded-full"
448
+ disabled={!hasResults}
449
+ size="sm"
450
+ type="button"
451
+ variant="outline"
452
+ onClick={() => onJumpToResult(activeResultIndex + 1)}
453
+ >
454
+ <ChevronDown className="size-4" />
455
+ Next
456
+ </Button>
457
+ </div>
458
+ </div>
459
+
460
+ {hasQuery && hasResults ? (
461
+ <div className="mt-3 max-h-72 overflow-auto rounded-xl border border-[var(--border)] bg-[var(--panel-secondary)]">
462
+ {results.map((result, index) => (
463
+ <button
464
+ key={result.eventKey}
465
+ aria-current={index === activeResultIndex ? 'true' : undefined}
466
+ 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)]"
467
+ type="button"
468
+ onClick={() => onJumpToResult(index)}
469
+ >
470
+ <span className="flex flex-wrap items-center gap-2">
471
+ <span className="font-medium text-sm">Message {result.messageNumber}</span>
472
+ <Badge variant="outline">{result.roleLabel}</Badge>
473
+ {result.phase ? <Badge variant="outline">{result.phase}</Badge> : null}
474
+ </span>
475
+ <span className="mt-1 block min-w-0 break-words text-[var(--muted-foreground)] text-sm leading-5 [overflow-wrap:anywhere]">
476
+ {result.snippet}
477
+ </span>
478
+ </button>
479
+ ))}
480
+ </div>
481
+ ) : null}
482
+ </section>
483
+ );
484
+ }
485
+
243
486
  function ThreadMetadataPanels({ snapshot }: ThreadMetadataProps) {
244
487
  return (
245
488
  <div className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
@@ -319,12 +562,12 @@ function DeferredTranscriptNotice({
319
562
  <p className="mt-2 text-[var(--muted-foreground)] text-sm leading-6">
320
563
  {missing
321
564
  ? '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.`}
565
+ : `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
566
  </p>
324
567
  {missing ? null : (
325
568
  <div className="mt-4">
326
569
  <Button disabled={pending} variant="outline" onClick={onLoad}>
327
- {pending ? 'Loading preview...' : 'Load transcript preview'}
570
+ {pending ? 'Loading full thread...' : 'Load Full Thread'}
328
571
  </Button>
329
572
  </div>
330
573
  )}
@@ -332,6 +575,39 @@ function DeferredTranscriptNotice({
332
575
  );
333
576
  }
334
577
 
578
+ function LargeThreadPreviewNotice({
579
+ fileSizeBytes,
580
+ fullTranscriptLoaded,
581
+ pending,
582
+ onLoad,
583
+ }: {
584
+ fileSizeBytes: number | null;
585
+ fullTranscriptLoaded: boolean;
586
+ pending: boolean;
587
+ onLoad: () => void;
588
+ }) {
589
+ const buttonLabel = fullTranscriptLoaded
590
+ ? 'Full thread loaded'
591
+ : pending
592
+ ? 'Loading full thread...'
593
+ : 'Load Full Thread';
594
+
595
+ return (
596
+ <section className="rounded-[1.6rem] border border-[var(--border)] bg-[var(--panel)] p-5 shadow-[var(--panel-shadow)]">
597
+ <h3 className="font-semibold text-base">Showing the latest matching messages</h3>
598
+ <p className="mt-2 text-[var(--muted-foreground)] text-sm leading-6">
599
+ This rollout is {formatBytes(fileSizeBytes)}, so Spiracha loaded a small window from the end of the
600
+ thread using the current transcript filters. Load the full thread when you need earlier messages.
601
+ </p>
602
+ <div className="mt-4">
603
+ <Button disabled={pending || fullTranscriptLoaded} variant="outline" onClick={onLoad}>
604
+ {buttonLabel}
605
+ </Button>
606
+ </div>
607
+ </section>
608
+ );
609
+ }
610
+
335
611
  function ThreadErrorComponent({ error }: { error: Error }) {
336
612
  const isSqlite = error.message.includes('unable to open database') || error.message.includes('database is locked');
337
613
  return (
@@ -364,38 +640,263 @@ const getThreadExportErrorMessage = (transcriptMissing: boolean, error: unknown)
364
640
  export const Route = createFileRoute('/threads/$threadId')({
365
641
  component: ThreadDetailPage,
366
642
  errorComponent: ThreadErrorComponent,
367
- loader: ({ context, params }) => context.queryClient.ensureQueryData(threadSnapshotQueryOptions(params.threadId)),
643
+ loader: ({ context, deps, params }) =>
644
+ context.queryClient.ensureQueryData(
645
+ threadSnapshotQueryOptions(
646
+ params.threadId,
647
+ (deps as { transcriptFilters: ThreadTranscriptFilters }).transcriptFilters,
648
+ ),
649
+ ),
650
+ loaderDeps: ({ search }) => ({
651
+ transcriptFilters: getTranscriptFiltersFromSearch(parseThreadTranscriptSearch(search)),
652
+ }),
368
653
  pendingComponent: () => (
369
654
  <LoadingPanel
370
655
  description="Loading the transcript, metadata, and parsed event stream for this thread."
371
656
  title="Loading thread"
372
657
  />
373
658
  ),
659
+ validateSearch: parseThreadTranscriptSearch,
374
660
  });
375
661
 
662
+ const useThreadTranscriptRouteSearch = () => {
663
+ const navigate = useNavigate({ from: Route.fullPath });
664
+ const search = Route.useSearch();
665
+ const transcriptFilters = useMemo(() => getTranscriptFiltersFromSearch(search), [search]);
666
+
667
+ const updateTranscriptSearch = (patch: Partial<ThreadTranscriptSearch>) => {
668
+ startTransition(() => {
669
+ void navigate({
670
+ params: true,
671
+ replace: true,
672
+ search: (previous: Record<string, unknown>) => withThreadTranscriptSearch(previous, patch),
673
+ });
674
+ });
675
+ };
676
+
677
+ return {
678
+ navigate,
679
+ search,
680
+ transcriptFilters,
681
+ updateTranscriptSearch,
682
+ };
683
+ };
684
+
685
+ const useTranscriptSearchResults = ({
686
+ assistantModel,
687
+ filters,
688
+ projectPath,
689
+ query,
690
+ settings,
691
+ transcript,
692
+ }: {
693
+ assistantModel: string | null;
694
+ filters: TranscriptSearchFilters;
695
+ projectPath: string;
696
+ query: string;
697
+ settings: ReturnType<typeof useSettings>['settings'];
698
+ transcript: ThreadTranscript | null;
699
+ }) =>
700
+ useMemo(
701
+ () =>
702
+ transcript
703
+ ? buildTranscriptSearchResults(transcript.events, query, assistantModel, filters, (text) =>
704
+ applyPathTransforms(text, settings, projectPath),
705
+ )
706
+ : [],
707
+ [assistantModel, filters, projectPath, query, settings, transcript],
708
+ );
709
+
710
+ function ThreadTranscriptTab({
711
+ activeEventJumpSignal,
712
+ activeSearchResultIndex,
713
+ activeTranscriptEventKey,
714
+ fullTranscriptLoaded,
715
+ loadError,
716
+ loadingFullTranscript,
717
+ searchInput,
718
+ searchResults,
719
+ showRawJson,
720
+ sortOrder,
721
+ snapshot,
722
+ transcript,
723
+ transcriptFilters,
724
+ onJumpToSearchResult,
725
+ onLoadFullThread,
726
+ onSearchInputChange,
727
+ onSortOrderChange,
728
+ onTranscriptFilterChange,
729
+ }: {
730
+ activeEventJumpSignal: number;
731
+ activeSearchResultIndex: number;
732
+ activeTranscriptEventKey: string | null;
733
+ fullTranscriptLoaded: boolean;
734
+ loadError: unknown;
735
+ loadingFullTranscript: boolean;
736
+ searchInput: string;
737
+ searchResults: TranscriptSearchResult[];
738
+ showRawJson: boolean;
739
+ sortOrder: TranscriptSortOrder;
740
+ snapshot: ThreadSnapshot;
741
+ transcript: ThreadTranscript | null;
742
+ transcriptFilters: ThreadTranscriptFilters;
743
+ onJumpToSearchResult: (index: number) => void;
744
+ onLoadFullThread: () => void;
745
+ onSearchInputChange: (value: string) => void;
746
+ onSortOrderChange: (value: TranscriptSortOrder) => void;
747
+ onTranscriptFilterChange: (patch: Partial<ThreadTranscriptSearch>) => void;
748
+ }) {
749
+ return (
750
+ <TabsContent className="space-y-3" value="transcript">
751
+ <TranscriptControls
752
+ rawJsonDisabled={!transcript?.rawIncluded}
753
+ showCommentary={transcriptFilters.showCommentary}
754
+ showExtraEvents={transcriptFilters.showExtraEvents}
755
+ showRawJson={showRawJson}
756
+ showToolCalls={transcriptFilters.showToolCalls}
757
+ showUserMessages={transcriptFilters.showUserMessages}
758
+ onShowCommentaryChange={(commentary) => onTranscriptFilterChange({ commentary })}
759
+ onShowExtraEventsChange={(extra) => onTranscriptFilterChange({ extra })}
760
+ onShowRawJsonChange={(raw) => onTranscriptFilterChange({ raw })}
761
+ onShowToolCallsChange={(tools) => onTranscriptFilterChange({ tools })}
762
+ onShowUserMessagesChange={(user) => onTranscriptFilterChange({ user })}
763
+ />
764
+ {transcript?.isPartial && snapshot.rollout.shouldDeferTranscriptLoad ? (
765
+ <LargeThreadPreviewNotice
766
+ fileSizeBytes={snapshot.rollout.fileSizeBytes}
767
+ fullTranscriptLoaded={fullTranscriptLoaded}
768
+ pending={loadingFullTranscript}
769
+ onLoad={onLoadFullThread}
770
+ />
771
+ ) : null}
772
+ {transcript ? (
773
+ <TranscriptSearchPanel
774
+ activeResultIndex={activeSearchResultIndex}
775
+ query={searchInput}
776
+ results={searchResults}
777
+ onJumpToResult={onJumpToSearchResult}
778
+ onQueryChange={onSearchInputChange}
779
+ />
780
+ ) : null}
781
+ {transcript ? (
782
+ <TranscriptView
783
+ activeEventJumpSignal={activeEventJumpSignal}
784
+ activeEventKey={activeTranscriptEventKey}
785
+ assistantModel={snapshot.thread.model}
786
+ events={transcript.events}
787
+ projectPath={snapshot.thread.cwd}
788
+ showCommentary={transcriptFilters.showCommentary}
789
+ showExtraEvents={transcriptFilters.showExtraEvents}
790
+ showRawJson={showRawJson && transcript.rawIncluded}
791
+ showToolCalls={transcriptFilters.showToolCalls}
792
+ showUserMessages={transcriptFilters.showUserMessages}
793
+ sortOrder={sortOrder}
794
+ onSortOrderChange={onSortOrderChange}
795
+ />
796
+ ) : (
797
+ <DeferredTranscriptNotice
798
+ fileSizeBytes={snapshot.rollout.fileSizeBytes}
799
+ missing={snapshot.transcriptState === 'missing'}
800
+ pending={loadingFullTranscript}
801
+ onLoad={onLoadFullThread}
802
+ />
803
+ )}
804
+ {loadError ? (
805
+ <p className="text-[var(--destructive)] text-sm">
806
+ Failed to load transcript preview:{' '}
807
+ {loadError instanceof Error ? loadError.message : 'Unknown error'}
808
+ </p>
809
+ ) : null}
810
+ </TabsContent>
811
+ );
812
+ }
813
+
376
814
  function ThreadDetailPage() {
377
- const navigate = useNavigate();
815
+ const { navigate, search, transcriptFilters, updateTranscriptSearch } = useThreadTranscriptRouteSearch();
378
816
  const queryClient = useQueryClient();
379
817
  const params = Route.useParams();
380
- const snapshot = useSuspenseQuery(threadSnapshotQueryOptions(params.threadId)).data;
818
+ const snapshot = useSuspenseQuery(threadSnapshotQueryOptions(params.threadId, transcriptFilters)).data;
381
819
  const { settings } = useSettings();
382
820
  const transcriptMissing = snapshot.transcriptState === 'missing';
383
821
  const [shouldLoadTranscript, setShouldLoadTranscript] = useState(
384
822
  !snapshot.rollout.shouldDeferTranscriptLoad && !transcriptMissing,
385
823
  );
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);
824
+ const showRawJson = search.raw === true;
825
+ const sortOrder: TranscriptSortOrder = search.sort ?? 'earliest';
826
+ const transcriptSearchInput = search.q ?? '';
827
+ const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0);
828
+ const [activeTranscriptEventKey, setActiveTranscriptEventKey] = useState<string | null>(null);
829
+ const [activeEventJumpSignal, setActiveEventJumpSignal] = useState(0);
391
830
  const [exportOpen, setExportOpen] = useState(false);
392
831
  const [deleteOpen, setDeleteOpen] = useState(false);
832
+ const transcriptPreviewQuery = useQuery({
833
+ ...threadTranscriptPreviewQueryOptions(params.threadId, transcriptFilters),
834
+ enabled: snapshot.rollout.shouldDeferTranscriptLoad && !shouldLoadTranscript && !transcriptMissing,
835
+ });
393
836
  const transcriptQuery = useQuery({
394
837
  ...threadTranscriptQueryOptions(params.threadId),
395
- enabled: shouldLoadTranscript && snapshot.transcript === null && !transcriptMissing,
838
+ enabled: shouldLoadFullThreadTranscript({
839
+ shouldLoadTranscript,
840
+ snapshotTranscript: snapshot.transcript,
841
+ transcriptMissing,
842
+ }),
843
+ });
844
+ const transcript = transcriptQuery.data ?? transcriptPreviewQuery.data ?? snapshot.transcript ?? null;
845
+ const viewSnapshot = {
846
+ ...snapshot,
847
+ availableTools:
848
+ snapshot.availableTools.length > 0 ? snapshot.availableTools : (transcript?.sessionMeta.dynamicTools ?? []),
849
+ transcript,
850
+ };
851
+ const fullTranscriptLoaded = Boolean(transcriptQuery.data && !transcriptQuery.data.isPartial);
852
+ const transcriptSearchResults = useTranscriptSearchResults({
853
+ assistantModel: snapshot.thread.model,
854
+ filters: transcriptFilters,
855
+ projectPath: snapshot.thread.cwd,
856
+ query: transcriptSearchInput,
857
+ settings,
858
+ transcript,
396
859
  });
397
- const transcript = snapshot.transcript ?? transcriptQuery.data ?? null;
398
- const viewSnapshot = { ...snapshot, transcript };
860
+
861
+ useEffect(() => {
862
+ setActiveSearchResultIndex((current) =>
863
+ transcriptSearchResults.length === 0 ? 0 : Math.min(current, transcriptSearchResults.length - 1),
864
+ );
865
+ setActiveTranscriptEventKey((current) =>
866
+ current && transcriptSearchResults.some((result) => result.eventKey === current) ? current : null,
867
+ );
868
+ }, [transcriptSearchResults]);
869
+
870
+ const updateTranscriptSearchInput = (value: string) => {
871
+ setActiveSearchResultIndex(0);
872
+ setActiveTranscriptEventKey(null);
873
+ updateTranscriptSearch({ q: value });
874
+ };
875
+
876
+ const updateTranscriptFilter = (patch: Partial<ThreadTranscriptSearch>) => {
877
+ setActiveSearchResultIndex(0);
878
+ setActiveTranscriptEventKey(null);
879
+ updateTranscriptSearch(patch);
880
+ };
881
+
882
+ const updateSortOrder = (sort: TranscriptSortOrder) => {
883
+ updateTranscriptSearch({ sort });
884
+ };
885
+
886
+ const jumpToTranscriptSearchResult = (index: number) => {
887
+ if (transcriptSearchResults.length === 0) {
888
+ return;
889
+ }
890
+
891
+ const wrappedIndex =
892
+ ((index % transcriptSearchResults.length) + transcriptSearchResults.length) %
893
+ transcriptSearchResults.length;
894
+ const result = transcriptSearchResults[wrappedIndex]!;
895
+
896
+ setActiveSearchResultIndex(wrappedIndex);
897
+ setActiveTranscriptEventKey(result.eventKey);
898
+ setActiveEventJumpSignal((current) => current + 1);
899
+ };
399
900
 
400
901
  const exportThreadMutation = useMutation({
401
902
  mutationFn: async (options: {
@@ -502,13 +1003,10 @@ function ThreadDetailPage() {
502
1003
  params: { project: snapshot.project },
503
1004
  to: '/codex/$project',
504
1005
  },
505
- { label: snapshot.thread.title },
1006
+ { label: snapshot.thread.title, title: snapshot.thread.title, truncate: true },
506
1007
  ]}
507
1008
  />
508
1009
  }
509
- eyebrow={snapshot.project}
510
- subtitle={snapshot.thread.preview}
511
- title={snapshot.thread.title}
512
1010
  />
513
1011
 
514
1012
  <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
@@ -541,46 +1039,32 @@ function ThreadDetailPage() {
541
1039
  </TabsTrigger>
542
1040
  </TabsList>
543
1041
 
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>
1042
+ <ThreadTranscriptTab
1043
+ activeEventJumpSignal={activeEventJumpSignal}
1044
+ activeSearchResultIndex={activeSearchResultIndex}
1045
+ activeTranscriptEventKey={activeTranscriptEventKey}
1046
+ fullTranscriptLoaded={fullTranscriptLoaded}
1047
+ loadError={
1048
+ transcriptQuery.isError
1049
+ ? transcriptQuery.error
1050
+ : transcriptPreviewQuery.isError
1051
+ ? transcriptPreviewQuery.error
1052
+ : null
1053
+ }
1054
+ loadingFullTranscript={transcriptQuery.isFetching}
1055
+ searchInput={transcriptSearchInput}
1056
+ searchResults={transcriptSearchResults}
1057
+ showRawJson={showRawJson}
1058
+ sortOrder={sortOrder}
1059
+ snapshot={snapshot}
1060
+ transcript={transcript}
1061
+ transcriptFilters={transcriptFilters}
1062
+ onJumpToSearchResult={jumpToTranscriptSearchResult}
1063
+ onLoadFullThread={() => setShouldLoadTranscript(true)}
1064
+ onSearchInputChange={updateTranscriptSearchInput}
1065
+ onSortOrderChange={updateSortOrder}
1066
+ onTranscriptFilterChange={updateTranscriptFilter}
1067
+ />
584
1068
 
585
1069
  <TabsContent value="metadata">
586
1070
  <ThreadMetadataPanels snapshot={viewSnapshot} />