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,33 +1,79 @@
1
1
  import type { OpenCodeSessionSummary, OpenCodeWorkspaceGroup } from '@spiracha/lib/opencode-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 { ListSearchInput } from '#/components/list-search-input';
7
9
  import { LoadingPanel } from '#/components/loading-panel';
8
10
  import { OpenCodeSessionsTable } from '#/components/opencode-sessions-table';
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 { openCodeSessionsQueryOptions, openCodeWorkspacesQueryOptions } from '#/lib/opencode-queries';
13
- import { exportOpenCodeSessionFn } from '#/lib/opencode-server';
17
+ import {
18
+ deleteOpenCodeSessionFn,
19
+ deleteOpenCodeSessionsFn,
20
+ exportOpenCodeSessionFn,
21
+ exportOpenCodeSessionsFn,
22
+ } from '#/lib/opencode-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: OpenCodeSessionSummary[];
22
29
  };
23
30
 
24
- const findWorkspaceOrThrow = (workspaces: OpenCodeWorkspaceGroup[], workspaceKey: string) => {
25
- const workspace = workspaces.find((candidate) => candidate.key === workspaceKey);
26
- if (!workspace) {
27
- throw new Error(`OpenCode workspace not found: ${workspaceKey}`);
31
+ type PendingSessionExport = {
32
+ label: string;
33
+ sessionIds: string[];
34
+ };
35
+
36
+ const buildSessionExport = (selectedSessions: OpenCodeSessionSummary[]): PendingSessionExport => ({
37
+ label: selectedSessions.length === 1 ? selectedSessions[0]!.title : `${selectedSessions.length} selected sessions`,
38
+ sessionIds: selectedSessions.map((session) => session.sessionId),
39
+ });
40
+
41
+ const getDeleteConfirmLabel = (pendingDelete: PendingSessionDelete | null, isPending: boolean) => {
42
+ if (isPending) {
43
+ return 'Deleting...';
28
44
  }
29
45
 
30
- return workspace;
46
+ if (pendingDelete?.scope === 'all') {
47
+ return 'Delete all';
48
+ }
49
+
50
+ return pendingDelete && pendingDelete.sessions.length > 1 ? 'Delete sessions' : 'Delete session';
51
+ };
52
+
53
+ const getDeleteDescription = (pendingDelete: PendingSessionDelete | null) => {
54
+ if (!pendingDelete) {
55
+ return 'Permanently delete the selected OpenCode sessions from the database.';
56
+ }
57
+
58
+ if (pendingDelete.scope === 'all') {
59
+ return `Permanently delete all ${pendingDelete.sessions.length} OpenCode sessions in this workspace from the database, including child sessions, messages, and parts.`;
60
+ }
61
+
62
+ if (pendingDelete.sessions.length === 1) {
63
+ return `Permanently delete "${pendingDelete.sessions[0]!.title}" from OpenCode history. This removes the session, child sessions, messages, and parts from the OpenCode database.`;
64
+ }
65
+
66
+ return `Permanently delete ${pendingDelete.sessions.length} selected OpenCode sessions from the database, including child sessions, messages, and parts.`;
67
+ };
68
+
69
+ const getDeleteTitle = (pendingDelete: PendingSessionDelete | null) => {
70
+ if (pendingDelete?.scope === 'all') {
71
+ return `Delete all ${pendingDelete.sessions.length} OpenCode sessions?`;
72
+ }
73
+
74
+ return pendingDelete && pendingDelete.sessions.length > 1
75
+ ? `Delete ${pendingDelete.sessions.length} OpenCode sessions?`
76
+ : 'Delete this OpenCode session?';
31
77
  };
32
78
 
33
79
  export const Route = createFileRoute('/opencode/$workspaceKey')({
@@ -44,34 +90,65 @@ export const Route = createFileRoute('/opencode/$workspaceKey')({
44
90
  });
45
91
 
46
92
  function OpenCodeWorkspaceErrorComponent({ error }: { error: Error }) {
47
- return <ReloadErrorPanel description={error.message} title="Failed to load OpenCode workspace" />;
93
+ return <RouteErrorPanel error={error} title="Failed to load OpenCode workspace" />;
48
94
  }
49
95
 
96
+ const findWorkspaceOrThrow = (workspaces: OpenCodeWorkspaceGroup[], workspaceKey: string) => {
97
+ const workspace = workspaces.find((candidate) => candidate.key === workspaceKey);
98
+ if (!workspace) {
99
+ throw new Error(`OpenCode workspace not found: ${workspaceKey}`);
100
+ }
101
+
102
+ return workspace;
103
+ };
104
+
50
105
  function OpenCodeWorkspacePage() {
51
106
  const params = Route.useParams();
52
107
  const workspaces = useSuspenseQuery(openCodeWorkspacesQueryOptions()).data;
53
108
  const workspace = findWorkspaceOrThrow(workspaces, params.workspaceKey);
54
109
  const sessions = useSuspenseQuery(openCodeSessionsQueryOptions(workspace.key)).data;
110
+
111
+ return <OpenCodeWorkspaceContent sessions={sessions} workspace={workspace} />;
112
+ }
113
+
114
+ function OpenCodeWorkspaceContent({
115
+ sessions,
116
+ workspace,
117
+ }: {
118
+ sessions: OpenCodeSessionSummary[];
119
+ workspace: OpenCodeWorkspaceGroup;
120
+ }) {
121
+ const navigate = useNavigate({ from: Route.fullPath });
122
+ const queryClient = useQueryClient();
55
123
  const [searchInput, setSearchInput] = useState('');
56
- const [pendingExport, setPendingExport] = useState<OpenCodeSessionSummary | null>(null);
124
+ const [pendingDelete, setPendingDelete] = useState<PendingSessionDelete | null>(null);
125
+ const [pendingExport, setPendingExport] = useState<PendingSessionExport | null>(null);
57
126
  const deferredSearch = useDeferredValue(searchInput);
58
127
 
59
128
  const exportMutation = useMutation({
60
- mutationFn: async (options: ExportDialogOptions) => {
61
- if (!pendingExport) {
62
- throw new Error('No OpenCode session selected for export');
63
- }
64
-
65
- const download = await exportOpenCodeSessionFn({
66
- data: {
67
- includeCommentary: options.includeCommentary,
68
- includeMetadata: options.includeMetadata,
69
- includeTools: options.includeTools,
70
- outputFormat: options.outputFormat,
71
- sessionId: pendingExport.sessionId,
72
- zipArchive: options.zipArchive,
73
- },
74
- });
129
+ mutationFn: async ({ ids, options }: ExportSelectionMutationInput) => {
130
+ const download =
131
+ ids.length === 1
132
+ ? await exportOpenCodeSessionFn({
133
+ data: {
134
+ includeCommentary: options.includeCommentary,
135
+ includeMetadata: options.includeMetadata,
136
+ includeTools: options.includeTools,
137
+ outputFormat: options.outputFormat,
138
+ sessionId: ids[0]!,
139
+ zipArchive: options.zipArchive,
140
+ },
141
+ })
142
+ : await exportOpenCodeSessionsFn({
143
+ data: {
144
+ includeCommentary: options.includeCommentary,
145
+ includeMetadata: options.includeMetadata,
146
+ includeTools: options.includeTools,
147
+ outputFormat: options.outputFormat,
148
+ sessionIds: [...ids],
149
+ zipArchive: options.zipArchive,
150
+ },
151
+ });
75
152
  if (download.mode === 'download') {
76
153
  downloadTextFile(download.fileName, download.content, download.mimeType);
77
154
  return;
@@ -84,39 +161,119 @@ function OpenCodeWorkspacePage() {
84
161
  },
85
162
  });
86
163
 
87
- const visibleSessions = sessions.filter((session) =>
88
- matchesTextQuery(deferredSearch, [
89
- session.title,
90
- session.sessionId,
91
- session.slug,
92
- session.agent,
93
- session.modelLabel,
94
- session.directory,
95
- ]),
164
+ const deleteMutation = useMutation({
165
+ mutationFn: async (sessionIds: string[]) =>
166
+ sessionIds.length === 1
167
+ ? deleteOpenCodeSessionFn({ data: { sessionId: sessionIds[0]! } })
168
+ : deleteOpenCodeSessionsFn({ data: { sessionIds } }),
169
+ onSettled: async (_result, _error, sessionIds) => {
170
+ await Promise.all([
171
+ queryClient.invalidateQueries({ queryKey: ['opencode-workspaces'] }),
172
+ queryClient.invalidateQueries({ queryKey: ['opencode-sessions', workspace.key] }),
173
+ ...sessionIds.map((sessionId) =>
174
+ queryClient.invalidateQueries({ queryKey: ['opencode-session', sessionId] }),
175
+ ),
176
+ ]);
177
+ },
178
+ onSuccess: async (_result, sessionIds) => {
179
+ const workspaceEmptied = isWorkspaceEmptiedByDelete(sessions, sessionIds, (session) => session.sessionId);
180
+ setPendingDelete(null);
181
+ if (workspaceEmptied) {
182
+ await navigate({ to: '/opencode' });
183
+ }
184
+ },
185
+ });
186
+
187
+ const visibleSessions = useMemo(
188
+ () =>
189
+ sessions.filter((session) =>
190
+ matchesTextQuery(deferredSearch, [
191
+ session.title,
192
+ session.sessionId,
193
+ session.slug,
194
+ session.agent,
195
+ session.modelLabel,
196
+ session.directory,
197
+ ]),
198
+ ),
199
+ [deferredSearch, sessions],
96
200
  );
201
+ const visibleSessionsById = useMemo(
202
+ () => new Map(visibleSessions.map((session) => [session.sessionId, session])),
203
+ [visibleSessions],
204
+ );
205
+ const lookupSelectedSessions = (sessionIds: string[]) =>
206
+ sessionIds
207
+ .map((sessionId) => visibleSessionsById.get(sessionId) ?? null)
208
+ .filter((session): session is OpenCodeSessionSummary => session !== null);
209
+ const openExportForSessions = (selectedSessions: OpenCodeSessionSummary[]) => {
210
+ if (selectedSessions.length === 0) {
211
+ return;
212
+ }
213
+
214
+ setPendingExport(buildSessionExport(selectedSessions));
215
+ };
216
+ const openDeleteForSessions = (
217
+ selectedSessions: OpenCodeSessionSummary[],
218
+ scope: PendingSessionDelete['scope'],
219
+ ) => {
220
+ if (selectedSessions.length > 0) {
221
+ setPendingDelete({ scope, sessions: selectedSessions });
222
+ }
223
+ };
97
224
 
98
225
  return (
99
226
  <div className="space-y-6">
100
227
  <PageHeader
101
228
  actions={
102
- <ListSearchInput
103
- placeholder="Search session title, id, model, or agent"
104
- value={searchInput}
105
- onValueChange={setSearchInput}
106
- />
229
+ <div className="flex flex-col gap-2 sm:flex-row">
230
+ <Button
231
+ className="rounded-full"
232
+ disabled={deleteMutation.isPending || sessions.length === 0}
233
+ type="button"
234
+ variant="destructive"
235
+ onClick={() => openDeleteForSessions(sessions, 'all')}
236
+ >
237
+ <Trash2 className="size-4" />
238
+ Delete all
239
+ </Button>
240
+ <ListSearchInput
241
+ placeholder="Search session title, id, model, or agent"
242
+ value={searchInput}
243
+ onValueChange={setSearchInput}
244
+ />
245
+ </div>
107
246
  }
108
247
  eyebrow="OpenCode workspace"
109
248
  subtitle="Inspect local OpenCode sessions, transcript parts, tool calls, reasoning, token totals, and exportable conversation text."
110
249
  title={workspace.label}
111
250
  />
112
251
 
113
- <OpenCodeSessionsTable sessions={visibleSessions} onExportSession={setPendingExport} />
252
+ <OpenCodeSessionsTable
253
+ sessions={visibleSessions}
254
+ onDeleteSession={(session) => openDeleteForSessions([session], 'selected')}
255
+ onDeleteSessions={(sessionIds) => openDeleteForSessions(lookupSelectedSessions(sessionIds), 'selected')}
256
+ onExportSession={(session) => openExportForSessions([session])}
257
+ onExportSessions={(sessionIds) => openExportForSessions(lookupSelectedSessions(sessionIds))}
258
+ />
114
259
 
115
260
  <ExportDialog
261
+ errorMessage={
262
+ exportMutation.isError
263
+ ? exportMutation.error instanceof Error
264
+ ? exportMutation.error.message
265
+ : 'Session export failed'
266
+ : null
267
+ }
268
+ forceZipArchive={pendingExport ? pendingExport.sessionIds.length > 1 : false}
116
269
  open={pendingExport !== null}
117
270
  pending={exportMutation.isPending}
118
- title={pendingExport ? `Export ${pendingExport.title}` : 'Export session'}
119
- onExport={(options) => exportMutation.mutate(options)}
271
+ title={pendingExport ? `Export ${pendingExport.label}` : 'Export session'}
272
+ onExport={(options) => {
273
+ if (pendingExport) {
274
+ exportMutation.mutate(createExportSelectionMutationInput(pendingExport.sessionIds, options));
275
+ }
276
+ }}
120
277
  onOpenChange={(open) => {
121
278
  if (!open) {
122
279
  setPendingExport(null);
@@ -125,11 +282,30 @@ function OpenCodeWorkspacePage() {
125
282
  }}
126
283
  />
127
284
 
128
- {exportMutation.isError ? (
129
- <p className="text-[var(--destructive)] text-sm">
130
- {exportMutation.error instanceof Error ? exportMutation.error.message : 'Session export failed'}
131
- </p>
132
- ) : null}
285
+ <DeleteConfirmDialog
286
+ confirmLabel={getDeleteConfirmLabel(pendingDelete, deleteMutation.isPending)}
287
+ description={getDeleteDescription(pendingDelete)}
288
+ errorMessage={
289
+ deleteMutation.isError
290
+ ? deleteMutation.error instanceof Error
291
+ ? deleteMutation.error.message
292
+ : 'Session delete failed'
293
+ : null
294
+ }
295
+ open={pendingDelete !== null}
296
+ title={getDeleteTitle(pendingDelete)}
297
+ onConfirm={() => {
298
+ if (pendingDelete) {
299
+ deleteMutation.mutate(pendingDelete.sessions.map((session) => session.sessionId));
300
+ }
301
+ }}
302
+ onOpenChange={(open) => {
303
+ if (!open) {
304
+ setPendingDelete(null);
305
+ deleteMutation.reset();
306
+ }
307
+ }}
308
+ />
133
309
  </div>
134
310
  );
135
311
  }
@@ -2,14 +2,15 @@ import { useSuspenseQuery } from '@tanstack/react-query';
2
2
  import { createFileRoute } from '@tanstack/react-router';
3
3
  import { useDeferredValue, useState } from 'react';
4
4
  import { ListSearchInput } from '#/components/list-search-input';
5
+ import { LoadingPanel } from '#/components/loading-panel';
5
6
  import { OpenCodeWorkspacesTable } from '#/components/opencode-workspaces-table';
6
7
  import { PageHeader } from '#/components/page-header';
7
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
8
+ import { RouteErrorPanel } from '#/components/route-error-panel';
8
9
  import { openCodeWorkspacesQueryOptions } from '#/lib/opencode-queries';
9
10
  import { matchesTextQuery } from '#/lib/text-filter';
10
11
 
11
12
  const OpenCodeErrorComponent = ({ error }: { error: Error }) => {
12
- return <ReloadErrorPanel description={error.message} title="Failed to load OpenCode workspaces" />;
13
+ return <RouteErrorPanel error={error} title="Failed to load OpenCode workspaces" />;
13
14
  };
14
15
 
15
16
  const OpenCodePage = () => {
@@ -45,4 +46,7 @@ export const Route = createFileRoute('/opencode/')({
45
46
  component: OpenCodePage,
46
47
  errorComponent: OpenCodeErrorComponent,
47
48
  loader: ({ context }) => context.queryClient.ensureQueryData(openCodeWorkspacesQueryOptions()),
49
+ pendingComponent: () => (
50
+ <LoadingPanel description="Loading OpenCode workspaces and database metadata." title="Loading OpenCode" />
51
+ ),
48
52
  });
@@ -11,24 +11,18 @@ import { LoadingPanel } from '#/components/loading-panel';
11
11
  import { MetadataSection } from '#/components/metadata-section';
12
12
  import { MetricCard } from '#/components/metric-card';
13
13
  import { PageHeader } from '#/components/page-header';
14
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
15
- import { TranscriptView } from '#/components/transcript-view';
14
+ import { RouteErrorPanel } from '#/components/route-error-panel';
15
+ import { DEFAULT_SHOW_USER_MESSAGES, TranscriptView } from '#/components/transcript-view';
16
16
  import { Button } from '#/components/ui/button';
17
17
  import { Checkbox } from '#/components/ui/checkbox';
18
18
  import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
19
19
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
20
+ import type { ExportDialogOptions } from '#/lib/export-options';
20
21
  import { formatDateTime, formatList, formatNumber } from '#/lib/formatters';
21
22
  import { qoderSessionDetailQueryOptions } from '#/lib/qoder-queries';
22
23
  import { exportQoderSessionFn } from '#/lib/qoder-server';
23
24
  import { getQoderThreadTranscriptStats, qoderTranscriptToThreadEvents } from '#/lib/qoder-transcript-events';
24
-
25
- type ExportDialogOptions = {
26
- includeCommentary: boolean;
27
- includeMetadata: boolean;
28
- includeTools: boolean;
29
- outputFormat: 'md' | 'txt';
30
- zipArchive: boolean;
31
- };
25
+ import { RouteStateResetBoundary } from '#/lib/route-state-reset';
32
26
 
33
27
  type TranscriptControlsProps = {
34
28
  rawJsonDisabled?: boolean;
@@ -45,7 +39,7 @@ type TranscriptControlsProps = {
45
39
  };
46
40
 
47
41
  const QoderSessionDetailErrorComponent = ({ error }: { error: Error }) => {
48
- return <ReloadErrorPanel description={error.message} title="Failed to load Qoder session" />;
42
+ return <RouteErrorPanel error={error} title="Failed to load Qoder session" />;
49
43
  };
50
44
 
51
45
  const buildSessionMetadata = (detail: QoderSessionTranscript) => [
@@ -71,10 +65,13 @@ const buildSessionMetadata = (detail: QoderSessionTranscript) => [
71
65
  { label: 'Model', value: detail.session.model ?? 'unknown' },
72
66
  { label: 'Execution mode', value: detail.session.executionMode ?? 'unknown' },
73
67
  { label: 'Agent class', value: detail.session.agentClass ?? 'unknown' },
74
- { label: 'Created', value: formatDateTime(detail.session.createdAtMs) },
68
+ {
69
+ label: 'Created',
70
+ value: <span suppressHydrationWarning>{formatDateTime(detail.session.createdAtMs)}</span>,
71
+ },
75
72
  {
76
73
  label: 'Last active',
77
- value: formatDateTime(detail.session.lastActiveAtMs),
74
+ value: <span suppressHydrationWarning>{formatDateTime(detail.session.lastActiveAtMs)}</span>,
78
75
  },
79
76
  ];
80
77
 
@@ -171,7 +168,7 @@ const QoderSessionDetailPage = () => {
171
168
  const [showCommentary, setShowCommentary] = useState(false);
172
169
  const [showExtraEvents, setShowExtraEvents] = useState(false);
173
170
  const [showRawJson, setShowRawJson] = useState(false);
174
- const [showUserMessages, setShowUserMessages] = useState(true);
171
+ const [showUserMessages, setShowUserMessages] = useState(DEFAULT_SHOW_USER_MESSAGES);
175
172
  const transcriptEvents = useMemo(() => qoderTranscriptToThreadEvents(detail), [detail]);
176
173
  const transcriptStats = useMemo(() => getQoderThreadTranscriptStats(transcriptEvents), [transcriptEvents]);
177
174
  const modelLabel = detail.session.model ?? 'Qoder';
@@ -328,7 +325,14 @@ const QoderSessionDetailPage = () => {
328
325
  };
329
326
 
330
327
  export const Route = createFileRoute('/qoder-sessions/$sessionId')({
331
- component: QoderSessionDetailPage,
328
+ component: () => {
329
+ const { sessionId } = Route.useParams();
330
+ return (
331
+ <RouteStateResetBoundary routeKey={sessionId}>
332
+ <QoderSessionDetailPage />
333
+ </RouteStateResetBoundary>
334
+ );
335
+ },
332
336
  errorComponent: QoderSessionDetailErrorComponent,
333
337
  loader: ({ context, params }) =>
334
338
  context.queryClient.ensureQueryData(qoderSessionDetailQueryOptions(params.sessionId)),
@@ -1,24 +1,22 @@
1
1
  import type { QoderSessionSummary, QoderWorkspaceGroup } from '@spiracha/lib/qoder-exporter-types';
2
2
  import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
3
3
  import { createFileRoute } from '@tanstack/react-router';
4
- import { useDeferredValue, useState } from 'react';
4
+ import { useDeferredValue, useMemo, useState } from 'react';
5
5
  import { ExportDialog } from '#/components/export-dialog';
6
6
  import { ListSearchInput } from '#/components/list-search-input';
7
7
  import { LoadingPanel } from '#/components/loading-panel';
8
8
  import { PageHeader } from '#/components/page-header';
9
9
  import { QoderSessionsTable } from '#/components/qoder-sessions-table';
10
- import { ReloadErrorPanel } from '#/components/reload-error-panel';
10
+ import { RouteErrorPanel } from '#/components/route-error-panel';
11
11
  import { downloadTextFile, downloadUrlFile } from '#/lib/download';
12
+ import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
12
13
  import { qoderSessionsQueryOptions, qoderWorkspacesQueryOptions } from '#/lib/qoder-queries';
13
- import { exportQoderSessionFn } from '#/lib/qoder-server';
14
+ import { exportQoderSessionFn, exportQoderSessionsFn } from '#/lib/qoder-server';
14
15
  import { matchesTextQuery } from '#/lib/text-filter';
15
16
 
16
- type ExportDialogOptions = {
17
- includeCommentary: boolean;
18
- includeMetadata: boolean;
19
- includeTools: boolean;
20
- outputFormat: 'md' | 'txt';
21
- zipArchive: boolean;
17
+ type PendingSessionExport = {
18
+ label: string;
19
+ sessionIds: string[];
22
20
  };
23
21
 
24
22
  const findWorkspaceOrThrow = (workspaces: QoderWorkspaceGroup[], workspaceKey: string) => {
@@ -31,7 +29,7 @@ const findWorkspaceOrThrow = (workspaces: QoderWorkspaceGroup[], workspaceKey: s
31
29
  };
32
30
 
33
31
  const QoderWorkspaceErrorComponent = ({ error }: { error: Error }) => {
34
- return <ReloadErrorPanel description={error.message} title="Failed to load Qoder workspace" />;
32
+ return <RouteErrorPanel error={error} title="Failed to load Qoder workspace" />;
35
33
  };
36
34
 
37
35
  const QoderWorkspacePage = () => {
@@ -40,25 +38,33 @@ const QoderWorkspacePage = () => {
40
38
  const workspace = findWorkspaceOrThrow(workspaces, params.workspaceKey);
41
39
  const sessions = useSuspenseQuery(qoderSessionsQueryOptions(workspace.key)).data;
42
40
  const [searchInput, setSearchInput] = useState('');
43
- const [pendingExport, setPendingExport] = useState<QoderSessionSummary | null>(null);
41
+ const [pendingExport, setPendingExport] = useState<PendingSessionExport | null>(null);
44
42
  const deferredSearch = useDeferredValue(searchInput);
45
43
 
46
44
  const exportMutation = useMutation({
47
- mutationFn: async (options: ExportDialogOptions) => {
48
- if (!pendingExport) {
49
- throw new Error('No Qoder session selected for export');
50
- }
51
-
52
- const download = await exportQoderSessionFn({
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
- });
45
+ mutationFn: async ({ ids, options }: ExportSelectionMutationInput) => {
46
+ const download =
47
+ ids.length === 1
48
+ ? await exportQoderSessionFn({
49
+ data: {
50
+ includeCommentary: options.includeCommentary,
51
+ includeMetadata: options.includeMetadata,
52
+ includeTools: options.includeTools,
53
+ outputFormat: options.outputFormat,
54
+ sessionId: ids[0]!,
55
+ zipArchive: options.zipArchive,
56
+ },
57
+ })
58
+ : await exportQoderSessionsFn({
59
+ data: {
60
+ includeCommentary: options.includeCommentary,
61
+ includeMetadata: options.includeMetadata,
62
+ includeTools: options.includeTools,
63
+ outputFormat: options.outputFormat,
64
+ sessionIds: [...ids],
65
+ zipArchive: options.zipArchive,
66
+ },
67
+ });
62
68
  if (download.mode === 'download') {
63
69
  downloadTextFile(download.fileName, download.content, download.mimeType);
64
70
  return;
@@ -71,20 +77,45 @@ const QoderWorkspacePage = () => {
71
77
  },
72
78
  });
73
79
 
74
- const visibleSessions = sessions.filter((session) =>
75
- matchesTextQuery(deferredSearch, [
76
- session.title,
77
- session.sessionId,
78
- session.taskId,
79
- session.requestId,
80
- session.model,
81
- session.status,
82
- session.executionMode,
83
- session.agentClass,
84
- session.query,
85
- session.sourceStatePath,
86
- ]),
80
+ const visibleSessions = useMemo(
81
+ () =>
82
+ sessions.filter((session) =>
83
+ matchesTextQuery(deferredSearch, [
84
+ session.title,
85
+ session.sessionId,
86
+ session.taskId,
87
+ session.requestId,
88
+ session.model,
89
+ session.status,
90
+ session.executionMode,
91
+ session.agentClass,
92
+ session.query,
93
+ session.sourceStatePath,
94
+ ]),
95
+ ),
96
+ [deferredSearch, sessions],
97
+ );
98
+ const visibleSessionsById = useMemo(
99
+ () => new Map(visibleSessions.map((session) => [session.sessionId, session])),
100
+ [visibleSessions],
87
101
  );
102
+ const lookupSelectedSessions = (sessionIds: string[]) =>
103
+ sessionIds
104
+ .map((sessionId) => visibleSessionsById.get(sessionId) ?? null)
105
+ .filter((session): session is QoderSessionSummary => session !== null);
106
+ const openExportForSessions = (selectedSessions: QoderSessionSummary[]) => {
107
+ if (selectedSessions.length === 0) {
108
+ return;
109
+ }
110
+
111
+ setPendingExport({
112
+ label:
113
+ selectedSessions.length === 1
114
+ ? selectedSessions[0]!.title
115
+ : `${selectedSessions.length} selected sessions`,
116
+ sessionIds: selectedSessions.map((session) => session.sessionId),
117
+ });
118
+ };
88
119
 
89
120
  return (
90
121
  <div className="space-y-6">
@@ -101,13 +132,29 @@ const QoderWorkspacePage = () => {
101
132
  title={workspace.label}
102
133
  />
103
134
 
104
- <QoderSessionsTable sessions={visibleSessions} onExportSession={setPendingExport} />
135
+ <QoderSessionsTable
136
+ sessions={visibleSessions}
137
+ onExportSession={(session) => openExportForSessions([session])}
138
+ onExportSessions={(sessionIds) => openExportForSessions(lookupSelectedSessions(sessionIds))}
139
+ />
105
140
 
106
141
  <ExportDialog
142
+ errorMessage={
143
+ exportMutation.isError
144
+ ? exportMutation.error instanceof Error
145
+ ? exportMutation.error.message
146
+ : 'Session export failed'
147
+ : null
148
+ }
149
+ forceZipArchive={pendingExport ? pendingExport.sessionIds.length > 1 : false}
107
150
  open={pendingExport !== null}
108
151
  pending={exportMutation.isPending}
109
- title={pendingExport ? `Export ${pendingExport.title}` : 'Export session'}
110
- onExport={(options) => exportMutation.mutate(options)}
152
+ title={pendingExport ? `Export ${pendingExport.label}` : 'Export session'}
153
+ onExport={(options) => {
154
+ if (pendingExport) {
155
+ exportMutation.mutate(createExportSelectionMutationInput(pendingExport.sessionIds, options));
156
+ }
157
+ }}
111
158
  onOpenChange={(open) => {
112
159
  if (!open) {
113
160
  setPendingExport(null);
@@ -115,12 +162,6 @@ const QoderWorkspacePage = () => {
115
162
  }
116
163
  }}
117
164
  />
118
-
119
- {exportMutation.isError ? (
120
- <p className="text-[var(--destructive)] text-sm">
121
- {exportMutation.error instanceof Error ? exportMutation.error.message : 'Session export failed'}
122
- </p>
123
- ) : null}
124
165
  </div>
125
166
  );
126
167
  };