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
@@ -12,24 +12,19 @@ import { LoadingPanel } from '#/components/loading-panel';
12
12
  import { MetadataSection } from '#/components/metadata-section';
13
13
  import { MetricCard } from '#/components/metric-card';
14
14
  import { PageHeader } from '#/components/page-header';
15
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
15
+ import { RouteErrorPanel } from '#/components/route-error-panel';
16
16
  import { DEFAULT_SHOW_USER_MESSAGES, TranscriptView } from '#/components/transcript-view';
17
17
  import { Button } from '#/components/ui/button';
18
18
  import { Checkbox } from '#/components/ui/checkbox';
19
19
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
20
20
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
21
+ import type { ExportDialogOptions } from '#/lib/export-options';
21
22
  import { formatDateTime, formatList, formatNumber } from '#/lib/formatters';
22
- import { grokSessionDetailQueryOptions } from '#/lib/grok-queries';
23
+ import { grokSessionDetailQueryOptions, grokWorkspacesQueryOptions } from '#/lib/grok-queries';
23
24
  import { deleteGrokSessionFn, exportGrokSessionFn } from '#/lib/grok-server';
24
25
  import { getGrokThreadTranscriptStats, grokTranscriptToThreadEvents } from '#/lib/grok-transcript-events';
25
-
26
- type ExportDialogOptions = {
27
- includeCommentary: boolean;
28
- includeMetadata: boolean;
29
- includeTools: boolean;
30
- outputFormat: 'md' | 'txt';
31
- zipArchive: boolean;
32
- };
26
+ import { RouteStateResetBoundary } from '#/lib/route-state-reset';
27
+ import { shouldNavigateToSourceIndexAfterDelete } from '#/lib/workspace-delete-navigation';
33
28
 
34
29
  type TranscriptControlsProps = {
35
30
  rawJsonDisabled?: boolean;
@@ -46,7 +41,7 @@ type TranscriptControlsProps = {
46
41
  };
47
42
 
48
43
  const GrokSessionDetailErrorComponent = ({ error }: { error: Error }) => {
49
- return <ReloadErrorPanel description={error.message} title="Failed to load Grok session" />;
44
+ return <RouteErrorPanel error={error} title="Failed to load Grok session" />;
50
45
  };
51
46
 
52
47
  const buildSessionMetadata = (detail: GrokSessionTranscript) => [
@@ -205,6 +200,17 @@ const GrokSessionDetailPage = () => {
205
200
  queryClient.invalidateQueries({ queryKey: ['grok-sessions', detail.session.workspaceKey] }),
206
201
  queryClient.invalidateQueries({ queryKey: ['grok-session', detail.session.sessionId] }),
207
202
  ]);
203
+ const workspaces = await queryClient.fetchQuery(grokWorkspacesQueryOptions());
204
+ if (
205
+ shouldNavigateToSourceIndexAfterDelete(
206
+ workspaces,
207
+ detail.session.workspaceKey,
208
+ (workspace) => workspace.key,
209
+ )
210
+ ) {
211
+ navigate({ to: '/grok' });
212
+ return;
213
+ }
208
214
  navigate({
209
215
  params: { workspaceKey: detail.session.workspaceKey },
210
216
  to: '/grok/$workspaceKey',
@@ -378,7 +384,14 @@ const GrokSessionDetailPage = () => {
378
384
  };
379
385
 
380
386
  export const Route = createFileRoute('/grok-sessions/$sessionId')({
381
- component: GrokSessionDetailPage,
387
+ component: () => {
388
+ const { sessionId } = Route.useParams();
389
+ return (
390
+ <RouteStateResetBoundary routeKey={sessionId}>
391
+ <GrokSessionDetailPage />
392
+ </RouteStateResetBoundary>
393
+ );
394
+ },
382
395
  errorComponent: GrokSessionDetailErrorComponent,
383
396
  loader: ({ context, params }) =>
384
397
  context.queryClient.ensureQueryData(grokSessionDetailQueryOptions(params.sessionId)),
@@ -9,9 +9,10 @@ import { GrokSessionsTable } from '#/components/grok-sessions-table';
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 { downloadTextFile, downloadUrlFile } from '#/lib/download';
15
+ import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
15
16
  import { grokSessionsQueryOptions, grokWorkspacesQueryOptions } from '#/lib/grok-queries';
16
17
  import {
17
18
  deleteGrokSessionFn,
@@ -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: GrokSessionSummary[];
@@ -104,7 +97,7 @@ export const Route = createFileRoute('/grok/$workspaceKey')({
104
97
  });
105
98
 
106
99
  function GrokWorkspaceErrorComponent({ error }: { error: Error }) {
107
- return <ReloadErrorPanel description={error.message} title="Failed to load Grok workspace" />;
100
+ return <RouteErrorPanel error={error} title="Failed to load Grok workspace" />;
108
101
  }
109
102
 
110
103
  function GrokWorkspacePage() {
@@ -120,20 +113,16 @@ function GrokWorkspacePage() {
120
113
  const deferredSearch = useDeferredValue(searchInput);
121
114
 
122
115
  const exportMutation = useMutation({
123
- mutationFn: async (options: ExportDialogOptions) => {
124
- if (!pendingExport) {
125
- throw new Error('No Grok session selected for export');
126
- }
127
-
116
+ mutationFn: async ({ ids, options }: ExportSelectionMutationInput) => {
128
117
  const download =
129
- pendingExport.sessionIds.length === 1
118
+ ids.length === 1
130
119
  ? await exportGrokSessionFn({
131
120
  data: {
132
121
  includeCommentary: options.includeCommentary,
133
122
  includeMetadata: options.includeMetadata,
134
123
  includeTools: options.includeTools,
135
124
  outputFormat: options.outputFormat,
136
- sessionId: pendingExport.sessionIds[0]!,
125
+ sessionId: ids[0]!,
137
126
  zipArchive: options.zipArchive,
138
127
  },
139
128
  })
@@ -143,7 +132,7 @@ function GrokWorkspacePage() {
143
132
  includeMetadata: options.includeMetadata,
144
133
  includeTools: options.includeTools,
145
134
  outputFormat: options.outputFormat,
146
- sessionIds: pendingExport.sessionIds,
135
+ sessionIds: [...ids],
147
136
  zipArchive: options.zipArchive,
148
137
  },
149
138
  });
@@ -164,13 +153,7 @@ function GrokWorkspacePage() {
164
153
  sessionIds.length === 1
165
154
  ? deleteGrokSessionFn({ data: { sessionId: sessionIds[0]! } })
166
155
  : deleteGrokSessionsFn({ data: { sessionIds } }),
167
- onSuccess: async (_result, sessionIds) => {
168
- const workspaceEmptied = isWorkspaceEmptiedByDelete(sessions, sessionIds, (session) => session.sessionId);
169
- setPendingDelete(null);
170
- if (workspaceEmptied) {
171
- await navigate({ to: '/grok' });
172
- }
173
-
156
+ onSettled: async (_result, _error, sessionIds) => {
174
157
  await Promise.all([
175
158
  queryClient.invalidateQueries({ queryKey: ['grok-workspaces'] }),
176
159
  queryClient.invalidateQueries({ queryKey: ['grok-sessions', workspace.key] }),
@@ -179,6 +162,13 @@ function GrokWorkspacePage() {
179
162
  ),
180
163
  ]);
181
164
  },
165
+ onSuccess: async (_result, sessionIds) => {
166
+ const workspaceEmptied = isWorkspaceEmptiedByDelete(sessions, sessionIds, (session) => session.sessionId);
167
+ setPendingDelete(null);
168
+ if (workspaceEmptied) {
169
+ await navigate({ to: '/grok' });
170
+ }
171
+ },
182
172
  });
183
173
 
184
174
  const visibleSessions = useMemo(
@@ -263,7 +253,11 @@ function GrokWorkspacePage() {
263
253
  open={pendingExport !== null}
264
254
  pending={exportMutation.isPending}
265
255
  title={pendingExport ? `Export ${pendingExport.label}` : 'Export session'}
266
- onExport={(options) => exportMutation.mutate(options)}
256
+ onExport={(options) => {
257
+ if (pendingExport) {
258
+ exportMutation.mutate(createExportSelectionMutationInput(pendingExport.sessionIds, options));
259
+ }
260
+ }}
267
261
  onOpenChange={(open) => {
268
262
  if (!open) {
269
263
  setPendingExport(null);
@@ -4,12 +4,12 @@ import { useDeferredValue, useState } from 'react';
4
4
  import { GrokWorkspacesTable } from '#/components/grok-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 { grokWorkspacesQueryOptions } from '#/lib/grok-queries';
9
9
  import { matchesTextQuery } from '#/lib/text-filter';
10
10
 
11
11
  const GrokErrorComponent = ({ error }: { error: Error }) => {
12
- return <ReloadErrorPanel description={error.message} title="Failed to load Grok workspaces" />;
12
+ return <RouteErrorPanel error={error} title="Failed to load Grok workspaces" />;
13
13
  };
14
14
 
15
15
  const GrokPage = () => {
@@ -3,6 +3,7 @@ import { createFileRoute, Link } from '@tanstack/react-router';
3
3
  import { MetricCard } from '#/components/metric-card';
4
4
  import { PageHeader } from '#/components/page-header';
5
5
  import { RecentThreadsList } from '#/components/recent-threads-list';
6
+ import { RouteErrorPanel } from '#/components/route-error-panel';
6
7
  import { dashboardQueryOptions } from '#/lib/codex-queries';
7
8
  import { formatNumber, formatTokens } from '#/lib/formatters';
8
9
 
@@ -12,24 +13,7 @@ export const Route = createFileRoute('/')({
12
13
  });
13
14
 
14
15
  function DashboardErrorComponent({ error }: { error: Error }) {
15
- const isSqlite = error.message.includes('unable to open database') || error.message.includes('database is locked');
16
- return (
17
- <div className="rounded-xl border border-[var(--border)] bg-[var(--panel)] px-6 py-10 text-center">
18
- <p className="font-medium text-[var(--destructive)] text-sm">
19
- {isSqlite ? 'Database unavailable' : 'Failed to load dashboard'}
20
- </p>
21
- <p className="mt-2 text-[var(--muted-foreground)] text-sm">
22
- {isSqlite ? 'Codex may have an exclusive lock on the database. Reload to retry.' : error.message}
23
- </p>
24
- <button
25
- className="mt-4 text-[var(--accent)] text-sm underline-offset-2 hover:underline"
26
- type="button"
27
- onClick={() => window.location.reload()}
28
- >
29
- Reload
30
- </button>
31
- </div>
32
- );
16
+ return <RouteErrorPanel error={error} title="Failed to load dashboard" />;
33
17
  }
34
18
 
35
19
  Route.update({ errorComponent: DashboardErrorComponent });
@@ -12,24 +12,19 @@ import { LoadingPanel } from '#/components/loading-panel';
12
12
  import { MetadataSection } from '#/components/metadata-section';
13
13
  import { MetricCard } from '#/components/metric-card';
14
14
  import { PageHeader } from '#/components/page-header';
15
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
15
+ import { RouteErrorPanel } from '#/components/route-error-panel';
16
16
  import { DEFAULT_SHOW_USER_MESSAGES, TranscriptView } from '#/components/transcript-view';
17
17
  import { Button } from '#/components/ui/button';
18
18
  import { Checkbox } from '#/components/ui/checkbox';
19
19
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
20
20
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
21
+ import type { ExportDialogOptions } from '#/lib/export-options';
21
22
  import { formatDateTime, formatList, formatNumber } from '#/lib/formatters';
22
- import { kiroSessionDetailQueryOptions } from '#/lib/kiro-queries';
23
+ import { kiroSessionDetailQueryOptions, kiroWorkspacesQueryOptions } from '#/lib/kiro-queries';
23
24
  import { deleteKiroSessionFn, exportKiroSessionFn } from '#/lib/kiro-server';
24
25
  import { getKiroThreadTranscriptStats, kiroTranscriptToThreadEvents } from '#/lib/kiro-transcript-events';
25
-
26
- type ExportDialogOptions = {
27
- includeCommentary: boolean;
28
- includeMetadata: boolean;
29
- includeTools: boolean;
30
- outputFormat: 'md' | 'txt';
31
- zipArchive: boolean;
32
- };
26
+ import { RouteStateResetBoundary } from '#/lib/route-state-reset';
27
+ import { shouldNavigateToSourceIndexAfterDelete } from '#/lib/workspace-delete-navigation';
33
28
 
34
29
  type TranscriptControlsProps = {
35
30
  rawJsonDisabled?: boolean;
@@ -46,7 +41,7 @@ type TranscriptControlsProps = {
46
41
  };
47
42
 
48
43
  const KiroSessionDetailErrorComponent = ({ error }: { error: Error }) => {
49
- return <ReloadErrorPanel description={error.message} title="Failed to load Kiro session" />;
44
+ return <RouteErrorPanel error={error} title="Failed to load Kiro session" />;
50
45
  };
51
46
 
52
47
  const getKiroModelLabel = (detail: KiroSessionTranscript): string => {
@@ -216,6 +211,17 @@ const KiroSessionDetailPage = () => {
216
211
  queryClient.invalidateQueries({ queryKey: ['kiro-sessions', detail.session.workspaceKey] }),
217
212
  queryClient.invalidateQueries({ queryKey: ['kiro-session', detail.session.sessionId] }),
218
213
  ]);
214
+ const workspaces = await queryClient.fetchQuery(kiroWorkspacesQueryOptions());
215
+ if (
216
+ shouldNavigateToSourceIndexAfterDelete(
217
+ workspaces,
218
+ detail.session.workspaceKey,
219
+ (workspace) => workspace.key,
220
+ )
221
+ ) {
222
+ navigate({ to: '/kiro' });
223
+ return;
224
+ }
219
225
  navigate({
220
226
  params: { workspaceKey: detail.session.workspaceKey },
221
227
  to: '/kiro/$workspaceKey',
@@ -383,7 +389,14 @@ const KiroSessionDetailPage = () => {
383
389
  };
384
390
 
385
391
  export const Route = createFileRoute('/kiro-sessions/$sessionId')({
386
- component: KiroSessionDetailPage,
392
+ component: () => {
393
+ const { sessionId } = Route.useParams();
394
+ return (
395
+ <RouteStateResetBoundary routeKey={sessionId}>
396
+ <KiroSessionDetailPage />
397
+ </RouteStateResetBoundary>
398
+ );
399
+ },
387
400
  errorComponent: KiroSessionDetailErrorComponent,
388
401
  loader: ({ context, params }) =>
389
402
  context.queryClient.ensureQueryData(kiroSessionDetailQueryOptions(params.sessionId)),
@@ -9,9 +9,10 @@ import { KiroSessionsTable } from '#/components/kiro-sessions-table';
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 { downloadTextFile, downloadUrlFile } from '#/lib/download';
15
+ import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
15
16
  import { kiroSessionsQueryOptions, kiroWorkspacesQueryOptions } from '#/lib/kiro-queries';
16
17
  import {
17
18
  deleteKiroSessionFn,
@@ -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: KiroSessionSummary[];
@@ -93,7 +86,7 @@ const getDeleteTitle = (pendingDelete: PendingSessionDelete | null) => {
93
86
  };
94
87
 
95
88
  const KiroWorkspaceErrorComponent = ({ error }: { error: Error }) => {
96
- return <ReloadErrorPanel description={error.message} title="Failed to load Kiro workspace" />;
89
+ return <RouteErrorPanel error={error} title="Failed to load Kiro workspace" />;
97
90
  };
98
91
 
99
92
  const KiroWorkspacePage = () => {
@@ -109,20 +102,16 @@ const KiroWorkspacePage = () => {
109
102
  const deferredSearch = useDeferredValue(searchInput);
110
103
 
111
104
  const exportMutation = useMutation({
112
- mutationFn: async (options: ExportDialogOptions) => {
113
- if (!pendingExport) {
114
- throw new Error('No Kiro session selected for export');
115
- }
116
-
105
+ mutationFn: async ({ ids, options }: ExportSelectionMutationInput) => {
117
106
  const download =
118
- pendingExport.sessionIds.length === 1
107
+ ids.length === 1
119
108
  ? await exportKiroSessionFn({
120
109
  data: {
121
110
  includeCommentary: options.includeCommentary,
122
111
  includeMetadata: options.includeMetadata,
123
112
  includeTools: options.includeTools,
124
113
  outputFormat: options.outputFormat,
125
- sessionId: pendingExport.sessionIds[0]!,
114
+ sessionId: ids[0]!,
126
115
  zipArchive: options.zipArchive,
127
116
  },
128
117
  })
@@ -132,7 +121,7 @@ const KiroWorkspacePage = () => {
132
121
  includeMetadata: options.includeMetadata,
133
122
  includeTools: options.includeTools,
134
123
  outputFormat: options.outputFormat,
135
- sessionIds: pendingExport.sessionIds,
124
+ sessionIds: [...ids],
136
125
  zipArchive: options.zipArchive,
137
126
  },
138
127
  });
@@ -153,13 +142,7 @@ const KiroWorkspacePage = () => {
153
142
  sessionIds.length === 1
154
143
  ? deleteKiroSessionFn({ data: { sessionId: sessionIds[0]! } })
155
144
  : deleteKiroSessionsFn({ data: { sessionIds } }),
156
- onSuccess: async (_result, sessionIds) => {
157
- const workspaceEmptied = isWorkspaceEmptiedByDelete(sessions, sessionIds, (session) => session.sessionId);
158
- setPendingDelete(null);
159
- if (workspaceEmptied) {
160
- await navigate({ to: '/kiro' });
161
- }
162
-
145
+ onSettled: async (_result, _error, sessionIds) => {
163
146
  await Promise.all([
164
147
  queryClient.invalidateQueries({ queryKey: ['kiro-workspaces'] }),
165
148
  queryClient.invalidateQueries({ queryKey: ['kiro-sessions', workspace.key] }),
@@ -168,6 +151,13 @@ const KiroWorkspacePage = () => {
168
151
  ),
169
152
  ]);
170
153
  },
154
+ onSuccess: async (_result, sessionIds) => {
155
+ const workspaceEmptied = isWorkspaceEmptiedByDelete(sessions, sessionIds, (session) => session.sessionId);
156
+ setPendingDelete(null);
157
+ if (workspaceEmptied) {
158
+ await navigate({ to: '/kiro' });
159
+ }
160
+ },
171
161
  });
172
162
 
173
163
  const visibleSessions = useMemo(
@@ -242,11 +232,22 @@ const KiroWorkspacePage = () => {
242
232
  />
243
233
 
244
234
  <ExportDialog
235
+ errorMessage={
236
+ exportMutation.isError
237
+ ? exportMutation.error instanceof Error
238
+ ? exportMutation.error.message
239
+ : 'Session export failed'
240
+ : null
241
+ }
245
242
  forceZipArchive={pendingExport ? pendingExport.sessionIds.length > 1 : false}
246
243
  open={pendingExport !== null}
247
244
  pending={exportMutation.isPending}
248
245
  title={pendingExport ? `Export ${pendingExport.label}` : 'Export session'}
249
- onExport={(options) => exportMutation.mutate(options)}
246
+ onExport={(options) => {
247
+ if (pendingExport) {
248
+ exportMutation.mutate(createExportSelectionMutationInput(pendingExport.sessionIds, options));
249
+ }
250
+ }}
250
251
  onOpenChange={(open) => {
251
252
  if (!open) {
252
253
  setPendingExport(null);
@@ -255,12 +256,6 @@ const KiroWorkspacePage = () => {
255
256
  }}
256
257
  />
257
258
 
258
- {exportMutation.isError ? (
259
- <p className="text-[var(--destructive)] text-sm">
260
- {exportMutation.error instanceof Error ? exportMutation.error.message : 'Session export failed'}
261
- </p>
262
- ) : null}
263
-
264
259
  <DeleteConfirmDialog
265
260
  confirmLabel={getDeleteConfirmLabel(pendingDelete, deleteMutation.isPending)}
266
261
  description={getDeleteDescription(pendingDelete)}
@@ -4,12 +4,12 @@ import { useDeferredValue, useState } from 'react';
4
4
  import { KiroWorkspacesTable } from '#/components/kiro-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 { kiroWorkspacesQueryOptions } from '#/lib/kiro-queries';
9
9
  import { matchesTextQuery } from '#/lib/text-filter';
10
10
 
11
11
  const KiroErrorComponent = ({ error }: { error: Error }) => {
12
- return <ReloadErrorPanel description={error.message} title="Failed to load Kiro workspaces" />;
12
+ return <RouteErrorPanel error={error} title="Failed to load Kiro workspaces" />;
13
13
  };
14
14
 
15
15
  const KiroPage = () => {
@@ -1,6 +1,6 @@
1
1
  import type { ThreadEvent, ThreadTranscriptStats } from '@spiracha/lib/codex-browser-types';
2
2
  import type { OpenCodeSessionTranscript } from '@spiracha/lib/opencode-exporter-types';
3
- import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
4
4
  import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
5
5
  import { Download, Trash2 } from 'lucide-react';
6
6
  import { useMemo, useState } from 'react';
@@ -12,24 +12,19 @@ import { LoadingPanel } from '#/components/loading-panel';
12
12
  import { MetadataSection } from '#/components/metadata-section';
13
13
  import { MetricCard } from '#/components/metric-card';
14
14
  import { PageHeader } from '#/components/page-header';
15
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
15
+ import { RouteErrorPanel } from '#/components/route-error-panel';
16
16
  import { DEFAULT_SHOW_USER_MESSAGES, TranscriptView } from '#/components/transcript-view';
17
17
  import { Button } from '#/components/ui/button';
18
18
  import { Checkbox } from '#/components/ui/checkbox';
19
19
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
20
20
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
21
+ import type { ExportDialogOptions } from '#/lib/export-options';
21
22
  import { formatDateTime, formatList, formatNumber, formatTokens } from '#/lib/formatters';
22
- import { openCodeSessionDetailQueryOptions } from '#/lib/opencode-queries';
23
+ import { openCodeSessionDetailQueryOptions, openCodeWorkspacesQueryOptions } from '#/lib/opencode-queries';
23
24
  import { deleteOpenCodeSessionFn, exportOpenCodeSessionFn } from '#/lib/opencode-server';
24
25
  import { getOpenCodeThreadTranscriptStats, openCodeTranscriptToThreadEvents } from '#/lib/opencode-transcript-events';
25
-
26
- type ExportDialogOptions = {
27
- includeCommentary: boolean;
28
- includeMetadata: boolean;
29
- includeTools: boolean;
30
- outputFormat: 'md' | 'txt';
31
- zipArchive: boolean;
32
- };
26
+ import { RouteStateResetBoundary } from '#/lib/route-state-reset';
27
+ import { shouldNavigateToSourceIndexAfterDelete } from '#/lib/workspace-delete-navigation';
33
28
 
34
29
  type TranscriptControlsProps = {
35
30
  rawJsonDisabled?: boolean;
@@ -46,11 +41,9 @@ type TranscriptControlsProps = {
46
41
  };
47
42
 
48
43
  const OpenCodeSessionDetailErrorComponent = ({ error }: { error: Error }) => {
49
- return <ReloadErrorPanel description={error.message} title="Failed to load OpenCode session" />;
44
+ return <RouteErrorPanel error={error} title="Failed to load OpenCode session" />;
50
45
  };
51
46
 
52
- const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)));
53
-
54
47
  const buildSessionMetadata = (detail: OpenCodeSessionTranscript) => [
55
48
  { label: 'Session ID', value: <span data-mono="true">{detail.session.sessionId}</span> },
56
49
  {
@@ -165,8 +158,7 @@ const OpenCodeSessionDetailPage = () => {
165
158
  const navigate = useNavigate();
166
159
  const queryClient = useQueryClient();
167
160
  const params = Route.useParams();
168
- const detailQuery = useQuery(openCodeSessionDetailQueryOptions(params.sessionId));
169
- const detail = detailQuery.data ?? null;
161
+ const detail = useSuspenseQuery(openCodeSessionDetailQueryOptions(params.sessionId)).data;
170
162
  const [deleteOpen, setDeleteOpen] = useState(false);
171
163
  const [pendingExport, setPendingExport] = useState(false);
172
164
  const [showToolCalls, setShowToolCalls] = useState(false);
@@ -223,6 +215,17 @@ const OpenCodeSessionDetailPage = () => {
223
215
  queryClient.invalidateQueries({ queryKey: ['opencode-sessions', detail.session.workspaceKey] }),
224
216
  queryClient.invalidateQueries({ queryKey: ['opencode-session', detail.session.sessionId] }),
225
217
  ]);
218
+ const workspaces = await queryClient.fetchQuery(openCodeWorkspacesQueryOptions());
219
+ if (
220
+ shouldNavigateToSourceIndexAfterDelete(
221
+ workspaces,
222
+ detail.session.workspaceKey,
223
+ (workspace) => workspace.key,
224
+ )
225
+ ) {
226
+ navigate({ to: '/opencode' });
227
+ return;
228
+ }
226
229
  navigate({
227
230
  params: { workspaceKey: detail.session.workspaceKey },
228
231
  to: '/opencode/$workspaceKey',
@@ -230,25 +233,6 @@ const OpenCodeSessionDetailPage = () => {
230
233
  },
231
234
  });
232
235
 
233
- if (detailQuery.isLoading) {
234
- return (
235
- <LoadingPanel
236
- description="Loading the OpenCode transcript, parts, and session metadata."
237
- title="Loading session"
238
- />
239
- );
240
- }
241
-
242
- if (detailQuery.isError) {
243
- return <OpenCodeSessionDetailErrorComponent error={toError(detailQuery.error)} />;
244
- }
245
-
246
- if (!detail) {
247
- return (
248
- <OpenCodeSessionDetailErrorComponent error={new Error(`OpenCode session not found: ${params.sessionId}`)} />
249
- );
250
- }
251
-
252
236
  return (
253
237
  <div className="space-y-6">
254
238
  <PageHeader
@@ -409,8 +393,17 @@ const OpenCodeSessionDetailPage = () => {
409
393
  };
410
394
 
411
395
  export const Route = createFileRoute('/opencode-sessions/$sessionId')({
412
- component: OpenCodeSessionDetailPage,
396
+ component: () => {
397
+ const { sessionId } = Route.useParams();
398
+ return (
399
+ <RouteStateResetBoundary routeKey={sessionId}>
400
+ <OpenCodeSessionDetailPage />
401
+ </RouteStateResetBoundary>
402
+ );
403
+ },
413
404
  errorComponent: OpenCodeSessionDetailErrorComponent,
405
+ loader: ({ context, params }) =>
406
+ context.queryClient.ensureQueryData(openCodeSessionDetailQueryOptions(params.sessionId)),
414
407
  pendingComponent: () => (
415
408
  <LoadingPanel
416
409
  description="Loading the OpenCode transcript, parts, and session metadata."