spiracha 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +42 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +36 -11
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +51 -11
  7. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  8. package/apps/ui/src/components/app-shell.tsx +46 -3
  9. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  10. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  11. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  12. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  13. package/apps/ui/src/components/data-table.tsx +23 -5
  14. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  15. package/apps/ui/src/components/export-dialog.tsx +79 -74
  16. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  17. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  18. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  19. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  20. package/apps/ui/src/components/page-header.tsx +15 -9
  21. package/apps/ui/src/components/projects-table.tsx +4 -1
  22. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  23. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  24. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  25. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  26. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  27. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  28. package/apps/ui/src/components/threads-table.tsx +13 -47
  29. package/apps/ui/src/components/transcript-view.tsx +148 -52
  30. package/apps/ui/src/components/ui/select.tsx +1 -1
  31. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  32. package/apps/ui/src/lib/antigravity-server.ts +209 -11
  33. package/apps/ui/src/lib/antigravity-transcript-events.ts +57 -73
  34. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  35. package/apps/ui/src/lib/claude-code-server.ts +185 -7
  36. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  37. package/apps/ui/src/lib/codex-queries.ts +16 -0
  38. package/apps/ui/src/lib/codex-server.ts +134 -52
  39. package/apps/ui/src/lib/cursor-server.ts +68 -53
  40. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  41. package/apps/ui/src/lib/delete-batch.ts +52 -0
  42. package/apps/ui/src/lib/error-presentation.ts +43 -0
  43. package/apps/ui/src/lib/export-mutation.ts +20 -0
  44. package/apps/ui/src/lib/export-options.ts +15 -0
  45. package/apps/ui/src/lib/formatters.ts +3 -5
  46. package/apps/ui/src/lib/grok-queries.ts +22 -0
  47. package/apps/ui/src/lib/grok-server.ts +168 -0
  48. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  49. package/apps/ui/src/lib/kiro-server.ts +97 -7
  50. package/apps/ui/src/lib/mutation-error.ts +15 -0
  51. package/apps/ui/src/lib/opencode-server.ts +96 -7
  52. package/apps/ui/src/lib/path-utils.ts +1 -1
  53. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  54. package/apps/ui/src/lib/qoder-server.ts +67 -11
  55. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  56. package/apps/ui/src/lib/route-search.ts +114 -0
  57. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  58. package/apps/ui/src/lib/settings-server.ts +33 -0
  59. package/apps/ui/src/lib/settings-store.tsx +82 -38
  60. package/apps/ui/src/lib/settings.ts +65 -0
  61. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  62. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  63. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  64. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  65. package/apps/ui/src/lib/workspace-delete-navigation.ts +20 -0
  66. package/apps/ui/src/routeTree.gen.ts +341 -234
  67. package/apps/ui/src/routes/__root.tsx +12 -15
  68. package/apps/ui/src/routes/analytics.tsx +10 -61
  69. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +149 -29
  70. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +262 -42
  71. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  72. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  73. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  74. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  75. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +168 -28
  76. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +179 -45
  77. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  78. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  79. package/apps/ui/src/routes/codex.index.tsx +5 -10
  80. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +31 -18
  81. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +44 -28
  82. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  83. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +401 -0
  84. package/apps/ui/src/routes/grok.$workspaceKey.tsx +295 -0
  85. package/apps/ui/src/routes/grok.index.tsx +48 -0
  86. package/apps/ui/src/routes/index.tsx +2 -18
  87. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +91 -27
  88. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +213 -51
  89. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  90. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +108 -29
  91. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +231 -55
  92. package/apps/ui/src/routes/opencode.index.tsx +6 -2
  93. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +19 -15
  94. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +89 -48
  95. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  96. package/apps/ui/src/routes/threads.$threadId.tsx +620 -105
  97. package/apps/ui/vite.config.ts +22 -12
  98. package/bin/spiracha.ts +28 -1
  99. package/package.json +37 -27
  100. package/src/client.ts +183 -5
  101. package/src/lib/antigravity-db.ts +424 -94
  102. package/src/lib/antigravity-exporter-types.ts +3 -0
  103. package/src/lib/antigravity-keychain.ts +7 -8
  104. package/src/lib/antigravity-projects.ts +201 -0
  105. package/src/lib/antigravity-transcript-phase.ts +47 -0
  106. package/src/lib/claude-code-db.ts +534 -42
  107. package/src/lib/claude-code-exporter-types.ts +5 -0
  108. package/src/lib/claude-code-transcript-phase.ts +60 -1
  109. package/src/lib/claude-code-transcript.ts +8 -5
  110. package/src/lib/codex-analytics.ts +32 -5
  111. package/src/lib/codex-browser-db.ts +540 -137
  112. package/src/lib/codex-browser-export.ts +18 -27
  113. package/src/lib/codex-browser-types.ts +21 -1
  114. package/src/lib/codex-thread-cache.ts +106 -23
  115. package/src/lib/codex-thread-parser.ts +149 -55
  116. package/src/lib/codex-thread-recovery.ts +38 -11
  117. package/src/lib/codex-transcript-filter.ts +31 -0
  118. package/src/lib/codex-transcript-renderer.ts +87 -79
  119. package/src/lib/concurrency.ts +56 -3
  120. package/src/lib/conversation-api.ts +410 -27
  121. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  122. package/src/lib/conversation-data/antigravity-adapter.ts +70 -26
  123. package/src/lib/conversation-data/claude-code-adapter.ts +69 -12
  124. package/src/lib/conversation-data/codex-adapter.ts +108 -73
  125. package/src/lib/conversation-data/cursor-adapter.ts +77 -29
  126. package/src/lib/conversation-data/grok-adapter.ts +223 -0
  127. package/src/lib/conversation-data/index.ts +105 -11
  128. package/src/lib/conversation-data/kiro-adapter.ts +59 -12
  129. package/src/lib/conversation-data/message-selector.ts +2 -5
  130. package/src/lib/conversation-data/opencode-adapter.ts +67 -17
  131. package/src/lib/conversation-data/path-match.ts +1 -13
  132. package/src/lib/conversation-data/qoder-adapter.ts +67 -37
  133. package/src/lib/conversation-data/types.ts +43 -0
  134. package/src/lib/conversation-zip-export.ts +75 -0
  135. package/src/lib/cursor-db.ts +68 -28
  136. package/src/lib/cursor-id.ts +17 -0
  137. package/src/lib/cursor-recovery.ts +146 -35
  138. package/src/lib/cursor-transcript-phase.ts +40 -0
  139. package/src/lib/cursor-transcript.ts +21 -5
  140. package/src/lib/grok-db.ts +1068 -0
  141. package/src/lib/grok-exporter-types.ts +110 -0
  142. package/src/lib/grok-transcript-phase.ts +61 -0
  143. package/src/lib/grok-transcript.ts +167 -0
  144. package/src/lib/kiro-db.ts +187 -43
  145. package/src/lib/kiro-transcript.ts +0 -4
  146. package/src/lib/model-label.ts +11 -3
  147. package/src/lib/opencode-db.ts +599 -60
  148. package/src/lib/opencode-think-tags.ts +17 -3
  149. package/src/lib/opencode-transcript.ts +8 -5
  150. package/src/lib/portable-path.ts +9 -0
  151. package/src/lib/qoder-acp-client.ts +11 -2
  152. package/src/lib/qoder-db.ts +51 -17
  153. package/src/lib/qoder-transcript-phase.ts +64 -0
  154. package/src/lib/qoder-transcript.ts +30 -2
  155. package/src/lib/shared.ts +63 -15
  156. package/src/lib/sqlite-error.ts +31 -5
  157. package/src/lib/transcript-load-limiter.ts +87 -0
  158. package/src/lib/ui-cache.ts +185 -24
  159. package/src/lib/ui-export-archive.ts +58 -39
  160. package/src/lib/ui-export-files.ts +75 -15
  161. package/src/lib/ui-export-zip.ts +40 -0
  162. package/apps/ui/package.json +0 -65
@@ -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';
15
- import { TranscriptView } from '#/components/transcript-view';
14
+ import { RouteErrorPanel } from '#/components/route-error-panel';
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 = () => {
@@ -221,7 +216,7 @@ const CursorThreadDetailPage = () => {
221
216
  const [showCommentary, setShowCommentary] = useState(false);
222
217
  const [showExtraEvents, setShowExtraEvents] = useState(false);
223
218
  const [showRawJson, setShowRawJson] = useState(false);
224
- const [showUserMessages, setShowUserMessages] = useState(true);
219
+ const [showUserMessages, setShowUserMessages] = useState(DEFAULT_SHOW_USER_MESSAGES);
225
220
  const transcriptEvents = useMemo(
226
221
  () => (detail.transcript ? cursorTranscriptToThreadEvents(detail.transcript) : []),
227
222
  [detail.transcript],
@@ -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,7 +20,10 @@ 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';
26
+ import { isWorkspaceEmptiedByDelete } from '#/lib/workspace-delete-navigation';
24
27
 
25
28
  type PendingCursorDelete =
26
29
  | { kind: 'threads'; threads: CursorThreadSummary[] }
@@ -31,14 +34,6 @@ type PendingCursorExport = {
31
34
  label: string;
32
35
  };
33
36
 
34
- type ExportDialogOptions = {
35
- includeCommentary: boolean;
36
- includeMetadata: boolean;
37
- includeTools: boolean;
38
- outputFormat: 'md' | 'txt';
39
- zipArchive: boolean;
40
- };
41
-
42
37
  const findWorkspaceOrThrow = (workspaces: CursorWorkspaceGroup[], workspaceKey: string) => {
43
38
  const workspace = workspaces.find((candidate) => candidate.key === workspaceKey);
44
39
  if (!workspace) {
@@ -113,7 +108,7 @@ const getCursorDeleteTitle = (pendingDelete: PendingCursorDelete | null) => {
113
108
  };
114
109
 
115
110
  const CursorWorkspaceErrorComponent = ({ error }: { error: Error }) => {
116
- return <ReloadErrorPanel description={error.message} title="Failed to load Cursor workspace" />;
111
+ return <RouteErrorPanel error={error} title="Failed to load Cursor workspace" />;
117
112
  };
118
113
 
119
114
  const CursorWorkspacePage = () => {
@@ -148,34 +143,43 @@ const CursorWorkspacePage = () => {
148
143
  onSuccess: async (_result, target) => {
149
144
  if (target.kind === 'workspace') {
150
145
  await navigate({ to: '/cursor' });
146
+ queryClient.removeQueries({ queryKey: ['cursor-thread'] });
151
147
  await queryClient.invalidateQueries({ queryKey: ['cursor-workspaces'] });
152
148
  setPendingDelete(null);
153
149
  return;
154
150
  }
155
151
 
156
- await invalidateWorkspaceQueries();
152
+ const workspaceEmptied = isWorkspaceEmptiedByDelete(
153
+ threads,
154
+ target.threads.map((thread) => thread.composerId),
155
+ (thread) => thread.composerId,
156
+ );
157
+ for (const thread of target.threads) {
158
+ queryClient.removeQueries({ queryKey: ['cursor-thread', thread.composerId] });
159
+ }
157
160
  setPendingDelete(null);
161
+ if (workspaceEmptied) {
162
+ await navigate({ to: '/cursor' });
163
+ }
164
+
165
+ await invalidateWorkspaceQueries();
158
166
  },
159
167
  });
160
168
 
161
169
  const exportMutation = useMutation({
162
- mutationFn: async (options: ExportDialogOptions) => {
163
- if (!pendingExport) {
164
- throw new Error('No thread selected for export');
165
- }
166
-
170
+ mutationFn: async ({ ids, options }: ExportSelectionMutationInput) => {
167
171
  const download =
168
- pendingExport.composerIds.length === 1
172
+ ids.length === 1
169
173
  ? await exportCursorThreadFn({
170
174
  data: {
171
175
  ...options,
172
- composerId: pendingExport.composerIds[0]!,
176
+ composerId: ids[0]!,
173
177
  },
174
178
  })
175
179
  : await exportCursorThreadsFn({
176
180
  data: {
177
181
  ...options,
178
- composerIds: pendingExport.composerIds,
182
+ composerIds: [...ids],
179
183
  },
180
184
  });
181
185
 
@@ -243,11 +247,11 @@ const CursorWorkspacePage = () => {
243
247
 
244
248
  <CursorWorkspaceErrors
245
249
  deleteError={deleteMutation.isError ? deleteMutation.error : null}
246
- exportError={exportMutation.isError ? exportMutation.error : null}
247
250
  recoverError={recoverWorkspaceMutation.isError ? recoverWorkspaceMutation.error : null}
248
251
  />
249
252
 
250
253
  <CursorWorkspaceDeleteDialog
254
+ errorMessage={getMutationErrorMessage(deleteMutation.error, 'Delete failed')}
251
255
  pending={deleteMutation.isPending}
252
256
  pendingDelete={pendingDelete}
253
257
  onConfirm={() => {
@@ -260,19 +264,32 @@ const CursorWorkspacePage = () => {
260
264
  onOpenChange={(open) => {
261
265
  if (!open) {
262
266
  setPendingDelete(null);
267
+ deleteMutation.reset();
263
268
  }
264
269
  }}
265
270
  />
266
271
 
267
272
  <ExportDialog
273
+ errorMessage={
274
+ exportMutation.isError
275
+ ? exportMutation.error instanceof Error
276
+ ? exportMutation.error.message
277
+ : 'Thread export failed'
278
+ : null
279
+ }
268
280
  forceZipArchive={pendingExport ? pendingExport.composerIds.length > 1 : false}
269
281
  open={pendingExport !== null}
270
282
  pending={exportMutation.isPending}
271
283
  title={pendingExport ? `Export ${pendingExport.label}` : 'Export thread'}
272
- onExport={(options) => exportMutation.mutate(options)}
284
+ onExport={(options) => {
285
+ if (pendingExport) {
286
+ exportMutation.mutate(createExportSelectionMutationInput(pendingExport.composerIds, options));
287
+ }
288
+ }}
273
289
  onOpenChange={(open) => {
274
290
  if (!open) {
275
291
  setPendingExport(null);
292
+ exportMutation.reset();
276
293
  }
277
294
  }}
278
295
  />
@@ -348,18 +365,14 @@ const CursorWorkspaceRecoveryNotice = ({ workspace }: { workspace: CursorWorkspa
348
365
 
349
366
  const CursorWorkspaceErrors = ({
350
367
  deleteError,
351
- exportError,
352
368
  recoverError,
353
369
  }: {
354
370
  deleteError: Error | null;
355
- exportError: Error | null;
356
371
  recoverError: Error | null;
357
372
  }) => {
358
- const entries = [
359
- recoverError ? recoverError.message : null,
360
- deleteError ? deleteError.message : null,
361
- exportError ? exportError.message : null,
362
- ].filter(Boolean);
373
+ const entries = [recoverError ? recoverError.message : null, deleteError ? deleteError.message : null].filter(
374
+ Boolean,
375
+ );
363
376
 
364
377
  if (entries.length === 0) {
365
378
  return null;
@@ -377,11 +390,13 @@ const CursorWorkspaceErrors = ({
377
390
  };
378
391
 
379
392
  const CursorWorkspaceDeleteDialog = ({
393
+ errorMessage,
380
394
  pending,
381
395
  pendingDelete,
382
396
  onConfirm,
383
397
  onOpenChange,
384
398
  }: {
399
+ errorMessage: string | null;
385
400
  pending: boolean;
386
401
  pendingDelete: PendingCursorDelete | null;
387
402
  onConfirm: () => void;
@@ -391,6 +406,7 @@ const CursorWorkspaceDeleteDialog = ({
391
406
  <DeleteConfirmDialog
392
407
  confirmLabel={getCursorDeleteConfirmLabel(pendingDelete, pending)}
393
408
  description={getCursorDeleteDescription(pendingDelete)}
409
+ errorMessage={errorMessage}
394
410
  open={pendingDelete !== null}
395
411
  title={getCursorDeleteTitle(pendingDelete)}
396
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
  />