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
@@ -8,7 +8,7 @@ import { ExportDialog } from '#/components/export-dialog';
8
8
  import { ListSearchInput } from '#/components/list-search-input';
9
9
  import { LoadingPanel } from '#/components/loading-panel';
10
10
  import { PageHeader } from '#/components/page-header';
11
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
11
+ import { RouteErrorPanel } from '#/components/route-error-panel';
12
12
  import { claudeCodeSessionsQueryOptions, claudeCodeWorkspacesQueryOptions } from '#/lib/claude-code-queries';
13
13
  import {
14
14
  deleteClaudeCodeSessionFn,
@@ -17,17 +17,10 @@ import {
17
17
  exportClaudeCodeSessionsFn,
18
18
  } from '#/lib/claude-code-server';
19
19
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
20
+ import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
20
21
  import { matchesTextQuery } from '#/lib/text-filter';
21
22
  import { isWorkspaceEmptiedByDelete } from '#/lib/workspace-delete-navigation';
22
23
 
23
- type ExportDialogOptions = {
24
- includeCommentary: boolean;
25
- includeMetadata: boolean;
26
- includeTools: boolean;
27
- outputFormat: 'md' | 'txt';
28
- zipArchive: boolean;
29
- };
30
-
31
24
  type PendingSessionDelete = {
32
25
  sessions: ClaudeCodeSessionSummary[];
33
26
  };
@@ -90,7 +83,7 @@ export const Route = createFileRoute('/claude-code/$workspaceKey')({
90
83
  });
91
84
 
92
85
  function ClaudeCodeWorkspaceErrorComponent({ error }: { error: Error }) {
93
- return <ReloadErrorPanel description={error.message} title="Failed to load Claude Code workspace" />;
86
+ return <RouteErrorPanel error={error} title="Failed to load Claude Code workspace" />;
94
87
  }
95
88
 
96
89
  function ClaudeCodeWorkspacePage() {
@@ -106,20 +99,16 @@ function ClaudeCodeWorkspacePage() {
106
99
  const deferredSearch = useDeferredValue(searchInput);
107
100
 
108
101
  const exportMutation = useMutation({
109
- mutationFn: async (options: ExportDialogOptions) => {
110
- if (!pendingExport) {
111
- throw new Error('No Claude Code session selected for export');
112
- }
113
-
102
+ mutationFn: async ({ ids, options }: ExportSelectionMutationInput) => {
114
103
  const download =
115
- pendingExport.sessionIds.length === 1
104
+ ids.length === 1
116
105
  ? await exportClaudeCodeSessionFn({
117
106
  data: {
118
107
  includeCommentary: options.includeCommentary,
119
108
  includeMetadata: options.includeMetadata,
120
109
  includeTools: options.includeTools,
121
110
  outputFormat: options.outputFormat,
122
- sessionId: pendingExport.sessionIds[0]!,
111
+ sessionId: ids[0]!,
123
112
  zipArchive: options.zipArchive,
124
113
  },
125
114
  })
@@ -129,7 +118,7 @@ function ClaudeCodeWorkspacePage() {
129
118
  includeMetadata: options.includeMetadata,
130
119
  includeTools: options.includeTools,
131
120
  outputFormat: options.outputFormat,
132
- sessionIds: pendingExport.sessionIds,
121
+ sessionIds: [...ids],
133
122
  zipArchive: options.zipArchive,
134
123
  },
135
124
  });
@@ -150,13 +139,7 @@ function ClaudeCodeWorkspacePage() {
150
139
  sessionIds.length === 1
151
140
  ? deleteClaudeCodeSessionFn({ data: { sessionId: sessionIds[0]! } })
152
141
  : deleteClaudeCodeSessionsFn({ data: { sessionIds } }),
153
- onSuccess: async (_result, sessionIds) => {
154
- const workspaceEmptied = isWorkspaceEmptiedByDelete(sessions, sessionIds, (session) => session.sessionId);
155
- setPendingDelete(null);
156
- if (workspaceEmptied) {
157
- await navigate({ to: '/claude-code' });
158
- }
159
-
142
+ onSettled: async (_result, _error, sessionIds) => {
160
143
  await Promise.all([
161
144
  queryClient.invalidateQueries({ queryKey: ['claude-code-workspaces'] }),
162
145
  queryClient.invalidateQueries({ queryKey: ['claude-code-sessions', workspace.key] }),
@@ -165,6 +148,13 @@ function ClaudeCodeWorkspacePage() {
165
148
  ),
166
149
  ]);
167
150
  },
151
+ onSuccess: async (_result, sessionIds) => {
152
+ const workspaceEmptied = isWorkspaceEmptiedByDelete(sessions, sessionIds, (session) => session.sessionId);
153
+ setPendingDelete(null);
154
+ if (workspaceEmptied) {
155
+ await navigate({ to: '/claude-code' });
156
+ }
157
+ },
168
158
  });
169
159
 
170
160
  const visibleSessions = useMemo(
@@ -226,11 +216,22 @@ function ClaudeCodeWorkspacePage() {
226
216
  />
227
217
 
228
218
  <ExportDialog
219
+ errorMessage={
220
+ exportMutation.isError
221
+ ? exportMutation.error instanceof Error
222
+ ? exportMutation.error.message
223
+ : 'Session export failed'
224
+ : null
225
+ }
229
226
  forceZipArchive={pendingExport ? pendingExport.sessionIds.length > 1 : false}
230
227
  open={pendingExport !== null}
231
228
  pending={exportMutation.isPending}
232
229
  title={pendingExport ? `Export ${pendingExport.label}` : 'Export session'}
233
- onExport={(options) => exportMutation.mutate(options)}
230
+ onExport={(options) => {
231
+ if (pendingExport) {
232
+ exportMutation.mutate(createExportSelectionMutationInput(pendingExport.sessionIds, options));
233
+ }
234
+ }}
234
235
  onOpenChange={(open) => {
235
236
  if (!open) {
236
237
  setPendingExport(null);
@@ -239,12 +240,6 @@ function ClaudeCodeWorkspacePage() {
239
240
  }}
240
241
  />
241
242
 
242
- {exportMutation.isError ? (
243
- <p className="text-[var(--destructive)] text-sm">
244
- {exportMutation.error instanceof Error ? exportMutation.error.message : 'Session export failed'}
245
- </p>
246
- ) : null}
247
-
248
243
  <DeleteConfirmDialog
249
244
  confirmLabel={getDeleteConfirmLabel(pendingDelete, deleteMutation.isPending)}
250
245
  description={getDeleteDescription(pendingDelete)}
@@ -4,7 +4,7 @@ import { useDeferredValue, useState } from 'react';
4
4
  import { ClaudeCodeWorkspacesTable } from '#/components/claude-code-workspaces-table';
5
5
  import { ListSearchInput } from '#/components/list-search-input';
6
6
  import { PageHeader } from '#/components/page-header';
7
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
7
+ import { RouteErrorPanel } from '#/components/route-error-panel';
8
8
  import { claudeCodeWorkspacesQueryOptions } from '#/lib/claude-code-queries';
9
9
  import { matchesTextQuery } from '#/lib/text-filter';
10
10
 
@@ -15,7 +15,7 @@ export const Route = createFileRoute('/claude-code/')({
15
15
  });
16
16
 
17
17
  function ClaudeCodeErrorComponent({ error }: { error: Error }) {
18
- return <ReloadErrorPanel description={error.message} title="Failed to load Claude Code workspaces" />;
18
+ return <RouteErrorPanel error={error} title="Failed to load Claude Code workspaces" />;
19
19
  }
20
20
 
21
21
  function ClaudeCodePage() {
@@ -8,7 +8,7 @@ import { ExportDialog } from '#/components/export-dialog';
8
8
  import { ListSearchInput } from '#/components/list-search-input';
9
9
  import { LoadingPanel } from '#/components/loading-panel';
10
10
  import { PageHeader } from '#/components/page-header';
11
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
11
+ import { RouteErrorPanel } from '#/components/route-error-panel';
12
12
  import { ThreadsTable } from '#/components/threads-table';
13
13
  import { Button } from '#/components/ui/button';
14
14
  import { projectThreadsQueryOptions } from '#/lib/codex-queries';
@@ -20,6 +20,8 @@ import {
20
20
  recoverProjectThreadsFn,
21
21
  } from '#/lib/codex-server';
22
22
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
23
+ import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
24
+ import { getMutationErrorMessage } from '#/lib/mutation-error';
23
25
  import { parseTextQuerySearch, withTextQuerySearch } from '#/lib/route-search';
24
26
  import { useSettings } from '#/lib/settings-store';
25
27
  import { matchesTextQuery } from '#/lib/text-filter';
@@ -84,15 +86,7 @@ export const Route = createFileRoute('/codex/$project')({
84
86
  });
85
87
 
86
88
  function ProjectDetailErrorComponent({ error }: { error: Error }) {
87
- const isSqlite = error.message.includes('unable to open database') || error.message.includes('database is locked');
88
- return (
89
- <ReloadErrorPanel
90
- description={
91
- isSqlite ? 'Codex may have an exclusive lock on the database. Reload to retry.' : error.message
92
- }
93
- title={isSqlite ? 'Database unavailable' : 'Failed to load Codex project'}
94
- />
95
- );
89
+ return <RouteErrorPanel error={error} title="Failed to load Codex project" />;
96
90
  }
97
91
 
98
92
  function ProjectThreadsLoadingState({ project }: { project: string }) {
@@ -112,6 +106,14 @@ function ProjectThreadsLoadingState({ project }: { project: string }) {
112
106
 
113
107
  const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)));
114
108
 
109
+ const getThreadExportErrorMessage = (error: unknown): string | null => {
110
+ if (!error) {
111
+ return null;
112
+ }
113
+
114
+ return error instanceof Error ? error.message : 'Thread export failed';
115
+ };
116
+
115
117
  function ProjectDetailPage() {
116
118
  const navigate = useNavigate({ from: Route.fullPath });
117
119
  const params = Route.useParams();
@@ -168,39 +170,29 @@ function ProjectDetailPage() {
168
170
  });
169
171
 
170
172
  const exportThreadMutation = useMutation({
171
- mutationFn: async (options: {
172
- includeCommentary: boolean;
173
- includeTools: boolean;
174
- includeMetadata: boolean;
175
- outputFormat: 'md' | 'txt';
176
- zipArchive: boolean;
177
- }) => {
178
- if (!pendingExport) {
179
- throw new Error('No thread selected for export');
180
- }
181
-
173
+ mutationFn: async ({ ids, options }: ExportSelectionMutationInput) => {
182
174
  console.info('[spiracha:export-ui] request', {
183
175
  outputFormat: options.outputFormat,
184
176
  project,
185
- selectedThreadCount: pendingExport.threadIds.length,
186
- selectedThreadIds: pendingExport.threadIds,
177
+ selectedThreadCount: ids.length,
178
+ selectedThreadIds: ids,
187
179
  zipArchive: options.zipArchive,
188
180
  });
189
181
 
190
182
  const download =
191
- pendingExport.threadIds.length === 1
183
+ ids.length === 1
192
184
  ? await exportThreadFn({
193
185
  data: {
194
186
  ...options,
195
187
  ...settings,
196
- threadId: pendingExport.threadIds[0]!,
188
+ threadId: ids[0]!,
197
189
  },
198
190
  })
199
191
  : await exportThreadsFn({
200
192
  data: {
201
193
  ...options,
202
194
  ...settings,
203
- threadIds: pendingExport.threadIds,
195
+ threadIds: [...ids],
204
196
  },
205
197
  });
206
198
 
@@ -209,7 +201,7 @@ function ProjectDetailPage() {
209
201
  fileName: download.fileName,
210
202
  mode: download.mode,
211
203
  project,
212
- selectedThreadCount: pendingExport.threadIds.length,
204
+ selectedThreadCount: ids.length,
213
205
  });
214
206
 
215
207
  if (download.mode === 'download') {
@@ -219,12 +211,12 @@ function ProjectDetailPage() {
219
211
 
220
212
  await downloadUrlFile(download.fileName, download.downloadUrl);
221
213
  },
222
- onError: (error) => {
214
+ onError: (error, variables) => {
223
215
  console.error('[spiracha:export-ui] failed', {
224
216
  error: error instanceof Error ? error.message : String(error),
225
217
  project,
226
- selectedThreadCount: pendingExport?.threadIds.length ?? 0,
227
- selectedThreadIds: pendingExport?.threadIds ?? [],
218
+ selectedThreadCount: variables.ids.length,
219
+ selectedThreadIds: variables.ids,
228
220
  });
229
221
  },
230
222
  onSuccess: () => {
@@ -354,6 +346,7 @@ function ProjectDetailPage() {
354
346
  confirmLabel={getDeleteConfirmLabel(pendingDelete, deleteThreadMutation.isPending)}
355
347
  defaultDeleteSessionFiles
356
348
  description={getDeleteDescription(pendingDelete)}
349
+ errorMessage={getMutationErrorMessage(deleteThreadMutation.error, 'Thread delete failed')}
357
350
  open={pendingDelete !== null}
358
351
  showDeleteSessionFilesOption
359
352
  title={getDeleteTitle(pendingDelete)}
@@ -369,19 +362,28 @@ function ProjectDetailPage() {
369
362
  onOpenChange={(open) => {
370
363
  if (!open) {
371
364
  setPendingDelete(null);
365
+ deleteThreadMutation.reset();
372
366
  }
373
367
  }}
374
368
  />
375
369
 
376
370
  <ExportDialog
371
+ errorMessage={getThreadExportErrorMessage(exportThreadMutation.error)}
377
372
  forceZipArchive={shouldForceZipArchive(pendingExport)}
378
373
  open={pendingExport !== null}
379
374
  pending={exportThreadMutation.isPending}
380
375
  title={pendingExport ? `Export ${pendingExport.threadLabel}` : 'Export thread'}
381
- onExport={(options) => exportThreadMutation.mutate(options)}
376
+ onExport={(options) => {
377
+ if (pendingExport) {
378
+ exportThreadMutation.mutate(
379
+ createExportSelectionMutationInput(pendingExport.threadIds, options),
380
+ );
381
+ }
382
+ }}
382
383
  onOpenChange={(open) => {
383
384
  if (!open) {
384
385
  setPendingExport(null);
386
+ exportThreadMutation.reset();
385
387
  }
386
388
  }}
387
389
  />
@@ -7,9 +7,10 @@ import { ListSearchInput } from '#/components/list-search-input';
7
7
  import { PageHeader } from '#/components/page-header';
8
8
  import { ProjectsLoadingState } from '#/components/projects-loading-state';
9
9
  import { ProjectsTable } from '#/components/projects-table';
10
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
10
+ import { RouteErrorPanel } from '#/components/route-error-panel';
11
11
  import { projectsQueryOptions } from '#/lib/codex-queries';
12
12
  import { deleteProjectFn } from '#/lib/codex-server';
13
+ import { getMutationErrorMessage } from '#/lib/mutation-error';
13
14
  import { parseTextQuerySearch, withTextQuerySearch } from '#/lib/route-search';
14
15
  import { matchesTextQuery } from '#/lib/text-filter';
15
16
 
@@ -22,15 +23,7 @@ export const Route = createFileRoute('/codex/')({
22
23
  });
23
24
 
24
25
  function ProjectsErrorComponent({ error }: { error: Error }) {
25
- const isSqlite = error.message.includes('unable to open database') || error.message.includes('database is locked');
26
- return (
27
- <ReloadErrorPanel
28
- description={
29
- isSqlite ? 'Codex may have an exclusive lock on the database. Reload to retry.' : error.message
30
- }
31
- title={isSqlite ? 'Database unavailable' : 'Failed to load Codex inventory'}
32
- />
33
- );
26
+ return <RouteErrorPanel error={error} title="Failed to load Codex inventory" />;
34
27
  }
35
28
 
36
29
  function ProjectsPage() {
@@ -93,6 +86,7 @@ function ProjectsPage() {
93
86
  ? `Delete ${pendingDelete.threadCount} thread records for the derived project "${pendingDelete.name}" from the Codex database. Enable Delete Session files to remove the rollout JSONL files too.`
94
87
  : ''
95
88
  }
89
+ errorMessage={getMutationErrorMessage(deleteProjectMutation.error, 'Project delete failed')}
96
90
  open={pendingDelete !== null}
97
91
  showDeleteSessionFilesOption
98
92
  title="Delete Codex project?"
@@ -108,6 +102,7 @@ function ProjectsPage() {
108
102
  onOpenChange={(open) => {
109
103
  if (!open) {
110
104
  setPendingDelete(null);
105
+ deleteProjectMutation.reset();
111
106
  }
112
107
  }}
113
108
  />
@@ -11,27 +11,22 @@ 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
- import { cursorThreadDetailQueryOptions } from '#/lib/cursor-queries';
19
+ import { cursorThreadDetailQueryOptions, cursorWorkspacesQueryOptions } from '#/lib/cursor-queries';
20
20
  import { deleteCursorThreadsFn, exportCursorThreadFn, type getCursorThreadDetailFn } from '#/lib/cursor-server';
21
21
  import { cursorTranscriptToThreadEvents, getCursorThreadTranscriptStats } from '#/lib/cursor-transcript-events';
22
22
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
23
+ import type { ExportDialogOptions } from '#/lib/export-options';
23
24
  import { formatBytes, formatDateTime, formatList, formatNumber } from '#/lib/formatters';
25
+ import { RouteStateResetBoundary } from '#/lib/route-state-reset';
26
+ import { shouldNavigateToSourceIndexAfterDelete } from '#/lib/workspace-delete-navigation';
24
27
 
25
28
  type CursorThreadDetail = Awaited<ReturnType<typeof getCursorThreadDetailFn>>;
26
29
 
27
- type ExportDialogOptions = {
28
- includeCommentary: boolean;
29
- includeMetadata: boolean;
30
- includeTools: boolean;
31
- outputFormat: 'md' | 'txt';
32
- zipArchive: boolean;
33
- };
34
-
35
30
  type TranscriptControlsProps = {
36
31
  rawJsonDisabled?: boolean;
37
32
  showCommentary: boolean;
@@ -208,7 +203,7 @@ const CursorThreadRawPanels = ({ detail, events }: { detail: CursorThreadDetail;
208
203
  };
209
204
 
210
205
  const CursorThreadDetailErrorComponent = ({ error }: { error: Error }) => {
211
- return <ReloadErrorPanel description={error.message} title="Failed to load Cursor thread" />;
206
+ return <RouteErrorPanel error={error} title="Failed to load Cursor thread" />;
212
207
  };
213
208
 
214
209
  const CursorThreadDetailPage = () => {
@@ -231,15 +226,26 @@ const CursorThreadDetailPage = () => {
231
226
  const deleteThreadMutation = useMutation({
232
227
  mutationFn: () => deleteCursorThreadsFn({ data: { composerIds: [detail.thread.composerId] } }),
233
228
  onSuccess: async () => {
234
- await navigate({
235
- params: { workspaceKey: detail.thread.workspaceKey },
236
- to: '/cursor/$workspaceKey',
237
- });
238
229
  await Promise.all([
239
230
  queryClient.invalidateQueries({ queryKey: ['cursor-thread', detail.thread.composerId] }),
240
231
  queryClient.invalidateQueries({ queryKey: ['cursor-threads', detail.thread.workspaceKey] }),
241
232
  queryClient.invalidateQueries({ queryKey: ['cursor-workspaces'] }),
242
233
  ]);
234
+ const workspaces = await queryClient.fetchQuery(cursorWorkspacesQueryOptions());
235
+ if (
236
+ shouldNavigateToSourceIndexAfterDelete(
237
+ workspaces,
238
+ detail.thread.workspaceKey,
239
+ (workspace) => workspace.key,
240
+ )
241
+ ) {
242
+ await navigate({ to: '/cursor' });
243
+ return;
244
+ }
245
+ await navigate({
246
+ params: { workspaceKey: detail.thread.workspaceKey },
247
+ to: '/cursor/$workspaceKey',
248
+ });
243
249
  },
244
250
  });
245
251
 
@@ -422,7 +428,14 @@ const CursorThreadDetailPage = () => {
422
428
  };
423
429
 
424
430
  export const Route = createFileRoute('/cursor-threads/$composerId')({
425
- component: CursorThreadDetailPage,
431
+ component: () => {
432
+ const { composerId } = Route.useParams();
433
+ return (
434
+ <RouteStateResetBoundary routeKey={composerId}>
435
+ <CursorThreadDetailPage />
436
+ </RouteStateResetBoundary>
437
+ );
438
+ },
426
439
  errorComponent: CursorThreadDetailErrorComponent,
427
440
  loader: ({ context, params }) =>
428
441
  context.queryClient.ensureQueryData(cursorThreadDetailQueryOptions(params.composerId)),
@@ -9,7 +9,7 @@ import { ExportDialog } from '#/components/export-dialog';
9
9
  import { ListSearchInput } from '#/components/list-search-input';
10
10
  import { LoadingPanel } from '#/components/loading-panel';
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 { cursorThreadsQueryOptions, cursorWorkspacesQueryOptions } from '#/lib/cursor-queries';
15
15
  import {
@@ -20,6 +20,8 @@ import {
20
20
  recoverCursorWorkspaceFn,
21
21
  } from '#/lib/cursor-server';
22
22
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
23
+ import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
24
+ import { getMutationErrorMessage } from '#/lib/mutation-error';
23
25
  import { matchesTextQuery } from '#/lib/text-filter';
24
26
  import { isWorkspaceEmptiedByDelete } from '#/lib/workspace-delete-navigation';
25
27
 
@@ -32,14 +34,6 @@ type PendingCursorExport = {
32
34
  label: string;
33
35
  };
34
36
 
35
- type ExportDialogOptions = {
36
- includeCommentary: boolean;
37
- includeMetadata: boolean;
38
- includeTools: boolean;
39
- outputFormat: 'md' | 'txt';
40
- zipArchive: boolean;
41
- };
42
-
43
37
  const findWorkspaceOrThrow = (workspaces: CursorWorkspaceGroup[], workspaceKey: string) => {
44
38
  const workspace = workspaces.find((candidate) => candidate.key === workspaceKey);
45
39
  if (!workspace) {
@@ -114,7 +108,7 @@ const getCursorDeleteTitle = (pendingDelete: PendingCursorDelete | null) => {
114
108
  };
115
109
 
116
110
  const CursorWorkspaceErrorComponent = ({ error }: { error: Error }) => {
117
- return <ReloadErrorPanel description={error.message} title="Failed to load Cursor workspace" />;
111
+ return <RouteErrorPanel error={error} title="Failed to load Cursor workspace" />;
118
112
  };
119
113
 
120
114
  const CursorWorkspacePage = () => {
@@ -149,6 +143,7 @@ const CursorWorkspacePage = () => {
149
143
  onSuccess: async (_result, target) => {
150
144
  if (target.kind === 'workspace') {
151
145
  await navigate({ to: '/cursor' });
146
+ queryClient.removeQueries({ queryKey: ['cursor-thread'] });
152
147
  await queryClient.invalidateQueries({ queryKey: ['cursor-workspaces'] });
153
148
  setPendingDelete(null);
154
149
  return;
@@ -159,6 +154,9 @@ const CursorWorkspacePage = () => {
159
154
  target.threads.map((thread) => thread.composerId),
160
155
  (thread) => thread.composerId,
161
156
  );
157
+ for (const thread of target.threads) {
158
+ queryClient.removeQueries({ queryKey: ['cursor-thread', thread.composerId] });
159
+ }
162
160
  setPendingDelete(null);
163
161
  if (workspaceEmptied) {
164
162
  await navigate({ to: '/cursor' });
@@ -169,23 +167,19 @@ const CursorWorkspacePage = () => {
169
167
  });
170
168
 
171
169
  const exportMutation = useMutation({
172
- mutationFn: async (options: ExportDialogOptions) => {
173
- if (!pendingExport) {
174
- throw new Error('No thread selected for export');
175
- }
176
-
170
+ mutationFn: async ({ ids, options }: ExportSelectionMutationInput) => {
177
171
  const download =
178
- pendingExport.composerIds.length === 1
172
+ ids.length === 1
179
173
  ? await exportCursorThreadFn({
180
174
  data: {
181
175
  ...options,
182
- composerId: pendingExport.composerIds[0]!,
176
+ composerId: ids[0]!,
183
177
  },
184
178
  })
185
179
  : await exportCursorThreadsFn({
186
180
  data: {
187
181
  ...options,
188
- composerIds: pendingExport.composerIds,
182
+ composerIds: [...ids],
189
183
  },
190
184
  });
191
185
 
@@ -253,11 +247,11 @@ const CursorWorkspacePage = () => {
253
247
 
254
248
  <CursorWorkspaceErrors
255
249
  deleteError={deleteMutation.isError ? deleteMutation.error : null}
256
- exportError={exportMutation.isError ? exportMutation.error : null}
257
250
  recoverError={recoverWorkspaceMutation.isError ? recoverWorkspaceMutation.error : null}
258
251
  />
259
252
 
260
253
  <CursorWorkspaceDeleteDialog
254
+ errorMessage={getMutationErrorMessage(deleteMutation.error, 'Delete failed')}
261
255
  pending={deleteMutation.isPending}
262
256
  pendingDelete={pendingDelete}
263
257
  onConfirm={() => {
@@ -270,19 +264,32 @@ const CursorWorkspacePage = () => {
270
264
  onOpenChange={(open) => {
271
265
  if (!open) {
272
266
  setPendingDelete(null);
267
+ deleteMutation.reset();
273
268
  }
274
269
  }}
275
270
  />
276
271
 
277
272
  <ExportDialog
273
+ errorMessage={
274
+ exportMutation.isError
275
+ ? exportMutation.error instanceof Error
276
+ ? exportMutation.error.message
277
+ : 'Thread export failed'
278
+ : null
279
+ }
278
280
  forceZipArchive={pendingExport ? pendingExport.composerIds.length > 1 : false}
279
281
  open={pendingExport !== null}
280
282
  pending={exportMutation.isPending}
281
283
  title={pendingExport ? `Export ${pendingExport.label}` : 'Export thread'}
282
- onExport={(options) => exportMutation.mutate(options)}
284
+ onExport={(options) => {
285
+ if (pendingExport) {
286
+ exportMutation.mutate(createExportSelectionMutationInput(pendingExport.composerIds, options));
287
+ }
288
+ }}
283
289
  onOpenChange={(open) => {
284
290
  if (!open) {
285
291
  setPendingExport(null);
292
+ exportMutation.reset();
286
293
  }
287
294
  }}
288
295
  />
@@ -358,18 +365,14 @@ const CursorWorkspaceRecoveryNotice = ({ workspace }: { workspace: CursorWorkspa
358
365
 
359
366
  const CursorWorkspaceErrors = ({
360
367
  deleteError,
361
- exportError,
362
368
  recoverError,
363
369
  }: {
364
370
  deleteError: Error | null;
365
- exportError: Error | null;
366
371
  recoverError: Error | null;
367
372
  }) => {
368
- const entries = [
369
- recoverError ? recoverError.message : null,
370
- deleteError ? deleteError.message : null,
371
- exportError ? exportError.message : null,
372
- ].filter(Boolean);
373
+ const entries = [recoverError ? recoverError.message : null, deleteError ? deleteError.message : null].filter(
374
+ Boolean,
375
+ );
373
376
 
374
377
  if (entries.length === 0) {
375
378
  return null;
@@ -387,11 +390,13 @@ const CursorWorkspaceErrors = ({
387
390
  };
388
391
 
389
392
  const CursorWorkspaceDeleteDialog = ({
393
+ errorMessage,
390
394
  pending,
391
395
  pendingDelete,
392
396
  onConfirm,
393
397
  onOpenChange,
394
398
  }: {
399
+ errorMessage: string | null;
395
400
  pending: boolean;
396
401
  pendingDelete: PendingCursorDelete | null;
397
402
  onConfirm: () => void;
@@ -401,6 +406,7 @@ const CursorWorkspaceDeleteDialog = ({
401
406
  <DeleteConfirmDialog
402
407
  confirmLabel={getCursorDeleteConfirmLabel(pendingDelete, pending)}
403
408
  description={getCursorDeleteDescription(pendingDelete)}
409
+ errorMessage={errorMessage}
404
410
  open={pendingDelete !== null}
405
411
  title={getCursorDeleteTitle(pendingDelete)}
406
412
  onConfirm={onConfirm}
@@ -6,13 +6,14 @@ import { CursorWorkspacesTable } from '#/components/cursor-workspaces-table';
6
6
  import { DeleteConfirmDialog } from '#/components/delete-confirm-dialog';
7
7
  import { ListSearchInput } from '#/components/list-search-input';
8
8
  import { PageHeader } from '#/components/page-header';
9
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
9
+ import { RouteErrorPanel } from '#/components/route-error-panel';
10
10
  import { cursorWorkspacesQueryOptions } from '#/lib/cursor-queries';
11
11
  import { deleteCursorWorkspaceFn, recoverCursorWorkspaceFn } from '#/lib/cursor-server';
12
+ import { getMutationErrorMessage } from '#/lib/mutation-error';
12
13
  import { matchesTextQuery } from '#/lib/text-filter';
13
14
 
14
15
  const CursorErrorComponent = ({ error }: { error: Error }) => {
15
- return <ReloadErrorPanel description={error.message} title="Failed to load Cursor workspaces" />;
16
+ return <RouteErrorPanel error={error} title="Failed to load Cursor workspaces" />;
16
17
  };
17
18
 
18
19
  const CursorPage = () => {
@@ -94,6 +95,7 @@ const CursorPage = () => {
94
95
  ? `Permanently delete every thread for "${pendingDelete.label}" from Cursor's database and remove any on-disk transcript directories. Quit Cursor first. This cannot be undone.`
95
96
  : ''
96
97
  }
98
+ errorMessage={getMutationErrorMessage(deleteWorkspaceMutation.error, 'Workspace deletion failed')}
97
99
  open={pendingDelete !== null}
98
100
  title="Delete Cursor workspace?"
99
101
  onConfirm={() => {
@@ -106,6 +108,7 @@ const CursorPage = () => {
106
108
  onOpenChange={(open) => {
107
109
  if (!open) {
108
110
  setPendingDelete(null);
111
+ deleteWorkspaceMutation.reset();
109
112
  }
110
113
  }}
111
114
  />