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