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
@@ -3,6 +3,8 @@ import { createRootRouteWithContext, HeadContent, Outlet, Scripts } from '@tanst
3
3
  import type { ReactNode } from 'react';
4
4
  import { AppShell } from '#/components/app-shell';
5
5
  import { TooltipProvider } from '#/components/ui/tooltip';
6
+ import { getErrorPresentation } from '#/lib/error-presentation';
7
+ import { getInitialSettingsFn } from '#/lib/settings-server';
6
8
  import { SettingsProvider } from '#/lib/settings-store';
7
9
  import appCss from '#/styles.css?url';
8
10
 
@@ -23,26 +25,18 @@ const themeInitScript = `
23
25
  `;
24
26
 
25
27
  function RootErrorComponent({ error }: { error: Error }) {
26
- const isSqliteError =
27
- error.message.includes('unable to open database') ||
28
- error.message.includes('database is locked') ||
29
- error.message.includes('SQLITE_');
28
+ const presentation = getErrorPresentation(error, { fallbackTitle: 'Something went wrong' });
30
29
 
31
30
  return (
32
31
  <div className="flex min-h-screen items-center justify-center bg-[#101418] px-4 text-[#eef3f7]">
33
32
  <div className="max-w-[30rem] text-center">
34
- <h1 className="mb-3 font-semibold text-base">
35
- {isSqliteError ? 'Database unavailable' : 'Something went wrong'}
36
- </h1>
37
- {isSqliteError ? (
38
- <p className="my-2 text-[#99a3af] text-[0.875rem] leading-6">
39
- Spiracha could not open the Codex SQLite database. Codex may have an exclusive lock on the file,
40
- or the database does not exist yet. Close Codex or wait a moment, then reload.
41
- </p>
33
+ <h1 className="mb-3 font-semibold text-base">{presentation.title}</h1>
34
+ {presentation.isDatabaseError ? (
35
+ <p className="my-2 text-[#99a3af] text-[0.875rem] leading-6">{presentation.description}</p>
42
36
  ) : (
43
37
  <p className="my-2 text-[#99a3af] text-[0.875rem] leading-6">
44
38
  <code className="rounded border border-white/10 bg-[#12181e] px-1.5 py-1 text-[0.8em]">
45
- {error.message}
39
+ {presentation.description}
46
40
  </code>
47
41
  </p>
48
42
  )}
@@ -87,7 +81,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
87
81
  },
88
82
  {
89
83
  content:
90
- 'Browse local Codex, Claude Code, Kiro, Qoder, Cursor, Antigravity, and OpenCode history through a compact workspace UI.',
84
+ 'Browse local Codex, Claude Code, Grok, Kiro, Qoder, Cursor, Antigravity, and OpenCode history through a compact workspace UI.',
91
85
  name: 'description',
92
86
  },
93
87
  {
@@ -95,12 +89,15 @@ export const Route = createRootRouteWithContext<RouterContext>()({
95
89
  },
96
90
  ],
97
91
  }),
92
+ loader: () => getInitialSettingsFn(),
98
93
  });
99
94
 
100
95
  function RootComponent() {
96
+ const initialSettings = Route.useLoaderData();
97
+
101
98
  return (
102
99
  <RootDocument>
103
- <SettingsProvider>
100
+ <SettingsProvider initialSettings={initialSettings}>
104
101
  <TooltipProvider>
105
102
  <AppShell>
106
103
  <Outlet />
@@ -1,9 +1,7 @@
1
- import type { ModelTokenSummary, ToolUsageSummary } from '@spiracha/lib/codex-browser-types';
2
1
  import { useSuspenseQuery } from '@tanstack/react-query';
3
2
  import { createFileRoute, useNavigate } from '@tanstack/react-router';
4
- import { createColumnHelper } from '@tanstack/react-table';
5
3
  import { startTransition } from 'react';
6
- import { DataTable } from '#/components/data-table';
4
+ import { AnalyticsBreakdowns } from '#/components/analytics-breakdowns';
7
5
  import { MetricCard } from '#/components/metric-card';
8
6
  import { PageHeader } from '#/components/page-header';
9
7
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '#/components/ui/select';
@@ -16,34 +14,6 @@ import {
16
14
  withAnalyticsProjectSearch,
17
15
  } from '#/lib/route-search';
18
16
 
19
- const toolUsageColumnHelper = createColumnHelper<ToolUsageSummary>();
20
- const toolUsageColumns = [
21
- toolUsageColumnHelper.accessor('name', {
22
- cell: (info) => <span className="font-mono text-sm">{info.getValue()}</span>,
23
- header: 'Tool',
24
- }),
25
- toolUsageColumnHelper.accessor('count', {
26
- cell: (info) => <span className="font-mono text-sm">{formatNumber(info.getValue())}</span>,
27
- header: 'Calls',
28
- }),
29
- ] as const;
30
-
31
- const modelColumnHelper = createColumnHelper<ModelTokenSummary>();
32
- const modelColumns = [
33
- modelColumnHelper.accessor('model', {
34
- cell: (info) => <span className="font-mono text-sm">{info.getValue()}</span>,
35
- header: 'Model',
36
- }),
37
- modelColumnHelper.accessor('threadCount', {
38
- cell: (info) => <span className="font-mono text-sm">{formatNumber(info.getValue())}</span>,
39
- header: 'Threads',
40
- }),
41
- modelColumnHelper.accessor('totalTokens', {
42
- cell: (info) => <span className="font-mono text-sm">{formatTokens(info.getValue())}</span>,
43
- header: 'Tokens',
44
- }),
45
- ] as const;
46
-
47
17
  export const Route = createFileRoute('/analytics')({
48
18
  component: AnalyticsPage,
49
19
  loader: ({ context, deps }) => {
@@ -98,7 +68,7 @@ function AnalyticsPage() {
98
68
  title="Analytics"
99
69
  />
100
70
 
101
- <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
71
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
102
72
  <MetricCard label="Threads" value={formatNumber(analytics.summary.totalThreads)} />
103
73
  <MetricCard label="Projects" value={formatNumber(analytics.summary.totalProjects)} />
104
74
  <MetricCard label="Tokens" value={formatTokens(analytics.summary.totalTokens)} />
@@ -106,38 +76,17 @@ function AnalyticsPage() {
106
76
  label="Average per thread"
107
77
  value={formatTokens(Math.round(analytics.summary.averageTokensPerThread))}
108
78
  />
79
+ <MetricCard label="Median per thread" value={formatTokens(analytics.summary.medianTokensPerThread)} />
109
80
  <MetricCard label="Web search threads" value={formatNumber(analytics.summary.threadsWithWebSearch)} />
81
+ <MetricCard label="Archived threads" value={formatNumber(analytics.summary.archivedThreads)} />
110
82
  </div>
111
83
 
112
- <div className="grid gap-4 xl:grid-cols-2">
113
- <section className="space-y-4">
114
- <div>
115
- <h3 className="font-semibold text-sm">Most frequent tool calls</h3>
116
- <p className="mt-1 text-[var(--muted-foreground)] text-sm">
117
- Useful for future prompt and tool optimization work.
118
- </p>
119
- </div>
120
- <DataTable
121
- columns={toolUsageColumns}
122
- data={analytics.toolUsage}
123
- emptyMessage="No tool calls recorded."
124
- />
125
- </section>
126
-
127
- <section className="space-y-4">
128
- <div>
129
- <h3 className="font-semibold text-sm">Model token breakdown</h3>
130
- <p className="mt-1 text-[var(--muted-foreground)] text-sm">
131
- Compare model usage and token concentration within the current project scope.
132
- </p>
133
- </div>
134
- <DataTable
135
- columns={modelColumns}
136
- data={analytics.modelsByTokens}
137
- emptyMessage="No model usage recorded."
138
- />
139
- </section>
140
- </div>
84
+ <AnalyticsBreakdowns
85
+ modelsByTokens={analytics.modelsByTokens}
86
+ reasoningEfforts={analytics.reasoningEfforts}
87
+ sources={analytics.sources}
88
+ toolUsage={analytics.toolUsage}
89
+ />
141
90
  </div>
142
91
  );
143
92
  }
@@ -5,12 +5,13 @@ import { useMemo, useState } from 'react';
5
5
  import { AntigravityKeychainPanel } from '#/components/antigravity-keychain-panel';
6
6
  import { Breadcrumbs } from '#/components/breadcrumbs';
7
7
  import { DeleteConfirmDialog } from '#/components/delete-confirm-dialog';
8
+ import { ExportDialog } from '#/components/export-dialog';
8
9
  import { JsonPanel } from '#/components/json-panel';
9
10
  import { LoadingPanel } from '#/components/loading-panel';
10
11
  import { MetadataSection } from '#/components/metadata-section';
11
12
  import { MetricCard } from '#/components/metric-card';
12
13
  import { PageHeader } from '#/components/page-header';
13
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
14
+ import { RouteErrorPanel } from '#/components/route-error-panel';
14
15
  import { TextDocumentPanel } from '#/components/text-document-panel';
15
16
  import { DEFAULT_SHOW_USER_MESSAGES, TranscriptView } from '#/components/transcript-view';
16
17
  import { Button } from '#/components/ui/button';
@@ -20,6 +21,7 @@ import { canExportAntigravityConversation } from '#/lib/antigravity-conversation
20
21
  import {
21
22
  antigravityConversationDetailQueryOptions,
22
23
  antigravityDecryptionQueryOptions,
24
+ antigravityWorkspacesQueryOptions,
23
25
  } from '#/lib/antigravity-queries';
24
26
  import {
25
27
  deleteAntigravityConversationFn,
@@ -31,8 +33,11 @@ import {
31
33
  antigravityMarkdownToThreadEvents,
32
34
  getAntigravityThreadTranscriptStats,
33
35
  } from '#/lib/antigravity-transcript-events';
34
- import { downloadTextFile } from '#/lib/download';
35
- import { formatBytes, formatDateTime, formatNumber } from '#/lib/formatters';
36
+ import { downloadTextFile, downloadUrlFile } from '#/lib/download';
37
+ import type { ExportDialogOptions } from '#/lib/export-options';
38
+ import { formatBytes, formatDateTime, formatList, formatNumber } from '#/lib/formatters';
39
+ import { RouteStateResetBoundary } from '#/lib/route-state-reset';
40
+ import { shouldNavigateToSourceIndexAfterDelete } from '#/lib/workspace-delete-navigation';
36
41
 
37
42
  type AntigravityConversationDetail = Awaited<ReturnType<typeof getAntigravityConversationDetailFn>>;
38
43
 
@@ -97,7 +102,7 @@ const buildTranscriptStatsItems = (
97
102
 
98
103
  const stats = getAntigravityThreadTranscriptStats(events);
99
104
  return [
100
- { label: 'Event kinds', value: [...new Set(events.map((event) => event.kind))].join(', ') || 'n/a' },
105
+ { label: 'Event kinds', value: formatList([...new Set(events.map((event) => event.kind))]) },
101
106
  { label: 'Messages', value: formatNumber(stats.messageCount) },
102
107
  { label: 'User messages', value: formatNumber(stats.userMessageCount) },
103
108
  { label: 'Assistant messages', value: formatNumber(stats.assistantMessageCount) },
@@ -117,7 +122,14 @@ const getConversationSizeLabel = (detail: AntigravityConversationDetail): string
117
122
  };
118
123
 
119
124
  export const Route = createFileRoute('/antigravity-conversations/$conversationId')({
120
- component: AntigravityConversationDetailPage,
125
+ component: () => {
126
+ const { conversationId } = Route.useParams();
127
+ return (
128
+ <RouteStateResetBoundary routeKey={conversationId}>
129
+ <AntigravityConversationDetailPage />
130
+ </RouteStateResetBoundary>
131
+ );
132
+ },
121
133
  errorComponent: AntigravityConversationDetailErrorComponent,
122
134
  loader: async ({ context, params }) => {
123
135
  await Promise.all([
@@ -134,7 +146,7 @@ export const Route = createFileRoute('/antigravity-conversations/$conversationId
134
146
  });
135
147
 
136
148
  function AntigravityConversationDetailErrorComponent({ error }: { error: Error }) {
137
- return <ReloadErrorPanel description={error.message} title="Failed to load Antigravity conversation" />;
149
+ return <RouteErrorPanel error={error} title="Failed to load Antigravity conversation" />;
138
150
  }
139
151
 
140
152
  function AntigravityConversationHeaderActions({
@@ -307,6 +319,7 @@ function AntigravityConversationDetailPage() {
307
319
  const decryptionState = useSuspenseQuery(antigravityDecryptionQueryOptions()).data;
308
320
  const detail = useSuspenseQuery(antigravityConversationDetailQueryOptions(Route.useParams().conversationId)).data;
309
321
  const [deleteOpen, setDeleteOpen] = useState(false);
322
+ const [exportOpen, setExportOpen] = useState(false);
310
323
  const [showToolCalls, setShowToolCalls] = useState(false);
311
324
  const [showCommentary, setShowCommentary] = useState(false);
312
325
  const [showExtraEvents, setShowExtraEvents] = useState(false);
@@ -324,10 +337,26 @@ function AntigravityConversationDetailPage() {
324
337
  const showConversationExport = canExportConversation || detail.transcriptLocked;
325
338
 
326
339
  const exportConversationMutation = useMutation({
327
- mutationFn: () =>
328
- exportAntigravityConversationFn({ data: { conversationId: detail.conversation.conversationId } }),
329
- onSuccess: (result) => {
330
- downloadTextFile(result.filename, result.content, 'text/markdown; charset=utf-8');
340
+ mutationFn: async (options: ExportDialogOptions) => {
341
+ const download = await exportAntigravityConversationFn({
342
+ data: {
343
+ conversationId: detail.conversation.conversationId,
344
+ includeCommentary: options.includeCommentary,
345
+ includeMetadata: options.includeMetadata,
346
+ includeTools: options.includeTools,
347
+ outputFormat: options.outputFormat,
348
+ zipArchive: options.zipArchive,
349
+ },
350
+ });
351
+ if (download.mode === 'download') {
352
+ downloadTextFile(download.fileName, download.content, download.mimeType);
353
+ return;
354
+ }
355
+
356
+ await downloadUrlFile(download.fileName, download.downloadUrl);
357
+ },
358
+ onSuccess: () => {
359
+ setExportOpen(false);
331
360
  },
332
361
  });
333
362
 
@@ -352,6 +381,17 @@ function AntigravityConversationDetailPage() {
352
381
  queryKey: ['antigravity-conversation', detail.conversation.conversationId],
353
382
  }),
354
383
  ]);
384
+ const workspaces = await queryClient.fetchQuery(antigravityWorkspacesQueryOptions());
385
+ if (
386
+ shouldNavigateToSourceIndexAfterDelete(
387
+ workspaces,
388
+ detail.conversationGroup.key,
389
+ (workspace) => workspace.key,
390
+ )
391
+ ) {
392
+ navigate({ to: '/antigravity' });
393
+ return;
394
+ }
355
395
  navigate({
356
396
  params: { workspaceKey: detail.conversationGroup.key },
357
397
  to: '/antigravity/$workspaceKey',
@@ -371,7 +411,7 @@ function AntigravityConversationDetailPage() {
371
411
  showConversationExport={showConversationExport}
372
412
  onDeleteConversation={() => setDeleteOpen(true)}
373
413
  onExportArtifacts={() => exportArtifactsMutation.mutate()}
374
- onExportConversation={() => exportConversationMutation.mutate()}
414
+ onExportConversation={() => setExportOpen(true)}
375
415
  />
376
416
  }
377
417
  breadcrumb={
@@ -463,14 +503,6 @@ function AntigravityConversationDetailPage() {
463
503
  </TabsContent>
464
504
  </Tabs>
465
505
 
466
- {exportConversationMutation.isError ? (
467
- <p className="text-[var(--destructive)] text-sm">
468
- {exportConversationMutation.error instanceof Error
469
- ? exportConversationMutation.error.message
470
- : 'Conversation export failed'}
471
- </p>
472
- ) : null}
473
-
474
506
  {exportArtifactsMutation.isError ? (
475
507
  <p className="text-[var(--destructive)] text-sm">
476
508
  {exportArtifactsMutation.error instanceof Error
@@ -479,6 +511,28 @@ function AntigravityConversationDetailPage() {
479
511
  </p>
480
512
  ) : null}
481
513
 
514
+ <ExportDialog
515
+ errorMessage={
516
+ exportConversationMutation.isError
517
+ ? exportConversationMutation.error instanceof Error
518
+ ? exportConversationMutation.error.message
519
+ : 'Conversation export failed'
520
+ : null
521
+ }
522
+ open={exportOpen}
523
+ pending={exportConversationMutation.isPending}
524
+ showCommentaryOption={detail.conversation.transcriptSource !== 'safe-storage'}
525
+ showToolsOption={detail.conversation.transcriptSource !== 'safe-storage'}
526
+ title={`Export ${detail.conversation.title}`}
527
+ onExport={(options) => exportConversationMutation.mutate(options)}
528
+ onOpenChange={(open) => {
529
+ setExportOpen(open);
530
+ if (!open) {
531
+ exportConversationMutation.reset();
532
+ }
533
+ }}
534
+ />
535
+
482
536
  <DeleteConfirmDialog
483
537
  confirmLabel={deleteConversationMutation.isPending ? 'Deleting...' : 'Delete conversation'}
484
538
  description="Permanently delete this Antigravity conversation from disk. This removes its summary entry, conversation file, transcript logs, and generated artifacts."
@@ -10,7 +10,7 @@ import { ExportDialog } from '#/components/export-dialog';
10
10
  import { ListSearchInput } from '#/components/list-search-input';
11
11
  import { LoadingPanel } from '#/components/loading-panel';
12
12
  import { PageHeader } from '#/components/page-header';
13
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
13
+ import { RouteErrorPanel } from '#/components/route-error-panel';
14
14
  import { Button } from '#/components/ui/button';
15
15
  import {
16
16
  antigravityConversationsQueryOptions,
@@ -21,21 +21,13 @@ import {
21
21
  deleteAntigravityConversationFn,
22
22
  deleteAntigravityConversationsFn,
23
23
  exportAntigravityArtifactsFn,
24
- exportAntigravityConversationFn,
25
24
  exportAntigravityConversationsFn,
26
25
  } from '#/lib/antigravity-server';
27
26
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
27
+ import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
28
28
  import { matchesTextQuery } from '#/lib/text-filter';
29
29
  import { isWorkspaceEmptiedByDelete } from '#/lib/workspace-delete-navigation';
30
30
 
31
- type ExportDialogOptions = {
32
- includeCommentary: boolean;
33
- includeMetadata: boolean;
34
- includeTools: boolean;
35
- outputFormat: 'md' | 'txt';
36
- zipArchive: boolean;
37
- };
38
-
39
31
  type PendingConversationDelete = {
40
32
  conversations: AntigravityConversation[];
41
33
  scope: 'all' | 'selected';
@@ -44,6 +36,7 @@ type PendingConversationDelete = {
44
36
  type PendingConversationExport = {
45
37
  conversationIds: string[];
46
38
  label: string;
39
+ supportsTranscriptFilters: boolean;
47
40
  };
48
41
 
49
42
  const findWorkspaceOrThrow = (workspaces: AntigravityWorkspaceGroup[], workspaceKey: string) => {
@@ -61,6 +54,9 @@ const buildConversationExport = (selectedConversations: AntigravityConversation[
61
54
  selectedConversations.length === 1
62
55
  ? selectedConversations[0]!.title
63
56
  : `${selectedConversations.length} selected conversations`,
57
+ supportsTranscriptFilters: selectedConversations.every(
58
+ (conversation) => conversation.transcriptSource !== 'safe-storage',
59
+ ),
64
60
  });
65
61
 
66
62
  const getDeleteConfirmLabel = (pendingDelete: PendingConversationDelete | null, isPending: boolean) => {
@@ -101,32 +97,12 @@ const getDeleteTitle = (pendingDelete: PendingConversationDelete | null) => {
101
97
  : 'Delete this Antigravity conversation?';
102
98
  };
103
99
 
104
- const AntigravityWorkspaceErrors = ({
105
- artifactError,
106
- batchExportError,
107
- conversationError,
108
- }: {
109
- artifactError: Error | null;
110
- batchExportError: Error | null;
111
- conversationError: Error | null;
112
- }) => {
113
- const messages = [conversationError?.message, artifactError?.message, batchExportError?.message].filter(
114
- (message): message is string => Boolean(message),
115
- );
116
-
117
- if (messages.length === 0) {
100
+ const AntigravityWorkspaceErrors = ({ artifactError }: { artifactError: Error | null }) => {
101
+ if (!artifactError) {
118
102
  return null;
119
103
  }
120
104
 
121
- return (
122
- <div className="space-y-1">
123
- {messages.map((message) => (
124
- <p className="text-[var(--destructive)] text-sm" key={message}>
125
- {message}
126
- </p>
127
- ))}
128
- </div>
129
- );
105
+ return <p className="text-[var(--destructive)] text-sm">{artifactError.message}</p>;
130
106
  };
131
107
 
132
108
  export const Route = createFileRoute('/antigravity/$workspaceKey')({
@@ -149,7 +125,7 @@ export const Route = createFileRoute('/antigravity/$workspaceKey')({
149
125
  });
150
126
 
151
127
  function AntigravityWorkspaceErrorComponent({ error }: { error: Error }) {
152
- return <ReloadErrorPanel description={error.message} title="Failed to load Antigravity workspace" />;
128
+ return <RouteErrorPanel error={error} title="Failed to load Antigravity workspace" />;
153
129
  }
154
130
 
155
131
  function AntigravityWorkspacePage() {
@@ -165,14 +141,6 @@ function AntigravityWorkspacePage() {
165
141
  const [pendingExport, setPendingExport] = useState<PendingConversationExport | null>(null);
166
142
  const deferredSearch = useDeferredValue(searchInput);
167
143
 
168
- const exportConversationMutation = useMutation({
169
- mutationFn: (conversation: AntigravityConversation) =>
170
- exportAntigravityConversationFn({ data: { conversationId: conversation.conversationId } }),
171
- onSuccess: (result) => {
172
- downloadTextFile(result.filename, result.content, 'text/markdown; charset=utf-8');
173
- },
174
- });
175
-
176
144
  const exportArtifactsMutation = useMutation({
177
145
  mutationFn: (conversation: AntigravityConversation) =>
178
146
  exportAntigravityArtifactsFn({ data: { conversationId: conversation.conversationId } }),
@@ -182,14 +150,13 @@ function AntigravityWorkspacePage() {
182
150
  });
183
151
 
184
152
  const exportConversationsMutation = useMutation({
185
- mutationFn: async (options: ExportDialogOptions) => {
186
- if (!pendingExport) {
187
- throw new Error('No Antigravity conversation selected for export');
188
- }
189
-
153
+ mutationFn: async ({ ids, options }: ExportSelectionMutationInput) => {
190
154
  const download = await exportAntigravityConversationsFn({
191
155
  data: {
192
- conversationIds: pendingExport.conversationIds,
156
+ conversationIds: [...ids],
157
+ includeCommentary: options.includeCommentary,
158
+ includeMetadata: options.includeMetadata,
159
+ includeTools: options.includeTools,
193
160
  outputFormat: options.outputFormat,
194
161
  zipArchive: options.zipArchive,
195
162
  },
@@ -212,14 +179,7 @@ function AntigravityWorkspacePage() {
212
179
  conversationIds.length === 1
213
180
  ? deleteAntigravityConversationFn({ data: { conversationId: conversationIds[0]! } })
214
181
  : deleteAntigravityConversationsFn({ data: { conversationIds } }),
215
- onSuccess: async (result) => {
216
- const conversationIds = result.deletedConversationIds;
217
- const workspaceEmptied = isWorkspaceEmptiedByDelete(
218
- conversations,
219
- conversationIds,
220
- (conversation) => conversation.conversationId,
221
- );
222
- setPendingDelete(null);
182
+ onSettled: async (_result, _error, conversationIds) => {
223
183
  await Promise.all([
224
184
  queryClient.invalidateQueries({ queryKey: ['antigravity-workspaces'] }),
225
185
  queryClient.invalidateQueries({ queryKey: ['antigravity-conversations', workspace.key] }),
@@ -229,6 +189,15 @@ function AntigravityWorkspacePage() {
229
189
  }),
230
190
  ),
231
191
  ]);
192
+ },
193
+ onSuccess: async (result) => {
194
+ const conversationIds = result.deletedConversationIds;
195
+ const workspaceEmptied = isWorkspaceEmptiedByDelete(
196
+ conversations,
197
+ conversationIds,
198
+ (conversation) => conversation.conversationId,
199
+ );
200
+ setPendingDelete(null);
232
201
  if (workspaceEmptied) {
233
202
  await navigate({ to: '/antigravity' });
234
203
  }
@@ -308,7 +277,7 @@ function AntigravityWorkspacePage() {
308
277
  openDeleteForConversations(lookupSelectedConversations(conversationIds), 'selected')
309
278
  }
310
279
  onExportArtifacts={(conversation) => exportArtifactsMutation.mutate(conversation)}
311
- onExportConversation={(conversation) => exportConversationMutation.mutate(conversation)}
280
+ onExportConversation={(conversation) => openExportForConversations([conversation])}
312
281
  onExportConversations={(conversationIds) =>
313
282
  openExportForConversations(lookupSelectedConversations(conversationIds))
314
283
  }
@@ -316,16 +285,29 @@ function AntigravityWorkspacePage() {
316
285
 
317
286
  <AntigravityWorkspaceErrors
318
287
  artifactError={exportArtifactsMutation.isError ? exportArtifactsMutation.error : null}
319
- batchExportError={exportConversationsMutation.isError ? exportConversationsMutation.error : null}
320
- conversationError={exportConversationMutation.isError ? exportConversationMutation.error : null}
321
288
  />
322
289
 
323
290
  <ExportDialog
291
+ errorMessage={
292
+ exportConversationsMutation.isError
293
+ ? exportConversationsMutation.error instanceof Error
294
+ ? exportConversationsMutation.error.message
295
+ : 'Conversation export failed'
296
+ : null
297
+ }
324
298
  forceZipArchive={pendingExport ? pendingExport.conversationIds.length > 1 : false}
325
299
  open={pendingExport !== null}
326
300
  pending={exportConversationsMutation.isPending}
301
+ showCommentaryOption={pendingExport?.supportsTranscriptFilters ?? true}
302
+ showToolsOption={pendingExport?.supportsTranscriptFilters ?? true}
327
303
  title={pendingExport ? `Export ${pendingExport.label}` : 'Export conversation'}
328
- onExport={(options) => exportConversationsMutation.mutate(options)}
304
+ onExport={(options) => {
305
+ if (pendingExport) {
306
+ exportConversationsMutation.mutate(
307
+ createExportSelectionMutationInput(pendingExport.conversationIds, options),
308
+ );
309
+ }
310
+ }}
329
311
  onOpenChange={(open) => {
330
312
  if (!open) {
331
313
  setPendingExport(null);
@@ -5,7 +5,7 @@ import { AntigravityKeychainPanel } from '#/components/antigravity-keychain-pane
5
5
  import { AntigravityWorkspacesTable } from '#/components/antigravity-workspaces-table';
6
6
  import { ListSearchInput } from '#/components/list-search-input';
7
7
  import { PageHeader } from '#/components/page-header';
8
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
8
+ import { RouteErrorPanel } from '#/components/route-error-panel';
9
9
  import { antigravityWorkspacesQueryOptions } from '#/lib/antigravity-queries';
10
10
  import { matchesTextQuery } from '#/lib/text-filter';
11
11
 
@@ -16,7 +16,7 @@ export const Route = createFileRoute('/antigravity/')({
16
16
  });
17
17
 
18
18
  function AntigravityErrorComponent({ error }: { error: Error }) {
19
- return <ReloadErrorPanel description={error.message} title="Failed to load Antigravity workspaces" />;
19
+ return <RouteErrorPanel error={error} title="Failed to load Antigravity workspaces" />;
20
20
  }
21
21
 
22
22
  function AntigravityPage() {
@@ -12,7 +12,7 @@ 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';
@@ -20,6 +20,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
20
20
  import {
21
21
  claudeCodeSessionDetailQueryOptions,
22
22
  claudeCodeSessionTranscriptQueryOptions,
23
+ claudeCodeWorkspacesQueryOptions,
23
24
  } from '#/lib/claude-code-queries';
24
25
  import { deleteClaudeCodeSessionFn, exportClaudeCodeSessionFn } from '#/lib/claude-code-server';
25
26
  import {
@@ -27,15 +28,10 @@ import {
27
28
  getClaudeCodeThreadTranscriptStats,
28
29
  } from '#/lib/claude-code-transcript-events';
29
30
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
31
+ import type { ExportDialogOptions } from '#/lib/export-options';
30
32
  import { formatDateTime, formatList, formatNumber, formatTokens } from '#/lib/formatters';
31
-
32
- type ExportDialogOptions = {
33
- includeCommentary: boolean;
34
- includeMetadata: boolean;
35
- includeTools: boolean;
36
- outputFormat: 'md' | 'txt';
37
- zipArchive: boolean;
38
- };
33
+ import { RouteStateResetBoundary } from '#/lib/route-state-reset';
34
+ import { shouldNavigateToSourceIndexAfterDelete } from '#/lib/workspace-delete-navigation';
39
35
 
40
36
  type TranscriptControlsProps = {
41
37
  rawJsonDisabled?: boolean;
@@ -52,7 +48,14 @@ type TranscriptControlsProps = {
52
48
  };
53
49
 
54
50
  export const Route = createFileRoute('/claude-code-sessions/$sessionId')({
55
- component: ClaudeCodeSessionDetailPage,
51
+ component: () => {
52
+ const { sessionId } = Route.useParams();
53
+ return (
54
+ <RouteStateResetBoundary routeKey={sessionId}>
55
+ <ClaudeCodeSessionDetailPage />
56
+ </RouteStateResetBoundary>
57
+ );
58
+ },
56
59
  errorComponent: ClaudeCodeSessionDetailErrorComponent,
57
60
  loader: ({ context, params }) =>
58
61
  context.queryClient.ensureQueryData(claudeCodeSessionDetailQueryOptions(params.sessionId)),
@@ -65,7 +68,7 @@ export const Route = createFileRoute('/claude-code-sessions/$sessionId')({
65
68
  });
66
69
 
67
70
  function ClaudeCodeSessionDetailErrorComponent({ error }: { error: Error }) {
68
- return <ReloadErrorPanel description={error.message} title="Failed to load Claude Code session" />;
71
+ return <RouteErrorPanel error={error} title="Failed to load Claude Code session" />;
69
72
  }
70
73
 
71
74
  const buildSessionMetadata = (detail: ClaudeCodeSessionTranscript) => [
@@ -285,6 +288,17 @@ function ClaudeCodeSessionDetailPage() {
285
288
  queryClient.invalidateQueries({ queryKey: ['claude-code-session', params.sessionId] }),
286
289
  queryClient.invalidateQueries({ queryKey: ['claude-code-session-transcript', params.sessionId] }),
287
290
  ]);
291
+ const workspaces = await queryClient.fetchQuery(claudeCodeWorkspacesQueryOptions());
292
+ if (
293
+ shouldNavigateToSourceIndexAfterDelete(
294
+ workspaces,
295
+ detail.session.workspaceKey,
296
+ (workspace) => workspace.key,
297
+ )
298
+ ) {
299
+ navigate({ to: '/claude-code' });
300
+ return;
301
+ }
288
302
  navigate({
289
303
  params: { workspaceKey: detail.session.workspaceKey },
290
304
  to: '/claude-code/$workspaceKey',