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