spiracha 2.1.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 (125) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +37 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +17 -10
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +1 -0
  7. package/apps/ui/src/components/app-shell.tsx +45 -3
  8. package/apps/ui/src/components/data-table.tsx +17 -1
  9. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  10. package/apps/ui/src/components/export-dialog.tsx +77 -62
  11. package/apps/ui/src/components/projects-table.tsx +4 -1
  12. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  13. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  14. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  15. package/apps/ui/src/components/threads-table.tsx +4 -1
  16. package/apps/ui/src/lib/antigravity-server.ts +70 -17
  17. package/apps/ui/src/lib/antigravity-transcript-events.ts +46 -70
  18. package/apps/ui/src/lib/claude-code-server.ts +14 -4
  19. package/apps/ui/src/lib/codex-queries.ts +3 -3
  20. package/apps/ui/src/lib/codex-server.ts +3 -3
  21. package/apps/ui/src/lib/cursor-server.ts +41 -69
  22. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  23. package/apps/ui/src/lib/delete-batch.ts +52 -0
  24. package/apps/ui/src/lib/error-presentation.ts +43 -0
  25. package/apps/ui/src/lib/export-mutation.ts +20 -0
  26. package/apps/ui/src/lib/export-options.ts +15 -0
  27. package/apps/ui/src/lib/grok-server.ts +2 -3
  28. package/apps/ui/src/lib/kiro-server.ts +14 -2
  29. package/apps/ui/src/lib/mutation-error.ts +15 -0
  30. package/apps/ui/src/lib/opencode-server.ts +13 -2
  31. package/apps/ui/src/lib/path-utils.ts +1 -1
  32. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  33. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  34. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  35. package/apps/ui/src/lib/settings-server.ts +33 -0
  36. package/apps/ui/src/lib/settings-store.tsx +82 -38
  37. package/apps/ui/src/lib/settings.ts +65 -0
  38. package/apps/ui/src/lib/source-session-export-server.ts +1 -1
  39. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  40. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  41. package/apps/ui/src/lib/workspace-delete-navigation.ts +8 -0
  42. package/apps/ui/src/routeTree.gen.ts +282 -282
  43. package/apps/ui/src/routes/__root.tsx +12 -15
  44. package/apps/ui/src/routes/analytics.tsx +10 -61
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +73 -19
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +42 -60
  47. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  48. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +25 -11
  49. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +27 -32
  50. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  51. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  52. package/apps/ui/src/routes/codex.index.tsx +5 -10
  53. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +29 -16
  54. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +33 -27
  55. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  56. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +25 -12
  57. package/apps/ui/src/routes/grok.$workspaceKey.tsx +20 -26
  58. package/apps/ui/src/routes/grok.index.tsx +2 -2
  59. package/apps/ui/src/routes/index.tsx +2 -18
  60. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +25 -12
  61. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +27 -32
  62. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  63. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +29 -36
  64. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +45 -61
  65. package/apps/ui/src/routes/opencode.index.tsx +8 -17
  66. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +12 -11
  67. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +19 -25
  68. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  69. package/apps/ui/src/routes/threads.$threadId.tsx +87 -56
  70. package/apps/ui/vite.config.ts +22 -12
  71. package/bin/spiracha.ts +28 -1
  72. package/package.json +26 -17
  73. package/src/client.ts +61 -16
  74. package/src/lib/antigravity-db.ts +236 -82
  75. package/src/lib/antigravity-keychain.ts +7 -8
  76. package/src/lib/antigravity-transcript-phase.ts +47 -0
  77. package/src/lib/claude-code-db.ts +45 -19
  78. package/src/lib/codex-analytics.ts +32 -5
  79. package/src/lib/codex-browser-db.ts +540 -137
  80. package/src/lib/codex-browser-export.ts +2 -7
  81. package/src/lib/codex-browser-types.ts +21 -1
  82. package/src/lib/codex-thread-cache.ts +88 -33
  83. package/src/lib/codex-thread-parser.ts +78 -35
  84. package/src/lib/codex-thread-recovery.ts +38 -11
  85. package/src/lib/codex-transcript-renderer.ts +49 -61
  86. package/src/lib/concurrency.ts +15 -3
  87. package/src/lib/conversation-api.ts +34 -31
  88. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  89. package/src/lib/conversation-data/antigravity-adapter.ts +54 -29
  90. package/src/lib/conversation-data/claude-code-adapter.ts +32 -6
  91. package/src/lib/conversation-data/codex-adapter.ts +91 -80
  92. package/src/lib/conversation-data/cursor-adapter.ts +48 -34
  93. package/src/lib/conversation-data/grok-adapter.ts +19 -6
  94. package/src/lib/conversation-data/index.ts +29 -10
  95. package/src/lib/conversation-data/kiro-adapter.ts +18 -5
  96. package/src/lib/conversation-data/message-selector.ts +2 -5
  97. package/src/lib/conversation-data/opencode-adapter.ts +43 -15
  98. package/src/lib/conversation-data/path-match.ts +1 -13
  99. package/src/lib/conversation-data/qoder-adapter.ts +23 -18
  100. package/src/lib/conversation-zip-export.ts +20 -2
  101. package/src/lib/cursor-db.ts +34 -23
  102. package/src/lib/cursor-id.ts +17 -0
  103. package/src/lib/cursor-recovery.ts +146 -35
  104. package/src/lib/cursor-transcript-phase.ts +40 -0
  105. package/src/lib/cursor-transcript.ts +21 -5
  106. package/src/lib/grok-db.ts +76 -34
  107. package/src/lib/grok-transcript-phase.ts +11 -2
  108. package/src/lib/grok-transcript.ts +15 -2
  109. package/src/lib/kiro-db.ts +161 -68
  110. package/src/lib/kiro-transcript.ts +0 -4
  111. package/src/lib/opencode-db.ts +4 -8
  112. package/src/lib/opencode-think-tags.ts +17 -3
  113. package/src/lib/portable-path.ts +9 -0
  114. package/src/lib/qoder-acp-client.ts +11 -2
  115. package/src/lib/qoder-db.ts +51 -17
  116. package/src/lib/qoder-transcript-phase.ts +64 -0
  117. package/src/lib/qoder-transcript.ts +30 -2
  118. package/src/lib/shared.ts +51 -15
  119. package/src/lib/sqlite-error.ts +31 -5
  120. package/src/lib/transcript-load-limiter.ts +8 -3
  121. package/src/lib/ui-cache.ts +152 -17
  122. package/src/lib/ui-export-archive.ts +14 -55
  123. package/src/lib/ui-export-files.ts +75 -15
  124. package/src/lib/ui-export-zip.ts +40 -0
  125. package/apps/ui/package.json +0 -65
@@ -1,5 +1,5 @@
1
1
  import type { OpenCodeSessionSummary, OpenCodeWorkspaceGroup } from '@spiracha/lib/opencode-exporter-types';
2
- import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
2
+ import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
3
3
  import { createFileRoute, useNavigate } from '@tanstack/react-router';
4
4
  import { Trash2 } from 'lucide-react';
5
5
  import { useDeferredValue, useMemo, useState } from 'react';
@@ -9,9 +9,10 @@ import { ListSearchInput } from '#/components/list-search-input';
9
9
  import { LoadingPanel } from '#/components/loading-panel';
10
10
  import { OpenCodeSessionsTable } from '#/components/opencode-sessions-table';
11
11
  import { PageHeader } from '#/components/page-header';
12
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
12
+ import { RouteErrorPanel } from '#/components/route-error-panel';
13
13
  import { Button } from '#/components/ui/button';
14
14
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
15
+ import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
15
16
  import { openCodeSessionsQueryOptions, openCodeWorkspacesQueryOptions } from '#/lib/opencode-queries';
16
17
  import {
17
18
  deleteOpenCodeSessionFn,
@@ -22,14 +23,6 @@ import {
22
23
  import { matchesTextQuery } from '#/lib/text-filter';
23
24
  import { isWorkspaceEmptiedByDelete } from '#/lib/workspace-delete-navigation';
24
25
 
25
- type ExportDialogOptions = {
26
- includeCommentary: boolean;
27
- includeMetadata: boolean;
28
- includeTools: boolean;
29
- outputFormat: 'md' | 'txt';
30
- zipArchive: boolean;
31
- };
32
-
33
26
  type PendingSessionDelete = {
34
27
  scope: 'all' | 'selected';
35
28
  sessions: OpenCodeSessionSummary[];
@@ -86,47 +79,36 @@ const getDeleteTitle = (pendingDelete: PendingSessionDelete | null) => {
86
79
  export const Route = createFileRoute('/opencode/$workspaceKey')({
87
80
  component: OpenCodeWorkspacePage,
88
81
  errorComponent: OpenCodeWorkspaceErrorComponent,
82
+ loader: async ({ context, params }) => {
83
+ const workspaces = await context.queryClient.ensureQueryData(openCodeWorkspacesQueryOptions());
84
+ findWorkspaceOrThrow(workspaces, params.workspaceKey);
85
+ await context.queryClient.ensureQueryData(openCodeSessionsQueryOptions(params.workspaceKey));
86
+ },
89
87
  pendingComponent: () => (
90
88
  <LoadingPanel description="Loading OpenCode sessions and transcript metadata." title="Loading workspace" />
91
89
  ),
92
90
  });
93
91
 
94
92
  function OpenCodeWorkspaceErrorComponent({ error }: { error: Error }) {
95
- return <ReloadErrorPanel description={error.message} title="Failed to load OpenCode workspace" />;
93
+ return <RouteErrorPanel error={error} title="Failed to load OpenCode workspace" />;
96
94
  }
97
95
 
98
- const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)));
99
-
100
- function OpenCodeWorkspacePage() {
101
- const params = Route.useParams();
102
- const workspacesQuery = useQuery(openCodeWorkspacesQueryOptions());
103
- const workspaces = workspacesQuery.data ?? [];
104
- const workspace = workspaces.find((candidate) => candidate.key === params.workspaceKey) ?? null;
105
- const sessionsQuery = useQuery(openCodeSessionsQueryOptions(workspace?.key ?? null));
106
-
107
- if (workspacesQuery.isLoading || (workspace && sessionsQuery.isLoading)) {
108
- return (
109
- <LoadingPanel description="Loading OpenCode sessions and transcript metadata." title="Loading workspace" />
110
- );
111
- }
112
-
113
- if (workspacesQuery.isError) {
114
- return <OpenCodeWorkspaceErrorComponent error={toError(workspacesQuery.error)} />;
115
- }
116
-
96
+ const findWorkspaceOrThrow = (workspaces: OpenCodeWorkspaceGroup[], workspaceKey: string) => {
97
+ const workspace = workspaces.find((candidate) => candidate.key === workspaceKey);
117
98
  if (!workspace) {
118
- return (
119
- <OpenCodeWorkspaceErrorComponent
120
- error={new Error(`OpenCode workspace not found: ${params.workspaceKey}`)}
121
- />
122
- );
99
+ throw new Error(`OpenCode workspace not found: ${workspaceKey}`);
123
100
  }
124
101
 
125
- if (sessionsQuery.isError) {
126
- return <OpenCodeWorkspaceErrorComponent error={toError(sessionsQuery.error)} />;
127
- }
102
+ return workspace;
103
+ };
128
104
 
129
- return <OpenCodeWorkspaceContent sessions={sessionsQuery.data ?? []} workspace={workspace} />;
105
+ function OpenCodeWorkspacePage() {
106
+ const params = Route.useParams();
107
+ const workspaces = useSuspenseQuery(openCodeWorkspacesQueryOptions()).data;
108
+ const workspace = findWorkspaceOrThrow(workspaces, params.workspaceKey);
109
+ const sessions = useSuspenseQuery(openCodeSessionsQueryOptions(workspace.key)).data;
110
+
111
+ return <OpenCodeWorkspaceContent sessions={sessions} workspace={workspace} />;
130
112
  }
131
113
 
132
114
  function OpenCodeWorkspaceContent({
@@ -144,20 +126,16 @@ function OpenCodeWorkspaceContent({
144
126
  const deferredSearch = useDeferredValue(searchInput);
145
127
 
146
128
  const exportMutation = useMutation({
147
- mutationFn: async (options: ExportDialogOptions) => {
148
- if (!pendingExport) {
149
- throw new Error('No OpenCode session selected for export');
150
- }
151
-
129
+ mutationFn: async ({ ids, options }: ExportSelectionMutationInput) => {
152
130
  const download =
153
- pendingExport.sessionIds.length === 1
131
+ ids.length === 1
154
132
  ? await exportOpenCodeSessionFn({
155
133
  data: {
156
134
  includeCommentary: options.includeCommentary,
157
135
  includeMetadata: options.includeMetadata,
158
136
  includeTools: options.includeTools,
159
137
  outputFormat: options.outputFormat,
160
- sessionId: pendingExport.sessionIds[0]!,
138
+ sessionId: ids[0]!,
161
139
  zipArchive: options.zipArchive,
162
140
  },
163
141
  })
@@ -167,7 +145,7 @@ function OpenCodeWorkspaceContent({
167
145
  includeMetadata: options.includeMetadata,
168
146
  includeTools: options.includeTools,
169
147
  outputFormat: options.outputFormat,
170
- sessionIds: pendingExport.sessionIds,
148
+ sessionIds: [...ids],
171
149
  zipArchive: options.zipArchive,
172
150
  },
173
151
  });
@@ -188,13 +166,7 @@ function OpenCodeWorkspaceContent({
188
166
  sessionIds.length === 1
189
167
  ? deleteOpenCodeSessionFn({ data: { sessionId: sessionIds[0]! } })
190
168
  : deleteOpenCodeSessionsFn({ data: { sessionIds } }),
191
- onSuccess: async (_result, sessionIds) => {
192
- const workspaceEmptied = isWorkspaceEmptiedByDelete(sessions, sessionIds, (session) => session.sessionId);
193
- setPendingDelete(null);
194
- if (workspaceEmptied) {
195
- await navigate({ to: '/opencode' });
196
- }
197
-
169
+ onSettled: async (_result, _error, sessionIds) => {
198
170
  await Promise.all([
199
171
  queryClient.invalidateQueries({ queryKey: ['opencode-workspaces'] }),
200
172
  queryClient.invalidateQueries({ queryKey: ['opencode-sessions', workspace.key] }),
@@ -203,6 +175,13 @@ function OpenCodeWorkspaceContent({
203
175
  ),
204
176
  ]);
205
177
  },
178
+ onSuccess: async (_result, sessionIds) => {
179
+ const workspaceEmptied = isWorkspaceEmptiedByDelete(sessions, sessionIds, (session) => session.sessionId);
180
+ setPendingDelete(null);
181
+ if (workspaceEmptied) {
182
+ await navigate({ to: '/opencode' });
183
+ }
184
+ },
206
185
  });
207
186
 
208
187
  const visibleSessions = useMemo(
@@ -279,11 +258,22 @@ function OpenCodeWorkspaceContent({
279
258
  />
280
259
 
281
260
  <ExportDialog
261
+ errorMessage={
262
+ exportMutation.isError
263
+ ? exportMutation.error instanceof Error
264
+ ? exportMutation.error.message
265
+ : 'Session export failed'
266
+ : null
267
+ }
282
268
  forceZipArchive={pendingExport ? pendingExport.sessionIds.length > 1 : false}
283
269
  open={pendingExport !== null}
284
270
  pending={exportMutation.isPending}
285
271
  title={pendingExport ? `Export ${pendingExport.label}` : 'Export session'}
286
- onExport={(options) => exportMutation.mutate(options)}
272
+ onExport={(options) => {
273
+ if (pendingExport) {
274
+ exportMutation.mutate(createExportSelectionMutationInput(pendingExport.sessionIds, options));
275
+ }
276
+ }}
287
277
  onOpenChange={(open) => {
288
278
  if (!open) {
289
279
  setPendingExport(null);
@@ -292,12 +282,6 @@ function OpenCodeWorkspaceContent({
292
282
  }}
293
283
  />
294
284
 
295
- {exportMutation.isError ? (
296
- <p className="text-[var(--destructive)] text-sm">
297
- {exportMutation.error instanceof Error ? exportMutation.error.message : 'Session export failed'}
298
- </p>
299
- ) : null}
300
-
301
285
  <DeleteConfirmDialog
302
286
  confirmLabel={getDeleteConfirmLabel(pendingDelete, deleteMutation.isPending)}
303
287
  description={getDeleteDescription(pendingDelete)}
@@ -1,40 +1,27 @@
1
- import { useQuery } from '@tanstack/react-query';
1
+ import { useSuspenseQuery } from '@tanstack/react-query';
2
2
  import { createFileRoute } from '@tanstack/react-router';
3
3
  import { useDeferredValue, useState } from 'react';
4
4
  import { ListSearchInput } from '#/components/list-search-input';
5
5
  import { LoadingPanel } from '#/components/loading-panel';
6
6
  import { OpenCodeWorkspacesTable } from '#/components/opencode-workspaces-table';
7
7
  import { PageHeader } from '#/components/page-header';
8
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
8
+ import { RouteErrorPanel } from '#/components/route-error-panel';
9
9
  import { openCodeWorkspacesQueryOptions } from '#/lib/opencode-queries';
10
10
  import { matchesTextQuery } from '#/lib/text-filter';
11
11
 
12
12
  const OpenCodeErrorComponent = ({ error }: { error: Error }) => {
13
- return <ReloadErrorPanel description={error.message} title="Failed to load OpenCode workspaces" />;
13
+ return <RouteErrorPanel error={error} title="Failed to load OpenCode workspaces" />;
14
14
  };
15
15
 
16
- const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)));
17
-
18
16
  const OpenCodePage = () => {
19
- const workspacesQuery = useQuery(openCodeWorkspacesQueryOptions());
17
+ const workspaces = useSuspenseQuery(openCodeWorkspacesQueryOptions()).data;
20
18
  const [searchInput, setSearchInput] = useState('');
21
19
  const deferredSearch = useDeferredValue(searchInput);
22
- const workspaces = workspacesQuery.data ?? [];
23
20
 
24
21
  const visibleWorkspaces = workspaces.filter((workspace) =>
25
22
  matchesTextQuery(deferredSearch, [workspace.label, workspace.worktree, workspace.key, workspace.projectId]),
26
23
  );
27
24
 
28
- if (workspacesQuery.isLoading) {
29
- return (
30
- <LoadingPanel description="Loading OpenCode workspaces and database metadata." title="Loading OpenCode" />
31
- );
32
- }
33
-
34
- if (workspacesQuery.isError) {
35
- return <OpenCodeErrorComponent error={toError(workspacesQuery.error)} />;
36
- }
37
-
38
25
  return (
39
26
  <div className="space-y-6">
40
27
  <PageHeader
@@ -58,4 +45,8 @@ const OpenCodePage = () => {
58
45
  export const Route = createFileRoute('/opencode/')({
59
46
  component: OpenCodePage,
60
47
  errorComponent: OpenCodeErrorComponent,
48
+ loader: ({ context }) => context.queryClient.ensureQueryData(openCodeWorkspacesQueryOptions()),
49
+ pendingComponent: () => (
50
+ <LoadingPanel description="Loading OpenCode workspaces and database metadata." title="Loading OpenCode" />
51
+ ),
61
52
  });
@@ -11,24 +11,18 @@ import { LoadingPanel } from '#/components/loading-panel';
11
11
  import { MetadataSection } from '#/components/metadata-section';
12
12
  import { MetricCard } from '#/components/metric-card';
13
13
  import { PageHeader } from '#/components/page-header';
14
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
14
+ import { RouteErrorPanel } from '#/components/route-error-panel';
15
15
  import { DEFAULT_SHOW_USER_MESSAGES, TranscriptView } from '#/components/transcript-view';
16
16
  import { Button } from '#/components/ui/button';
17
17
  import { Checkbox } from '#/components/ui/checkbox';
18
18
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
19
19
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
20
+ import type { ExportDialogOptions } from '#/lib/export-options';
20
21
  import { formatDateTime, formatList, formatNumber } from '#/lib/formatters';
21
22
  import { qoderSessionDetailQueryOptions } from '#/lib/qoder-queries';
22
23
  import { exportQoderSessionFn } from '#/lib/qoder-server';
23
24
  import { getQoderThreadTranscriptStats, qoderTranscriptToThreadEvents } from '#/lib/qoder-transcript-events';
24
-
25
- type ExportDialogOptions = {
26
- includeCommentary: boolean;
27
- includeMetadata: boolean;
28
- includeTools: boolean;
29
- outputFormat: 'md' | 'txt';
30
- zipArchive: boolean;
31
- };
25
+ import { RouteStateResetBoundary } from '#/lib/route-state-reset';
32
26
 
33
27
  type TranscriptControlsProps = {
34
28
  rawJsonDisabled?: boolean;
@@ -45,7 +39,7 @@ type TranscriptControlsProps = {
45
39
  };
46
40
 
47
41
  const QoderSessionDetailErrorComponent = ({ error }: { error: Error }) => {
48
- return <ReloadErrorPanel description={error.message} title="Failed to load Qoder session" />;
42
+ return <RouteErrorPanel error={error} title="Failed to load Qoder session" />;
49
43
  };
50
44
 
51
45
  const buildSessionMetadata = (detail: QoderSessionTranscript) => [
@@ -331,7 +325,14 @@ const QoderSessionDetailPage = () => {
331
325
  };
332
326
 
333
327
  export const Route = createFileRoute('/qoder-sessions/$sessionId')({
334
- component: QoderSessionDetailPage,
328
+ component: () => {
329
+ const { sessionId } = Route.useParams();
330
+ return (
331
+ <RouteStateResetBoundary routeKey={sessionId}>
332
+ <QoderSessionDetailPage />
333
+ </RouteStateResetBoundary>
334
+ );
335
+ },
335
336
  errorComponent: QoderSessionDetailErrorComponent,
336
337
  loader: ({ context, params }) =>
337
338
  context.queryClient.ensureQueryData(qoderSessionDetailQueryOptions(params.sessionId)),
@@ -7,20 +7,13 @@ import { ListSearchInput } from '#/components/list-search-input';
7
7
  import { LoadingPanel } from '#/components/loading-panel';
8
8
  import { PageHeader } from '#/components/page-header';
9
9
  import { QoderSessionsTable } from '#/components/qoder-sessions-table';
10
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
10
+ import { RouteErrorPanel } from '#/components/route-error-panel';
11
11
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
12
+ import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
12
13
  import { qoderSessionsQueryOptions, qoderWorkspacesQueryOptions } from '#/lib/qoder-queries';
13
14
  import { exportQoderSessionFn, exportQoderSessionsFn } from '#/lib/qoder-server';
14
15
  import { matchesTextQuery } from '#/lib/text-filter';
15
16
 
16
- type ExportDialogOptions = {
17
- includeCommentary: boolean;
18
- includeMetadata: boolean;
19
- includeTools: boolean;
20
- outputFormat: 'md' | 'txt';
21
- zipArchive: boolean;
22
- };
23
-
24
17
  type PendingSessionExport = {
25
18
  label: string;
26
19
  sessionIds: string[];
@@ -36,7 +29,7 @@ const findWorkspaceOrThrow = (workspaces: QoderWorkspaceGroup[], workspaceKey: s
36
29
  };
37
30
 
38
31
  const QoderWorkspaceErrorComponent = ({ error }: { error: Error }) => {
39
- return <ReloadErrorPanel description={error.message} title="Failed to load Qoder workspace" />;
32
+ return <RouteErrorPanel error={error} title="Failed to load Qoder workspace" />;
40
33
  };
41
34
 
42
35
  const QoderWorkspacePage = () => {
@@ -49,20 +42,16 @@ const QoderWorkspacePage = () => {
49
42
  const deferredSearch = useDeferredValue(searchInput);
50
43
 
51
44
  const exportMutation = useMutation({
52
- mutationFn: async (options: ExportDialogOptions) => {
53
- if (!pendingExport) {
54
- throw new Error('No Qoder session selected for export');
55
- }
56
-
45
+ mutationFn: async ({ ids, options }: ExportSelectionMutationInput) => {
57
46
  const download =
58
- pendingExport.sessionIds.length === 1
47
+ ids.length === 1
59
48
  ? await exportQoderSessionFn({
60
49
  data: {
61
50
  includeCommentary: options.includeCommentary,
62
51
  includeMetadata: options.includeMetadata,
63
52
  includeTools: options.includeTools,
64
53
  outputFormat: options.outputFormat,
65
- sessionId: pendingExport.sessionIds[0]!,
54
+ sessionId: ids[0]!,
66
55
  zipArchive: options.zipArchive,
67
56
  },
68
57
  })
@@ -72,7 +61,7 @@ const QoderWorkspacePage = () => {
72
61
  includeMetadata: options.includeMetadata,
73
62
  includeTools: options.includeTools,
74
63
  outputFormat: options.outputFormat,
75
- sessionIds: pendingExport.sessionIds,
64
+ sessionIds: [...ids],
76
65
  zipArchive: options.zipArchive,
77
66
  },
78
67
  });
@@ -150,11 +139,22 @@ const QoderWorkspacePage = () => {
150
139
  />
151
140
 
152
141
  <ExportDialog
142
+ errorMessage={
143
+ exportMutation.isError
144
+ ? exportMutation.error instanceof Error
145
+ ? exportMutation.error.message
146
+ : 'Session export failed'
147
+ : null
148
+ }
153
149
  forceZipArchive={pendingExport ? pendingExport.sessionIds.length > 1 : false}
154
150
  open={pendingExport !== null}
155
151
  pending={exportMutation.isPending}
156
152
  title={pendingExport ? `Export ${pendingExport.label}` : 'Export session'}
157
- onExport={(options) => exportMutation.mutate(options)}
153
+ onExport={(options) => {
154
+ if (pendingExport) {
155
+ exportMutation.mutate(createExportSelectionMutationInput(pendingExport.sessionIds, options));
156
+ }
157
+ }}
158
158
  onOpenChange={(open) => {
159
159
  if (!open) {
160
160
  setPendingExport(null);
@@ -162,12 +162,6 @@ const QoderWorkspacePage = () => {
162
162
  }
163
163
  }}
164
164
  />
165
-
166
- {exportMutation.isError ? (
167
- <p className="text-[var(--destructive)] text-sm">
168
- {exportMutation.error instanceof Error ? exportMutation.error.message : 'Session export failed'}
169
- </p>
170
- ) : null}
171
165
  </div>
172
166
  );
173
167
  };
@@ -4,12 +4,12 @@ import { useDeferredValue, useState } from 'react';
4
4
  import { ListSearchInput } from '#/components/list-search-input';
5
5
  import { PageHeader } from '#/components/page-header';
6
6
  import { QoderWorkspacesTable } from '#/components/qoder-workspaces-table';
7
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
7
+ import { RouteErrorPanel } from '#/components/route-error-panel';
8
8
  import { qoderWorkspacesQueryOptions } from '#/lib/qoder-queries';
9
9
  import { matchesTextQuery } from '#/lib/text-filter';
10
10
 
11
11
  const QoderErrorComponent = ({ error }: { error: Error }) => {
12
- return <ReloadErrorPanel description={error.message} title="Failed to load Qoder workspaces" />;
12
+ return <RouteErrorPanel error={error} title="Failed to load Qoder workspaces" />;
13
13
  };
14
14
 
15
15
  const QoderPage = () => {
@@ -17,6 +17,9 @@ import { LoadingPanel } from '#/components/loading-panel';
17
17
  import { MetadataSection } from '#/components/metadata-section';
18
18
  import { MetricCard } from '#/components/metric-card';
19
19
  import { PageHeader } from '#/components/page-header';
20
+ import { RouteErrorPanel } from '#/components/route-error-panel';
21
+ import { ThreadGoalsPanel } from '#/components/thread-goals-panel';
22
+ import { ThreadToolsPanel } from '#/components/thread-tools-panel';
20
23
  import {
21
24
  getTranscriptEventKey,
22
25
  shouldShowEvent,
@@ -43,13 +46,16 @@ import {
43
46
  formatModelLabel,
44
47
  formatTokens,
45
48
  } from '#/lib/formatters';
49
+ import { getMutationErrorMessage } from '#/lib/mutation-error';
46
50
  import { applyPathTransforms } from '#/lib/path-utils';
47
51
  import {
48
52
  parseThreadTranscriptSearch,
49
53
  type ThreadTranscriptSearch,
50
54
  withThreadTranscriptSearch,
51
55
  } from '#/lib/route-search';
56
+ import { RouteStateResetBoundary } from '#/lib/route-state-reset';
52
57
  import { useSettings } from '#/lib/settings-store';
58
+ import { formatSandboxPolicy } from '#/lib/thread-metadata';
53
59
  import { shouldLoadFullThreadTranscript } from '#/lib/thread-transcript-load';
54
60
 
55
61
  type ThreadSnapshotResponse = Awaited<ReturnType<typeof getThreadSnapshotFn>>;
@@ -242,6 +248,7 @@ const buildRuntimeItems = (snapshot: ThreadSnapshot) => {
242
248
  { label: 'Reasoning effort', value: snapshot.thread.reasoning_effort ?? 'n/a' },
243
249
  { label: 'CLI version', value: snapshot.thread.cli_version },
244
250
  { label: 'Approval mode', value: snapshot.thread.approval_mode },
251
+ { label: 'Sandbox policy', value: formatSandboxPolicy(snapshot.thread.sandbox_policy) },
245
252
  { label: 'Memory mode', value: snapshot.thread.memory_mode },
246
253
  { label: 'Has user event', value: formatBooleanLabel(Boolean(snapshot.thread.has_user_event)) },
247
254
  ];
@@ -494,29 +501,7 @@ function ThreadMetadataPanels({ snapshot }: ThreadMetadataProps) {
494
501
 
495
502
  <div className="space-y-4">
496
503
  <MetadataSection items={buildRelationItems(snapshot)} title="Relations and summary" />
497
-
498
- <section className="rounded-[1.6rem] border border-[var(--border)] bg-[var(--panel)] p-5 shadow-[var(--panel-shadow)]">
499
- <h3 className="font-semibold text-[var(--muted-foreground)] text-sm uppercase tracking-[0.18em]">
500
- Available tools
501
- </h3>
502
- <div className="mt-4 space-y-3">
503
- {snapshot.availableTools.map((tool) => (
504
- <div
505
- key={`${tool.name}-${tool.namespace ?? 'global'}`}
506
- className="rounded-xl border border-[var(--border)] bg-[var(--panel-secondary)] p-3.5"
507
- >
508
- <div className="flex flex-wrap items-center gap-2">
509
- <p className="font-medium font-mono text-sm">{tool.name}</p>
510
- {tool.namespace ? <Badge variant="outline">{tool.namespace}</Badge> : null}
511
- </div>
512
- <p className="mt-1.5 text-[var(--muted-foreground)] text-sm">
513
- {tool.description || 'No description.'}
514
- </p>
515
- </div>
516
- ))}
517
- </div>
518
- </section>
519
-
504
+ <ThreadGoalsPanel goals={snapshot.goals} />
520
505
  <MetadataSection items={buildTranscriptStatsItems(snapshot)} title="Transcript stats" />
521
506
  </div>
522
507
  </div>
@@ -609,24 +594,7 @@ function LargeThreadPreviewNotice({
609
594
  }
610
595
 
611
596
  function ThreadErrorComponent({ error }: { error: Error }) {
612
- const isSqlite = error.message.includes('unable to open database') || error.message.includes('database is locked');
613
- return (
614
- <div className="rounded-xl border border-[var(--border)] bg-[var(--panel)] px-6 py-10 text-center">
615
- <p className="font-medium text-[var(--destructive)] text-sm">
616
- {isSqlite ? 'Database unavailable' : 'Failed to load thread'}
617
- </p>
618
- <p className="mt-2 text-[var(--muted-foreground)] text-sm">
619
- {isSqlite ? 'Codex may have an exclusive lock on the database. Reload to retry.' : error.message}
620
- </p>
621
- <button
622
- className="mt-4 text-[var(--accent)] text-sm underline-offset-2 hover:underline"
623
- type="button"
624
- onClick={() => window.location.reload()}
625
- >
626
- Reload
627
- </button>
628
- </div>
629
- );
597
+ return <RouteErrorPanel error={error} title="Failed to load thread" />;
630
598
  }
631
599
 
632
600
  const getThreadExportErrorMessage = (transcriptMissing: boolean, error: unknown): string | null => {
@@ -640,16 +608,7 @@ const getThreadExportErrorMessage = (transcriptMissing: boolean, error: unknown)
640
608
  export const Route = createFileRoute('/threads/$threadId')({
641
609
  component: ThreadDetailPage,
642
610
  errorComponent: ThreadErrorComponent,
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
- }),
611
+ loader: ({ context, params }) => context.queryClient.ensureQueryData(threadSnapshotQueryOptions(params.threadId)),
653
612
  pendingComponent: () => (
654
613
  <LoadingPanel
655
614
  description="Loading the transcript, metadata, and parsed event stream for this thread."
@@ -811,11 +770,47 @@ function ThreadTranscriptTab({
811
770
  );
812
771
  }
813
772
 
814
- function ThreadDetailPage() {
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() {
815
810
  const { navigate, search, transcriptFilters, updateTranscriptSearch } = useThreadTranscriptRouteSearch();
816
811
  const queryClient = useQueryClient();
817
812
  const params = Route.useParams();
818
- const snapshot = useSuspenseQuery(threadSnapshotQueryOptions(params.threadId, transcriptFilters)).data;
813
+ const snapshot = useSuspenseQuery(threadSnapshotQueryOptions(params.threadId)).data;
819
814
  const { settings } = useSettings();
820
815
  const transcriptMissing = snapshot.transcriptState === 'missing';
821
816
  const [shouldLoadTranscript, setShouldLoadTranscript] = useState(
@@ -959,6 +954,9 @@ function ThreadDetailPage() {
959
954
  },
960
955
  }),
961
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] });
962
960
  await Promise.all([
963
961
  queryClient.invalidateQueries({ queryKey: ['analytics'] }),
964
962
  queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
@@ -1027,10 +1025,13 @@ function ThreadDetailPage() {
1027
1025
  </div>
1028
1026
 
1029
1027
  <Tabs className="space-y-4" defaultValue="transcript">
1030
- <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]">
1031
1029
  <TabsTrigger className="rounded-full px-5 text-sm" value="transcript">
1032
1030
  Transcript
1033
1031
  </TabsTrigger>
1032
+ <TabsTrigger className="rounded-full px-5 text-sm" value="tools">
1033
+ Tools
1034
+ </TabsTrigger>
1034
1035
  <TabsTrigger className="rounded-full px-5 text-sm" value="metadata">
1035
1036
  Metadata
1036
1037
  </TabsTrigger>
@@ -1066,6 +1067,16 @@ function ThreadDetailPage() {
1066
1067
  onTranscriptFilterChange={updateTranscriptFilter}
1067
1068
  />
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
+ />
1079
+
1069
1080
  <TabsContent value="metadata">
1070
1081
  <ThreadMetadataPanels snapshot={viewSnapshot} />
1071
1082
  </TabsContent>
@@ -1079,11 +1090,17 @@ function ThreadDetailPage() {
1079
1090
  confirmLabel={deleteThreadMutation.isPending ? 'Deleting...' : 'Delete thread'}
1080
1091
  defaultDeleteSessionFiles
1081
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')}
1082
1094
  open={deleteOpen}
1083
1095
  showDeleteSessionFilesOption
1084
1096
  title="Delete this thread from Codex DB?"
1085
1097
  onConfirm={({ deleteSessionFiles }) => deleteThreadMutation.mutate({ deleteSessionFiles })}
1086
- onOpenChange={setDeleteOpen}
1098
+ onOpenChange={(open) => {
1099
+ setDeleteOpen(open);
1100
+ if (!open) {
1101
+ deleteThreadMutation.reset();
1102
+ }
1103
+ }}
1087
1104
  />
1088
1105
 
1089
1106
  <ExportDialog
@@ -1096,8 +1113,22 @@ function ThreadDetailPage() {
1096
1113
  exportThreadMutation.mutate(options);
1097
1114
  }
1098
1115
  }}
1099
- onOpenChange={setExportOpen}
1116
+ onOpenChange={(open) => {
1117
+ setExportOpen(open);
1118
+ if (!open) {
1119
+ exportThreadMutation.reset();
1120
+ }
1121
+ }}
1100
1122
  />
1101
1123
  </div>
1102
1124
  );
1103
1125
  }
1126
+
1127
+ function ThreadDetailPage() {
1128
+ const { threadId } = Route.useParams();
1129
+ return (
1130
+ <RouteStateResetBoundary routeKey={threadId}>
1131
+ <ThreadDetailPageContent />
1132
+ </RouteStateResetBoundary>
1133
+ );
1134
+ }