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
@@ -1,24 +1,36 @@
1
1
  import type { KiroSessionSummary, KiroWorkspaceGroup } from '@spiracha/lib/kiro-exporter-types';
2
- import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
3
- import { createFileRoute } from '@tanstack/react-router';
4
- import { useDeferredValue, useState } from 'react';
2
+ import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
3
+ import { createFileRoute, useNavigate } from '@tanstack/react-router';
4
+ import { Trash2 } from 'lucide-react';
5
+ import { useDeferredValue, useMemo, useState } from 'react';
6
+ import { DeleteConfirmDialog } from '#/components/delete-confirm-dialog';
5
7
  import { ExportDialog } from '#/components/export-dialog';
6
8
  import { KiroSessionsTable } from '#/components/kiro-sessions-table';
7
9
  import { ListSearchInput } from '#/components/list-search-input';
8
10
  import { LoadingPanel } from '#/components/loading-panel';
9
11
  import { PageHeader } from '#/components/page-header';
10
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
12
+ import { RouteErrorPanel } from '#/components/route-error-panel';
13
+ import { Button } from '#/components/ui/button';
11
14
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
15
+ import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
12
16
  import { kiroSessionsQueryOptions, kiroWorkspacesQueryOptions } from '#/lib/kiro-queries';
13
- import { exportKiroSessionFn } from '#/lib/kiro-server';
17
+ import {
18
+ deleteKiroSessionFn,
19
+ deleteKiroSessionsFn,
20
+ exportKiroSessionFn,
21
+ exportKiroSessionsFn,
22
+ } from '#/lib/kiro-server';
14
23
  import { matchesTextQuery } from '#/lib/text-filter';
24
+ import { isWorkspaceEmptiedByDelete } from '#/lib/workspace-delete-navigation';
15
25
 
16
- type ExportDialogOptions = {
17
- includeCommentary: boolean;
18
- includeMetadata: boolean;
19
- includeTools: boolean;
20
- outputFormat: 'md' | 'txt';
21
- zipArchive: boolean;
26
+ type PendingSessionDelete = {
27
+ scope: 'all' | 'selected';
28
+ sessions: KiroSessionSummary[];
29
+ };
30
+
31
+ type PendingSessionExport = {
32
+ label: string;
33
+ sessionIds: string[];
22
34
  };
23
35
 
24
36
  const findWorkspaceOrThrow = (workspaces: KiroWorkspaceGroup[], workspaceKey: string) => {
@@ -30,35 +42,89 @@ const findWorkspaceOrThrow = (workspaces: KiroWorkspaceGroup[], workspaceKey: st
30
42
  return workspace;
31
43
  };
32
44
 
45
+ const buildSessionExport = (selectedSessions: KiroSessionSummary[]): PendingSessionExport => ({
46
+ label: selectedSessions.length === 1 ? selectedSessions[0]!.title : `${selectedSessions.length} selected sessions`,
47
+ sessionIds: selectedSessions.map((session) => session.sessionId),
48
+ });
49
+
50
+ const getDeleteConfirmLabel = (pendingDelete: PendingSessionDelete | null, isPending: boolean) => {
51
+ if (isPending) {
52
+ return 'Deleting...';
53
+ }
54
+
55
+ if (pendingDelete?.scope === 'all') {
56
+ return 'Delete all';
57
+ }
58
+
59
+ return pendingDelete && pendingDelete.sessions.length > 1 ? 'Delete sessions' : 'Delete session';
60
+ };
61
+
62
+ const getDeleteDescription = (pendingDelete: PendingSessionDelete | null) => {
63
+ if (!pendingDelete) {
64
+ return 'Permanently delete the selected Kiro sessions from disk.';
65
+ }
66
+
67
+ if (pendingDelete.scope === 'all') {
68
+ return `Permanently delete all ${pendingDelete.sessions.length} Kiro sessions in this workspace from disk. This removes session JSON files and matching execution files.`;
69
+ }
70
+
71
+ if (pendingDelete.sessions.length === 1) {
72
+ return `Permanently delete "${pendingDelete.sessions[0]!.title}" from Kiro history. This removes the session JSON file and matching execution files from disk.`;
73
+ }
74
+
75
+ return `Permanently delete ${pendingDelete.sessions.length} selected Kiro sessions from disk. This removes session JSON files and matching execution files.`;
76
+ };
77
+
78
+ const getDeleteTitle = (pendingDelete: PendingSessionDelete | null) => {
79
+ if (pendingDelete?.scope === 'all') {
80
+ return `Delete all ${pendingDelete.sessions.length} Kiro sessions?`;
81
+ }
82
+
83
+ return pendingDelete && pendingDelete.sessions.length > 1
84
+ ? `Delete ${pendingDelete.sessions.length} Kiro sessions?`
85
+ : 'Delete this Kiro session?';
86
+ };
87
+
33
88
  const KiroWorkspaceErrorComponent = ({ error }: { error: Error }) => {
34
- return <ReloadErrorPanel description={error.message} title="Failed to load Kiro workspace" />;
89
+ return <RouteErrorPanel error={error} title="Failed to load Kiro workspace" />;
35
90
  };
36
91
 
37
92
  const KiroWorkspacePage = () => {
93
+ const navigate = useNavigate({ from: Route.fullPath });
38
94
  const params = Route.useParams();
95
+ const queryClient = useQueryClient();
39
96
  const workspaces = useSuspenseQuery(kiroWorkspacesQueryOptions()).data;
40
97
  const workspace = findWorkspaceOrThrow(workspaces, params.workspaceKey);
41
98
  const sessions = useSuspenseQuery(kiroSessionsQueryOptions(workspace.key)).data;
42
99
  const [searchInput, setSearchInput] = useState('');
43
- const [pendingExport, setPendingExport] = useState<KiroSessionSummary | null>(null);
100
+ const [pendingDelete, setPendingDelete] = useState<PendingSessionDelete | null>(null);
101
+ const [pendingExport, setPendingExport] = useState<PendingSessionExport | null>(null);
44
102
  const deferredSearch = useDeferredValue(searchInput);
45
103
 
46
104
  const exportMutation = useMutation({
47
- mutationFn: async (options: ExportDialogOptions) => {
48
- if (!pendingExport) {
49
- throw new Error('No Kiro session selected for export');
50
- }
51
-
52
- const download = await exportKiroSessionFn({
53
- data: {
54
- includeCommentary: options.includeCommentary,
55
- includeMetadata: options.includeMetadata,
56
- includeTools: options.includeTools,
57
- outputFormat: options.outputFormat,
58
- sessionId: pendingExport.sessionId,
59
- zipArchive: options.zipArchive,
60
- },
61
- });
105
+ mutationFn: async ({ ids, options }: ExportSelectionMutationInput) => {
106
+ const download =
107
+ ids.length === 1
108
+ ? await exportKiroSessionFn({
109
+ data: {
110
+ includeCommentary: options.includeCommentary,
111
+ includeMetadata: options.includeMetadata,
112
+ includeTools: options.includeTools,
113
+ outputFormat: options.outputFormat,
114
+ sessionId: ids[0]!,
115
+ zipArchive: options.zipArchive,
116
+ },
117
+ })
118
+ : await exportKiroSessionsFn({
119
+ data: {
120
+ includeCommentary: options.includeCommentary,
121
+ includeMetadata: options.includeMetadata,
122
+ includeTools: options.includeTools,
123
+ outputFormat: options.outputFormat,
124
+ sessionIds: [...ids],
125
+ zipArchive: options.zipArchive,
126
+ },
127
+ });
62
128
  if (download.mode === 'download') {
63
129
  downloadTextFile(download.fileName, download.content, download.mimeType);
64
130
  return;
@@ -71,40 +137,117 @@ const KiroWorkspacePage = () => {
71
137
  },
72
138
  });
73
139
 
74
- const visibleSessions = sessions.filter((session) =>
75
- matchesTextQuery(deferredSearch, [
76
- session.title,
77
- session.sessionId,
78
- session.selectedModel,
79
- session.defaultModelTitle,
80
- session.selectedProfileId,
81
- session.sessionType,
82
- session.filePath,
83
- ]),
140
+ const deleteMutation = useMutation({
141
+ mutationFn: async (sessionIds: string[]) =>
142
+ sessionIds.length === 1
143
+ ? deleteKiroSessionFn({ data: { sessionId: sessionIds[0]! } })
144
+ : deleteKiroSessionsFn({ data: { sessionIds } }),
145
+ onSettled: async (_result, _error, sessionIds) => {
146
+ await Promise.all([
147
+ queryClient.invalidateQueries({ queryKey: ['kiro-workspaces'] }),
148
+ queryClient.invalidateQueries({ queryKey: ['kiro-sessions', workspace.key] }),
149
+ ...sessionIds.map((sessionId) =>
150
+ queryClient.invalidateQueries({ queryKey: ['kiro-session', sessionId] }),
151
+ ),
152
+ ]);
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
+ },
161
+ });
162
+
163
+ const visibleSessions = useMemo(
164
+ () =>
165
+ sessions.filter((session) =>
166
+ matchesTextQuery(deferredSearch, [
167
+ session.title,
168
+ session.sessionId,
169
+ session.selectedModel,
170
+ session.defaultModelTitle,
171
+ session.selectedProfileId,
172
+ session.sessionType,
173
+ session.filePath,
174
+ ]),
175
+ ),
176
+ [deferredSearch, sessions],
177
+ );
178
+ const visibleSessionsById = useMemo(
179
+ () => new Map(visibleSessions.map((session) => [session.sessionId, session])),
180
+ [visibleSessions],
84
181
  );
182
+ const lookupSelectedSessions = (sessionIds: string[]) =>
183
+ sessionIds
184
+ .map((sessionId) => visibleSessionsById.get(sessionId) ?? null)
185
+ .filter((session): session is KiroSessionSummary => session !== null);
186
+ const openExportForSessions = (selectedSessions: KiroSessionSummary[]) => {
187
+ if (selectedSessions.length === 0) {
188
+ return;
189
+ }
190
+
191
+ setPendingExport(buildSessionExport(selectedSessions));
192
+ };
193
+ const openDeleteForSessions = (selectedSessions: KiroSessionSummary[], scope: PendingSessionDelete['scope']) => {
194
+ if (selectedSessions.length > 0) {
195
+ setPendingDelete({ scope, sessions: selectedSessions });
196
+ }
197
+ };
85
198
 
86
199
  return (
87
200
  <div className="space-y-6">
88
201
  <PageHeader
89
202
  actions={
90
- <ListSearchInput
91
- placeholder="Search session title, id, model, or type"
92
- value={searchInput}
93
- onValueChange={setSearchInput}
94
- />
203
+ <div className="flex flex-col gap-2 sm:flex-row">
204
+ <Button
205
+ className="rounded-full"
206
+ disabled={deleteMutation.isPending || sessions.length === 0}
207
+ type="button"
208
+ variant="destructive"
209
+ onClick={() => openDeleteForSessions(sessions, 'all')}
210
+ >
211
+ <Trash2 className="size-4" />
212
+ Delete all
213
+ </Button>
214
+ <ListSearchInput
215
+ placeholder="Search session title, id, model, or type"
216
+ value={searchInput}
217
+ onValueChange={setSearchInput}
218
+ />
219
+ </div>
95
220
  }
96
221
  eyebrow="Kiro workspace"
97
222
  subtitle="Inspect local Kiro sessions, user prompts, assistant responses, images, and prompt logs."
98
223
  title={workspace.label}
99
224
  />
100
225
 
101
- <KiroSessionsTable sessions={visibleSessions} onExportSession={setPendingExport} />
226
+ <KiroSessionsTable
227
+ sessions={visibleSessions}
228
+ onDeleteSession={(session) => openDeleteForSessions([session], 'selected')}
229
+ onDeleteSessions={(sessionIds) => openDeleteForSessions(lookupSelectedSessions(sessionIds), 'selected')}
230
+ onExportSession={(session) => openExportForSessions([session])}
231
+ onExportSessions={(sessionIds) => openExportForSessions(lookupSelectedSessions(sessionIds))}
232
+ />
102
233
 
103
234
  <ExportDialog
235
+ errorMessage={
236
+ exportMutation.isError
237
+ ? exportMutation.error instanceof Error
238
+ ? exportMutation.error.message
239
+ : 'Session export failed'
240
+ : null
241
+ }
242
+ forceZipArchive={pendingExport ? pendingExport.sessionIds.length > 1 : false}
104
243
  open={pendingExport !== null}
105
244
  pending={exportMutation.isPending}
106
- title={pendingExport ? `Export ${pendingExport.title}` : 'Export session'}
107
- onExport={(options) => exportMutation.mutate(options)}
245
+ title={pendingExport ? `Export ${pendingExport.label}` : 'Export session'}
246
+ onExport={(options) => {
247
+ if (pendingExport) {
248
+ exportMutation.mutate(createExportSelectionMutationInput(pendingExport.sessionIds, options));
249
+ }
250
+ }}
108
251
  onOpenChange={(open) => {
109
252
  if (!open) {
110
253
  setPendingExport(null);
@@ -113,11 +256,30 @@ const KiroWorkspacePage = () => {
113
256
  }}
114
257
  />
115
258
 
116
- {exportMutation.isError ? (
117
- <p className="text-[var(--destructive)] text-sm">
118
- {exportMutation.error instanceof Error ? exportMutation.error.message : 'Session export failed'}
119
- </p>
120
- ) : null}
259
+ <DeleteConfirmDialog
260
+ confirmLabel={getDeleteConfirmLabel(pendingDelete, deleteMutation.isPending)}
261
+ description={getDeleteDescription(pendingDelete)}
262
+ errorMessage={
263
+ deleteMutation.isError
264
+ ? deleteMutation.error instanceof Error
265
+ ? deleteMutation.error.message
266
+ : 'Session delete failed'
267
+ : null
268
+ }
269
+ open={pendingDelete !== null}
270
+ title={getDeleteTitle(pendingDelete)}
271
+ onConfirm={() => {
272
+ if (pendingDelete) {
273
+ deleteMutation.mutate(pendingDelete.sessions.map((session) => session.sessionId));
274
+ }
275
+ }}
276
+ onOpenChange={(open) => {
277
+ if (!open) {
278
+ setPendingDelete(null);
279
+ deleteMutation.reset();
280
+ }
281
+ }}
282
+ />
121
283
  </div>
122
284
  );
123
285
  };
@@ -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,34 +1,30 @@
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, useSuspenseQuery } from '@tanstack/react-query';
4
- import { createFileRoute, Link } from '@tanstack/react-router';
5
- import { Download } from 'lucide-react';
3
+ import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
4
+ import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
5
+ import { Download, Trash2 } from 'lucide-react';
6
6
  import { useMemo, useState } from 'react';
7
7
  import { Breadcrumbs } from '#/components/breadcrumbs';
8
+ import { DeleteConfirmDialog } from '#/components/delete-confirm-dialog';
8
9
  import { ExportDialog } from '#/components/export-dialog';
9
10
  import { JsonPanel } from '#/components/json-panel';
10
11
  import { LoadingPanel } from '#/components/loading-panel';
11
12
  import { MetadataSection } from '#/components/metadata-section';
12
13
  import { MetricCard } from '#/components/metric-card';
13
14
  import { PageHeader } from '#/components/page-header';
14
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
15
- import { TranscriptView } from '#/components/transcript-view';
15
+ import { RouteErrorPanel } from '#/components/route-error-panel';
16
+ import { DEFAULT_SHOW_USER_MESSAGES, TranscriptView } from '#/components/transcript-view';
16
17
  import { Button } from '#/components/ui/button';
17
18
  import { Checkbox } from '#/components/ui/checkbox';
18
19
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
19
20
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
21
+ import type { ExportDialogOptions } from '#/lib/export-options';
20
22
  import { formatDateTime, formatList, formatNumber, formatTokens } from '#/lib/formatters';
21
- import { openCodeSessionDetailQueryOptions } from '#/lib/opencode-queries';
22
- import { exportOpenCodeSessionFn } from '#/lib/opencode-server';
23
+ import { openCodeSessionDetailQueryOptions, openCodeWorkspacesQueryOptions } from '#/lib/opencode-queries';
24
+ import { deleteOpenCodeSessionFn, exportOpenCodeSessionFn } from '#/lib/opencode-server';
23
25
  import { getOpenCodeThreadTranscriptStats, openCodeTranscriptToThreadEvents } from '#/lib/opencode-transcript-events';
24
-
25
- type ExportDialogOptions = {
26
- includeCommentary: boolean;
27
- includeMetadata: boolean;
28
- includeTools: boolean;
29
- outputFormat: 'md' | 'txt';
30
- zipArchive: boolean;
31
- };
26
+ import { RouteStateResetBoundary } from '#/lib/route-state-reset';
27
+ import { shouldNavigateToSourceIndexAfterDelete } from '#/lib/workspace-delete-navigation';
32
28
 
33
29
  type TranscriptControlsProps = {
34
30
  rawJsonDisabled?: boolean;
@@ -45,7 +41,7 @@ type TranscriptControlsProps = {
45
41
  };
46
42
 
47
43
  const OpenCodeSessionDetailErrorComponent = ({ error }: { error: Error }) => {
48
- return <ReloadErrorPanel description={error.message} title="Failed to load OpenCode session" />;
44
+ return <RouteErrorPanel error={error} title="Failed to load OpenCode session" />;
49
45
  };
50
46
 
51
47
  const buildSessionMetadata = (detail: OpenCodeSessionTranscript) => [
@@ -159,18 +155,26 @@ const OpenCodeRawPanels = ({ detail, events }: { detail: OpenCodeSessionTranscri
159
155
  };
160
156
 
161
157
  const OpenCodeSessionDetailPage = () => {
162
- const detail = useSuspenseQuery(openCodeSessionDetailQueryOptions(Route.useParams().sessionId)).data;
158
+ const navigate = useNavigate();
159
+ const queryClient = useQueryClient();
160
+ const params = Route.useParams();
161
+ const detail = useSuspenseQuery(openCodeSessionDetailQueryOptions(params.sessionId)).data;
162
+ const [deleteOpen, setDeleteOpen] = useState(false);
163
163
  const [pendingExport, setPendingExport] = useState(false);
164
164
  const [showToolCalls, setShowToolCalls] = useState(false);
165
165
  const [showCommentary, setShowCommentary] = useState(false);
166
166
  const [showExtraEvents, setShowExtraEvents] = useState(false);
167
167
  const [showRawJson, setShowRawJson] = useState(false);
168
- const [showUserMessages, setShowUserMessages] = useState(true);
169
- const transcriptEvents = useMemo(() => openCodeTranscriptToThreadEvents(detail), [detail]);
168
+ const [showUserMessages, setShowUserMessages] = useState(DEFAULT_SHOW_USER_MESSAGES);
169
+ const transcriptEvents = useMemo(() => (detail ? openCodeTranscriptToThreadEvents(detail) : []), [detail]);
170
170
  const transcriptStats = useMemo(() => getOpenCodeThreadTranscriptStats(transcriptEvents), [transcriptEvents]);
171
171
 
172
172
  const exportSessionMutation = useMutation({
173
173
  mutationFn: async (options: ExportDialogOptions) => {
174
+ if (!detail) {
175
+ throw new Error(`OpenCode session not found: ${params.sessionId}`);
176
+ }
177
+
174
178
  const download = await exportOpenCodeSessionFn({
175
179
  data: {
176
180
  includeCommentary: options.includeCommentary,
@@ -193,19 +197,66 @@ const OpenCodeSessionDetailPage = () => {
193
197
  },
194
198
  });
195
199
 
200
+ const deleteSessionMutation = useMutation({
201
+ mutationFn: () => {
202
+ if (!detail) {
203
+ throw new Error(`OpenCode session not found: ${params.sessionId}`);
204
+ }
205
+
206
+ return deleteOpenCodeSessionFn({ data: { sessionId: detail.session.sessionId } });
207
+ },
208
+ onSuccess: async () => {
209
+ if (!detail) {
210
+ return;
211
+ }
212
+
213
+ await Promise.all([
214
+ queryClient.invalidateQueries({ queryKey: ['opencode-workspaces'] }),
215
+ queryClient.invalidateQueries({ queryKey: ['opencode-sessions', detail.session.workspaceKey] }),
216
+ queryClient.invalidateQueries({ queryKey: ['opencode-session', detail.session.sessionId] }),
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
+ }
229
+ navigate({
230
+ params: { workspaceKey: detail.session.workspaceKey },
231
+ to: '/opencode/$workspaceKey',
232
+ });
233
+ },
234
+ });
235
+
196
236
  return (
197
237
  <div className="space-y-6">
198
238
  <PageHeader
199
239
  actions={
200
- <Button
201
- className="rounded-full"
202
- type="button"
203
- variant="outline"
204
- onClick={() => setPendingExport(true)}
205
- >
206
- <Download className="mr-2 size-4" />
207
- Export
208
- </Button>
240
+ <>
241
+ <Button
242
+ className="rounded-full"
243
+ type="button"
244
+ variant="outline"
245
+ onClick={() => setPendingExport(true)}
246
+ >
247
+ <Download className="mr-2 size-4" />
248
+ Export
249
+ </Button>
250
+ <Button
251
+ className="rounded-full border-[var(--destructive)]/20 text-[var(--destructive)]"
252
+ type="button"
253
+ variant="outline"
254
+ onClick={() => setDeleteOpen(true)}
255
+ >
256
+ <Trash2 className="mr-2 size-4" />
257
+ Delete
258
+ </Button>
259
+ </>
209
260
  }
210
261
  breadcrumb={
211
262
  <Breadcrumbs
@@ -316,12 +367,40 @@ const OpenCodeSessionDetailPage = () => {
316
367
  }
317
368
  }}
318
369
  />
370
+
371
+ <DeleteConfirmDialog
372
+ confirmLabel={deleteSessionMutation.isPending ? 'Deleting...' : 'Delete session'}
373
+ description="Permanently delete this OpenCode session from the database. This removes the session, child sessions, messages, and parts."
374
+ errorMessage={
375
+ deleteSessionMutation.isError
376
+ ? deleteSessionMutation.error instanceof Error
377
+ ? deleteSessionMutation.error.message
378
+ : 'Session delete failed'
379
+ : null
380
+ }
381
+ open={deleteOpen}
382
+ title="Delete this OpenCode session?"
383
+ onConfirm={() => deleteSessionMutation.mutate()}
384
+ onOpenChange={(open) => {
385
+ setDeleteOpen(open);
386
+ if (!open) {
387
+ deleteSessionMutation.reset();
388
+ }
389
+ }}
390
+ />
319
391
  </div>
320
392
  );
321
393
  };
322
394
 
323
395
  export const Route = createFileRoute('/opencode-sessions/$sessionId')({
324
- component: OpenCodeSessionDetailPage,
396
+ component: () => {
397
+ const { sessionId } = Route.useParams();
398
+ return (
399
+ <RouteStateResetBoundary routeKey={sessionId}>
400
+ <OpenCodeSessionDetailPage />
401
+ </RouteStateResetBoundary>
402
+ );
403
+ },
325
404
  errorComponent: OpenCodeSessionDetailErrorComponent,
326
405
  loader: ({ context, params }) =>
327
406
  context.queryClient.ensureQueryData(openCodeSessionDetailQueryOptions(params.sessionId)),