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,21 +1,50 @@
1
1
  import type { AntigravityConversation, AntigravityWorkspaceGroup } from '@spiracha/lib/antigravity-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';
5
6
  import { AntigravityConversationsTable } from '#/components/antigravity-conversations-table';
6
7
  import { AntigravityKeychainPanel } from '#/components/antigravity-keychain-panel';
8
+ import { DeleteConfirmDialog } from '#/components/delete-confirm-dialog';
9
+ import { ExportDialog } from '#/components/export-dialog';
7
10
  import { ListSearchInput } from '#/components/list-search-input';
8
11
  import { LoadingPanel } from '#/components/loading-panel';
9
12
  import { PageHeader } from '#/components/page-header';
10
13
  import { ReloadErrorPanel } from '#/components/reload-error-panel';
14
+ import { Button } from '#/components/ui/button';
11
15
  import {
12
16
  antigravityConversationsQueryOptions,
13
17
  antigravityDecryptionQueryOptions,
14
18
  antigravityWorkspacesQueryOptions,
15
19
  } from '#/lib/antigravity-queries';
16
- import { exportAntigravityArtifactsFn, exportAntigravityConversationFn } from '#/lib/antigravity-server';
17
- import { downloadTextFile } from '#/lib/download';
20
+ import {
21
+ deleteAntigravityConversationFn,
22
+ deleteAntigravityConversationsFn,
23
+ exportAntigravityArtifactsFn,
24
+ exportAntigravityConversationFn,
25
+ exportAntigravityConversationsFn,
26
+ } from '#/lib/antigravity-server';
27
+ import { downloadTextFile, downloadUrlFile } from '#/lib/download';
18
28
  import { matchesTextQuery } from '#/lib/text-filter';
29
+ import { isWorkspaceEmptiedByDelete } from '#/lib/workspace-delete-navigation';
30
+
31
+ type ExportDialogOptions = {
32
+ includeCommentary: boolean;
33
+ includeMetadata: boolean;
34
+ includeTools: boolean;
35
+ outputFormat: 'md' | 'txt';
36
+ zipArchive: boolean;
37
+ };
38
+
39
+ type PendingConversationDelete = {
40
+ conversations: AntigravityConversation[];
41
+ scope: 'all' | 'selected';
42
+ };
43
+
44
+ type PendingConversationExport = {
45
+ conversationIds: string[];
46
+ label: string;
47
+ };
19
48
 
20
49
  const findWorkspaceOrThrow = (workspaces: AntigravityWorkspaceGroup[], workspaceKey: string) => {
21
50
  const workspace = workspaces.find((candidate) => candidate.key === workspaceKey);
@@ -26,6 +55,80 @@ const findWorkspaceOrThrow = (workspaces: AntigravityWorkspaceGroup[], workspace
26
55
  return workspace;
27
56
  };
28
57
 
58
+ const buildConversationExport = (selectedConversations: AntigravityConversation[]): PendingConversationExport => ({
59
+ conversationIds: selectedConversations.map((conversation) => conversation.conversationId),
60
+ label:
61
+ selectedConversations.length === 1
62
+ ? selectedConversations[0]!.title
63
+ : `${selectedConversations.length} selected conversations`,
64
+ });
65
+
66
+ const getDeleteConfirmLabel = (pendingDelete: PendingConversationDelete | null, isPending: boolean) => {
67
+ if (isPending) {
68
+ return 'Deleting...';
69
+ }
70
+
71
+ if (pendingDelete?.scope === 'all') {
72
+ return 'Delete all';
73
+ }
74
+
75
+ return pendingDelete && pendingDelete.conversations.length > 1 ? 'Delete conversations' : 'Delete conversation';
76
+ };
77
+
78
+ const getDeleteDescription = (pendingDelete: PendingConversationDelete | null) => {
79
+ if (!pendingDelete) {
80
+ return 'Permanently delete the selected Antigravity conversations from disk.';
81
+ }
82
+
83
+ if (pendingDelete.scope === 'all') {
84
+ return `Permanently delete all ${pendingDelete.conversations.length} Antigravity conversations in this workspace from disk. This removes their summaries, conversation files, transcript logs, and generated artifacts.`;
85
+ }
86
+
87
+ if (pendingDelete.conversations.length === 1) {
88
+ return `Permanently delete "${pendingDelete.conversations[0]!.title}" from Antigravity history. This removes the summary entry, conversation file, transcript logs, and generated artifacts that belong to this conversation.`;
89
+ }
90
+
91
+ return `Permanently delete ${pendingDelete.conversations.length} selected Antigravity conversations from disk. This removes their summaries, conversation files, transcript logs, and generated artifacts.`;
92
+ };
93
+
94
+ const getDeleteTitle = (pendingDelete: PendingConversationDelete | null) => {
95
+ if (pendingDelete?.scope === 'all') {
96
+ return `Delete all ${pendingDelete.conversations.length} Antigravity conversations?`;
97
+ }
98
+
99
+ return pendingDelete && pendingDelete.conversations.length > 1
100
+ ? `Delete ${pendingDelete.conversations.length} Antigravity conversations?`
101
+ : 'Delete this Antigravity conversation?';
102
+ };
103
+
104
+ const AntigravityWorkspaceErrors = ({
105
+ artifactError,
106
+ batchExportError,
107
+ conversationError,
108
+ }: {
109
+ artifactError: Error | null;
110
+ batchExportError: Error | null;
111
+ conversationError: Error | null;
112
+ }) => {
113
+ const messages = [conversationError?.message, artifactError?.message, batchExportError?.message].filter(
114
+ (message): message is string => Boolean(message),
115
+ );
116
+
117
+ if (messages.length === 0) {
118
+ return null;
119
+ }
120
+
121
+ return (
122
+ <div className="space-y-1">
123
+ {messages.map((message) => (
124
+ <p className="text-[var(--destructive)] text-sm" key={message}>
125
+ {message}
126
+ </p>
127
+ ))}
128
+ </div>
129
+ );
130
+ };
131
+
29
132
  export const Route = createFileRoute('/antigravity/$workspaceKey')({
30
133
  component: AntigravityWorkspacePage,
31
134
  errorComponent: AntigravityWorkspaceErrorComponent,
@@ -50,12 +153,16 @@ function AntigravityWorkspaceErrorComponent({ error }: { error: Error }) {
50
153
  }
51
154
 
52
155
  function AntigravityWorkspacePage() {
156
+ const navigate = useNavigate({ from: Route.fullPath });
53
157
  const params = Route.useParams();
158
+ const queryClient = useQueryClient();
54
159
  const workspaces = useSuspenseQuery(antigravityWorkspacesQueryOptions()).data;
55
160
  const workspace = findWorkspaceOrThrow(workspaces, params.workspaceKey);
56
161
  const conversations = useSuspenseQuery(antigravityConversationsQueryOptions(workspace.key)).data;
57
162
  const decryptionState = useSuspenseQuery(antigravityDecryptionQueryOptions()).data ?? null;
58
163
  const [searchInput, setSearchInput] = useState('');
164
+ const [pendingDelete, setPendingDelete] = useState<PendingConversationDelete | null>(null);
165
+ const [pendingExport, setPendingExport] = useState<PendingConversationExport | null>(null);
59
166
  const deferredSearch = useDeferredValue(searchInput);
60
167
 
61
168
  const exportConversationMutation = useMutation({
@@ -74,24 +181,117 @@ function AntigravityWorkspacePage() {
74
181
  },
75
182
  });
76
183
 
77
- const visibleConversations = conversations.filter((conversation) =>
78
- matchesTextQuery(deferredSearch, [
79
- conversation.title,
80
- conversation.conversationId,
81
- conversation.transcriptSource,
82
- conversation.workspaceLabel,
83
- ]),
184
+ const exportConversationsMutation = useMutation({
185
+ mutationFn: async (options: ExportDialogOptions) => {
186
+ if (!pendingExport) {
187
+ throw new Error('No Antigravity conversation selected for export');
188
+ }
189
+
190
+ const download = await exportAntigravityConversationsFn({
191
+ data: {
192
+ conversationIds: pendingExport.conversationIds,
193
+ outputFormat: options.outputFormat,
194
+ zipArchive: options.zipArchive,
195
+ },
196
+ });
197
+
198
+ if (download.mode === 'download') {
199
+ downloadTextFile(download.fileName, download.content, download.mimeType);
200
+ return;
201
+ }
202
+
203
+ await downloadUrlFile(download.fileName, download.downloadUrl);
204
+ },
205
+ onSuccess: () => {
206
+ setPendingExport(null);
207
+ },
208
+ });
209
+
210
+ const deleteMutation = useMutation({
211
+ mutationFn: async (conversationIds: string[]) =>
212
+ conversationIds.length === 1
213
+ ? deleteAntigravityConversationFn({ data: { conversationId: conversationIds[0]! } })
214
+ : deleteAntigravityConversationsFn({ data: { conversationIds } }),
215
+ onSuccess: async (result) => {
216
+ const conversationIds = result.deletedConversationIds;
217
+ const workspaceEmptied = isWorkspaceEmptiedByDelete(
218
+ conversations,
219
+ conversationIds,
220
+ (conversation) => conversation.conversationId,
221
+ );
222
+ setPendingDelete(null);
223
+ await Promise.all([
224
+ queryClient.invalidateQueries({ queryKey: ['antigravity-workspaces'] }),
225
+ queryClient.invalidateQueries({ queryKey: ['antigravity-conversations', workspace.key] }),
226
+ ...conversationIds.map((conversationId) =>
227
+ queryClient.invalidateQueries({
228
+ queryKey: ['antigravity-conversation', conversationId],
229
+ }),
230
+ ),
231
+ ]);
232
+ if (workspaceEmptied) {
233
+ await navigate({ to: '/antigravity' });
234
+ }
235
+ },
236
+ });
237
+
238
+ const visibleConversations = useMemo(
239
+ () =>
240
+ conversations.filter((conversation) =>
241
+ matchesTextQuery(deferredSearch, [
242
+ conversation.title,
243
+ conversation.conversationId,
244
+ conversation.transcriptSource,
245
+ conversation.workspaceLabel,
246
+ ]),
247
+ ),
248
+ [conversations, deferredSearch],
249
+ );
250
+ const visibleConversationsById = useMemo(
251
+ () => new Map(visibleConversations.map((conversation) => [conversation.conversationId, conversation])),
252
+ [visibleConversations],
84
253
  );
254
+ const lookupSelectedConversations = (conversationIds: string[]) =>
255
+ conversationIds
256
+ .map((conversationId) => visibleConversationsById.get(conversationId) ?? null)
257
+ .filter((conversation): conversation is AntigravityConversation => conversation !== null);
258
+ const openExportForConversations = (selectedConversations: AntigravityConversation[]) => {
259
+ if (selectedConversations.length === 0) {
260
+ return;
261
+ }
262
+
263
+ setPendingExport(buildConversationExport(selectedConversations));
264
+ };
265
+ const openDeleteForConversations = (
266
+ selectedConversations: AntigravityConversation[],
267
+ scope: PendingConversationDelete['scope'],
268
+ ) => {
269
+ if (selectedConversations.length > 0) {
270
+ setPendingDelete({ conversations: selectedConversations, scope });
271
+ }
272
+ };
85
273
 
86
274
  return (
87
275
  <div className="space-y-6">
88
276
  <PageHeader
89
277
  actions={
90
- <ListSearchInput
91
- placeholder="Search title, id, or transcript source"
92
- value={searchInput}
93
- onValueChange={setSearchInput}
94
- />
278
+ <div className="flex flex-col gap-2 sm:flex-row">
279
+ <Button
280
+ className="rounded-full"
281
+ disabled={deleteMutation.isPending || conversations.length === 0}
282
+ type="button"
283
+ variant="destructive"
284
+ onClick={() => openDeleteForConversations(conversations, 'all')}
285
+ >
286
+ <Trash2 className="size-4" />
287
+ Delete all
288
+ </Button>
289
+ <ListSearchInput
290
+ placeholder="Search title, id, or transcript source"
291
+ value={searchInput}
292
+ onValueChange={setSearchInput}
293
+ />
294
+ </div>
95
295
  }
96
296
  eyebrow="Antigravity workspace"
97
297
  subtitle="Inspect conversation coverage across Antigravity transcripts, raw payloads, and generated artifacts."
@@ -103,25 +303,63 @@ function AntigravityWorkspacePage() {
103
303
  <AntigravityConversationsTable
104
304
  conversations={visibleConversations}
105
305
  decryptionState={decryptionState}
306
+ onDeleteConversation={(conversation) => openDeleteForConversations([conversation], 'selected')}
307
+ onDeleteConversations={(conversationIds) =>
308
+ openDeleteForConversations(lookupSelectedConversations(conversationIds), 'selected')
309
+ }
106
310
  onExportArtifacts={(conversation) => exportArtifactsMutation.mutate(conversation)}
107
311
  onExportConversation={(conversation) => exportConversationMutation.mutate(conversation)}
312
+ onExportConversations={(conversationIds) =>
313
+ openExportForConversations(lookupSelectedConversations(conversationIds))
314
+ }
108
315
  />
109
316
 
110
- {exportConversationMutation.isError ? (
111
- <p className="text-[var(--destructive)] text-sm">
112
- {exportConversationMutation.error instanceof Error
113
- ? exportConversationMutation.error.message
114
- : 'Conversation export failed'}
115
- </p>
116
- ) : null}
317
+ <AntigravityWorkspaceErrors
318
+ artifactError={exportArtifactsMutation.isError ? exportArtifactsMutation.error : null}
319
+ batchExportError={exportConversationsMutation.isError ? exportConversationsMutation.error : null}
320
+ conversationError={exportConversationMutation.isError ? exportConversationMutation.error : null}
321
+ />
117
322
 
118
- {exportArtifactsMutation.isError ? (
119
- <p className="text-[var(--destructive)] text-sm">
120
- {exportArtifactsMutation.error instanceof Error
121
- ? exportArtifactsMutation.error.message
122
- : 'Artifact export failed'}
123
- </p>
124
- ) : null}
323
+ <ExportDialog
324
+ forceZipArchive={pendingExport ? pendingExport.conversationIds.length > 1 : false}
325
+ open={pendingExport !== null}
326
+ pending={exportConversationsMutation.isPending}
327
+ title={pendingExport ? `Export ${pendingExport.label}` : 'Export conversation'}
328
+ onExport={(options) => exportConversationsMutation.mutate(options)}
329
+ onOpenChange={(open) => {
330
+ if (!open) {
331
+ setPendingExport(null);
332
+ exportConversationsMutation.reset();
333
+ }
334
+ }}
335
+ />
336
+
337
+ <DeleteConfirmDialog
338
+ confirmLabel={getDeleteConfirmLabel(pendingDelete, deleteMutation.isPending)}
339
+ description={getDeleteDescription(pendingDelete)}
340
+ errorMessage={
341
+ deleteMutation.isError
342
+ ? deleteMutation.error instanceof Error
343
+ ? deleteMutation.error.message
344
+ : 'Conversation delete failed'
345
+ : null
346
+ }
347
+ open={pendingDelete !== null}
348
+ title={getDeleteTitle(pendingDelete)}
349
+ onConfirm={() => {
350
+ if (pendingDelete) {
351
+ deleteMutation.mutate(
352
+ pendingDelete.conversations.map((conversation) => conversation.conversationId),
353
+ );
354
+ }
355
+ }}
356
+ onOpenChange={(open) => {
357
+ if (!open) {
358
+ setPendingDelete(null);
359
+ deleteMutation.reset();
360
+ }
361
+ }}
362
+ />
125
363
  </div>
126
364
  );
127
365
  }
@@ -3,6 +3,10 @@ import { createFileRoute } from '@tanstack/react-router';
3
3
  export const Route = createFileRoute('/api/v1/conversations/$source/$id')({
4
4
  server: {
5
5
  handlers: {
6
+ DELETE: async ({ request }) => {
7
+ const { handleConversationApiRequest } = await import('@spiracha/lib/conversation-api');
8
+ return handleConversationApiRequest(request);
9
+ },
6
10
  GET: async ({ request }) => {
7
11
  const { handleConversationApiRequest } = await import('@spiracha/lib/conversation-api');
8
12
  return handleConversationApiRequest(request);
@@ -0,0 +1,12 @@
1
+ import { createFileRoute } from '@tanstack/react-router';
2
+
3
+ export const Route = createFileRoute('/api/v1/conversations/delete')({
4
+ server: {
5
+ handlers: {
6
+ POST: async ({ request }) => {
7
+ const { handleConversationApiRequest } = await import('@spiracha/lib/conversation-api');
8
+ return handleConversationApiRequest(request);
9
+ },
10
+ },
11
+ },
12
+ });
@@ -0,0 +1,12 @@
1
+ import { createFileRoute } from '@tanstack/react-router';
2
+
3
+ export const Route = createFileRoute('/api/v1/conversations/export')({
4
+ server: {
5
+ handlers: {
6
+ POST: async ({ request }) => {
7
+ const { handleConversationApiRequest } = await import('@spiracha/lib/conversation-api');
8
+ return handleConversationApiRequest(request);
9
+ },
10
+ },
11
+ },
12
+ });
@@ -1,10 +1,11 @@
1
1
  import type { ClaudeCodeSessionTranscript } from '@spiracha/lib/claude-code-exporter-types';
2
2
  import type { ThreadEvent, ThreadTranscriptStats } from '@spiracha/lib/codex-browser-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, useQuery, 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';
@@ -12,12 +13,15 @@ import { MetadataSection } from '#/components/metadata-section';
12
13
  import { MetricCard } from '#/components/metric-card';
13
14
  import { PageHeader } from '#/components/page-header';
14
15
  import { ReloadErrorPanel } from '#/components/reload-error-panel';
15
- import { TranscriptView } from '#/components/transcript-view';
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
- import { claudeCodeSessionDetailQueryOptions } from '#/lib/claude-code-queries';
20
- import { exportClaudeCodeSessionFn } from '#/lib/claude-code-server';
20
+ import {
21
+ claudeCodeSessionDetailQueryOptions,
22
+ claudeCodeSessionTranscriptQueryOptions,
23
+ } from '#/lib/claude-code-queries';
24
+ import { deleteClaudeCodeSessionFn, exportClaudeCodeSessionFn } from '#/lib/claude-code-server';
21
25
  import {
22
26
  claudeCodeTranscriptToThreadEvents,
23
27
  getClaudeCodeThreadTranscriptStats,
@@ -169,6 +173,20 @@ const ClaudeCodeTranscriptControls = ({
169
173
  };
170
174
 
171
175
  function ClaudeCodeRawPanels({ detail, events }: { detail: ClaudeCodeSessionTranscript; events: ThreadEvent[] }) {
176
+ if (detail.rawPayloadsOmitted) {
177
+ return (
178
+ <section className="rounded-[1.6rem] border border-[var(--border)] bg-[var(--panel)] p-5 shadow-[var(--panel-shadow)]">
179
+ <h3 className="font-semibold text-[var(--muted-foreground)] text-sm uppercase tracking-[0.18em]">
180
+ Raw payloads omitted
181
+ </h3>
182
+ <p className="mt-4 text-[var(--muted-foreground)] text-sm">
183
+ This Claude Code session is large, so raw JSON payload copies were omitted from the browser detail
184
+ response. Export still reads the full source session from disk.
185
+ </p>
186
+ </section>
187
+ );
188
+ }
189
+
172
190
  return (
173
191
  <div className="space-y-4">
174
192
  <JsonPanel title="Session summary" value={detail.session} />
@@ -179,14 +197,57 @@ function ClaudeCodeRawPanels({ detail, events }: { detail: ClaudeCodeSessionTran
179
197
  );
180
198
  }
181
199
 
200
+ const ClaudeCodeTranscriptPreviewNotice = ({
201
+ fullTranscriptLoaded,
202
+ omittedEntryCount,
203
+ pending,
204
+ onLoad,
205
+ }: {
206
+ fullTranscriptLoaded: boolean;
207
+ omittedEntryCount: number;
208
+ pending: boolean;
209
+ onLoad: () => void;
210
+ }) => {
211
+ const buttonLabel = fullTranscriptLoaded
212
+ ? 'Full transcript loaded'
213
+ : pending
214
+ ? 'Loading full transcript...'
215
+ : 'Load Full Transcript';
216
+
217
+ return (
218
+ <section className="rounded-[1.6rem] border border-[var(--border)] bg-[var(--panel)] p-5 shadow-[var(--panel-shadow)]">
219
+ <h3 className="font-semibold text-base">Showing a compact transcript preview</h3>
220
+ <p className="mt-2 text-[var(--muted-foreground)] text-sm leading-6">
221
+ Spiracha omitted {formatNumber(omittedEntryCount)} internal transcript entries from the initial page
222
+ load. The preview keeps the beginning and latest activity.
223
+ </p>
224
+ <div className="mt-4">
225
+ <Button disabled={pending || fullTranscriptLoaded} variant="outline" onClick={onLoad}>
226
+ {buttonLabel}
227
+ </Button>
228
+ </div>
229
+ </section>
230
+ );
231
+ };
232
+
182
233
  function ClaudeCodeSessionDetailPage() {
183
- const detail = useSuspenseQuery(claudeCodeSessionDetailQueryOptions(Route.useParams().sessionId)).data;
234
+ const navigate = useNavigate();
235
+ const queryClient = useQueryClient();
236
+ const params = Route.useParams();
237
+ const initialDetail = useSuspenseQuery(claudeCodeSessionDetailQueryOptions(params.sessionId)).data;
238
+ const [shouldLoadFullTranscript, setShouldLoadFullTranscript] = useState(false);
239
+ const fullTranscriptQuery = useQuery({
240
+ ...claudeCodeSessionTranscriptQueryOptions(params.sessionId),
241
+ enabled: shouldLoadFullTranscript,
242
+ });
243
+ const detail = fullTranscriptQuery.data ?? initialDetail;
244
+ const [deleteOpen, setDeleteOpen] = useState(false);
184
245
  const [pendingExport, setPendingExport] = useState(false);
185
246
  const [showToolCalls, setShowToolCalls] = useState(false);
186
247
  const [showCommentary, setShowCommentary] = useState(false);
187
248
  const [showExtraEvents, setShowExtraEvents] = useState(false);
188
249
  const [showRawJson, setShowRawJson] = useState(false);
189
- const [showUserMessages, setShowUserMessages] = useState(true);
250
+ const [showUserMessages, setShowUserMessages] = useState(DEFAULT_SHOW_USER_MESSAGES);
190
251
  const transcriptEvents = useMemo(() => claudeCodeTranscriptToThreadEvents(detail), [detail]);
191
252
  const transcriptStats = useMemo(() => getClaudeCodeThreadTranscriptStats(transcriptEvents), [transcriptEvents]);
192
253
 
@@ -214,19 +275,47 @@ function ClaudeCodeSessionDetailPage() {
214
275
  },
215
276
  });
216
277
 
278
+ const deleteSessionMutation = useMutation({
279
+ mutationFn: () => deleteClaudeCodeSessionFn({ data: { sessionId: detail.session.sessionId } }),
280
+ onSuccess: async () => {
281
+ await Promise.all([
282
+ queryClient.invalidateQueries({ queryKey: ['claude-code-workspaces'] }),
283
+ queryClient.invalidateQueries({ queryKey: ['claude-code-sessions', detail.session.workspaceKey] }),
284
+ queryClient.invalidateQueries({ queryKey: ['claude-code-session', detail.session.sessionId] }),
285
+ queryClient.invalidateQueries({ queryKey: ['claude-code-session', params.sessionId] }),
286
+ queryClient.invalidateQueries({ queryKey: ['claude-code-session-transcript', params.sessionId] }),
287
+ ]);
288
+ navigate({
289
+ params: { workspaceKey: detail.session.workspaceKey },
290
+ to: '/claude-code/$workspaceKey',
291
+ });
292
+ },
293
+ });
294
+
217
295
  return (
218
296
  <div className="space-y-6">
219
297
  <PageHeader
220
298
  actions={
221
- <Button
222
- className="rounded-full"
223
- type="button"
224
- variant="outline"
225
- onClick={() => setPendingExport(true)}
226
- >
227
- <Download className="mr-2 size-4" />
228
- Export
229
- </Button>
299
+ <>
300
+ <Button
301
+ className="rounded-full"
302
+ type="button"
303
+ variant="outline"
304
+ onClick={() => setPendingExport(true)}
305
+ >
306
+ <Download className="mr-2 size-4" />
307
+ Export
308
+ </Button>
309
+ <Button
310
+ className="rounded-full border-[var(--destructive)]/20 text-[var(--destructive)]"
311
+ type="button"
312
+ variant="outline"
313
+ onClick={() => setDeleteOpen(true)}
314
+ >
315
+ <Trash2 className="mr-2 size-4" />
316
+ Delete
317
+ </Button>
318
+ </>
230
319
  }
231
320
  breadcrumb={
232
321
  <Breadcrumbs
@@ -280,6 +369,22 @@ function ClaudeCodeSessionDetailPage() {
280
369
  onShowToolCallsChange={setShowToolCalls}
281
370
  onShowUserMessagesChange={setShowUserMessages}
282
371
  />
372
+ {initialDetail.isPartial ? (
373
+ <ClaudeCodeTranscriptPreviewNotice
374
+ fullTranscriptLoaded={Boolean(fullTranscriptQuery.data)}
375
+ omittedEntryCount={initialDetail.omittedEntryCount ?? 0}
376
+ pending={fullTranscriptQuery.isFetching}
377
+ onLoad={() => setShouldLoadFullTranscript(true)}
378
+ />
379
+ ) : null}
380
+ {fullTranscriptQuery.isError ? (
381
+ <p className="text-[var(--destructive)] text-sm">
382
+ Failed to load the full transcript:{' '}
383
+ {fullTranscriptQuery.error instanceof Error
384
+ ? fullTranscriptQuery.error.message
385
+ : 'Unknown error'}
386
+ </p>
387
+ ) : null}
283
388
  {transcriptEvents.length > 0 ? (
284
389
  <TranscriptView
285
390
  assistantModel={detail.session.model}
@@ -337,6 +442,27 @@ function ClaudeCodeSessionDetailPage() {
337
442
  }
338
443
  }}
339
444
  />
445
+
446
+ <DeleteConfirmDialog
447
+ confirmLabel={deleteSessionMutation.isPending ? 'Deleting...' : 'Delete session'}
448
+ description="Permanently delete this Claude Code session from disk. This removes the session JSONL file."
449
+ errorMessage={
450
+ deleteSessionMutation.isError
451
+ ? deleteSessionMutation.error instanceof Error
452
+ ? deleteSessionMutation.error.message
453
+ : 'Session delete failed'
454
+ : null
455
+ }
456
+ open={deleteOpen}
457
+ title="Delete this Claude Code session?"
458
+ onConfirm={() => deleteSessionMutation.mutate()}
459
+ onOpenChange={(open) => {
460
+ setDeleteOpen(open);
461
+ if (!open) {
462
+ deleteSessionMutation.reset();
463
+ }
464
+ }}
465
+ />
340
466
  </div>
341
467
  );
342
468
  }