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.
- package/AGENTS.md +27 -12
- package/README.md +42 -19
- package/apps/ui/AGENTS.md +18 -10
- package/apps/ui/README.md +36 -11
- package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
- package/apps/ui/src/components/antigravity-conversations-table.tsx +51 -11
- package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
- package/apps/ui/src/components/app-shell.tsx +46 -3
- package/apps/ui/src/components/breadcrumbs.tsx +21 -3
- package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
- package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
- package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
- package/apps/ui/src/components/data-table.tsx +23 -5
- package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
- package/apps/ui/src/components/export-dialog.tsx +79 -74
- package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
- package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
- package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
- package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
- package/apps/ui/src/components/page-header.tsx +15 -9
- package/apps/ui/src/components/projects-table.tsx +4 -1
- package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
- package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
- package/apps/ui/src/components/route-error-panel.tsx +13 -0
- package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
- package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
- package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
- package/apps/ui/src/components/threads-table.tsx +13 -47
- package/apps/ui/src/components/transcript-view.tsx +148 -52
- package/apps/ui/src/components/ui/select.tsx +1 -1
- package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
- package/apps/ui/src/lib/antigravity-server.ts +209 -11
- package/apps/ui/src/lib/antigravity-transcript-events.ts +57 -73
- package/apps/ui/src/lib/claude-code-queries.ts +8 -0
- package/apps/ui/src/lib/claude-code-server.ts +185 -7
- package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
- package/apps/ui/src/lib/codex-queries.ts +16 -0
- package/apps/ui/src/lib/codex-server.ts +134 -52
- package/apps/ui/src/lib/cursor-server.ts +68 -53
- package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
- package/apps/ui/src/lib/delete-batch.ts +52 -0
- package/apps/ui/src/lib/error-presentation.ts +43 -0
- package/apps/ui/src/lib/export-mutation.ts +20 -0
- package/apps/ui/src/lib/export-options.ts +15 -0
- package/apps/ui/src/lib/formatters.ts +3 -5
- package/apps/ui/src/lib/grok-queries.ts +22 -0
- package/apps/ui/src/lib/grok-server.ts +168 -0
- package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
- package/apps/ui/src/lib/kiro-server.ts +97 -7
- package/apps/ui/src/lib/mutation-error.ts +15 -0
- package/apps/ui/src/lib/opencode-server.ts +96 -7
- package/apps/ui/src/lib/path-utils.ts +1 -1
- package/apps/ui/src/lib/qoder-queries.ts +15 -0
- package/apps/ui/src/lib/qoder-server.ts +67 -11
- package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
- package/apps/ui/src/lib/route-search.ts +114 -0
- package/apps/ui/src/lib/route-state-reset.tsx +10 -0
- package/apps/ui/src/lib/settings-server.ts +33 -0
- package/apps/ui/src/lib/settings-store.tsx +82 -38
- package/apps/ui/src/lib/settings.ts +65 -0
- package/apps/ui/src/lib/source-session-export-server.ts +86 -3
- package/apps/ui/src/lib/thread-metadata.ts +22 -0
- package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
- package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
- package/apps/ui/src/lib/workspace-delete-navigation.ts +20 -0
- package/apps/ui/src/routeTree.gen.ts +341 -234
- package/apps/ui/src/routes/__root.tsx +12 -15
- package/apps/ui/src/routes/analytics.tsx +10 -61
- package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +149 -29
- package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +262 -42
- package/apps/ui/src/routes/antigravity.index.tsx +2 -2
- package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
- package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
- package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
- package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +168 -28
- package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +179 -45
- package/apps/ui/src/routes/claude-code.index.tsx +2 -2
- package/apps/ui/src/routes/codex.$project.tsx +33 -31
- package/apps/ui/src/routes/codex.index.tsx +5 -10
- package/apps/ui/src/routes/cursor-threads.$composerId.tsx +31 -18
- package/apps/ui/src/routes/cursor.$workspaceKey.tsx +44 -28
- package/apps/ui/src/routes/cursor.index.tsx +5 -2
- package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +401 -0
- package/apps/ui/src/routes/grok.$workspaceKey.tsx +295 -0
- package/apps/ui/src/routes/grok.index.tsx +48 -0
- package/apps/ui/src/routes/index.tsx +2 -18
- package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +91 -27
- package/apps/ui/src/routes/kiro.$workspaceKey.tsx +213 -51
- package/apps/ui/src/routes/kiro.index.tsx +2 -2
- package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +108 -29
- package/apps/ui/src/routes/opencode.$workspaceKey.tsx +231 -55
- package/apps/ui/src/routes/opencode.index.tsx +6 -2
- package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +19 -15
- package/apps/ui/src/routes/qoder.$workspaceKey.tsx +89 -48
- package/apps/ui/src/routes/qoder.index.tsx +2 -2
- package/apps/ui/src/routes/threads.$threadId.tsx +620 -105
- package/apps/ui/vite.config.ts +22 -12
- package/bin/spiracha.ts +28 -1
- package/package.json +37 -27
- package/src/client.ts +183 -5
- package/src/lib/antigravity-db.ts +424 -94
- package/src/lib/antigravity-exporter-types.ts +3 -0
- package/src/lib/antigravity-keychain.ts +7 -8
- package/src/lib/antigravity-projects.ts +201 -0
- package/src/lib/antigravity-transcript-phase.ts +47 -0
- package/src/lib/claude-code-db.ts +534 -42
- package/src/lib/claude-code-exporter-types.ts +5 -0
- package/src/lib/claude-code-transcript-phase.ts +60 -1
- package/src/lib/claude-code-transcript.ts +8 -5
- package/src/lib/codex-analytics.ts +32 -5
- package/src/lib/codex-browser-db.ts +540 -137
- package/src/lib/codex-browser-export.ts +18 -27
- package/src/lib/codex-browser-types.ts +21 -1
- package/src/lib/codex-thread-cache.ts +106 -23
- package/src/lib/codex-thread-parser.ts +149 -55
- package/src/lib/codex-thread-recovery.ts +38 -11
- package/src/lib/codex-transcript-filter.ts +31 -0
- package/src/lib/codex-transcript-renderer.ts +87 -79
- package/src/lib/concurrency.ts +56 -3
- package/src/lib/conversation-api.ts +410 -27
- package/src/lib/conversation-data/adapter-helpers.ts +25 -3
- package/src/lib/conversation-data/antigravity-adapter.ts +70 -26
- package/src/lib/conversation-data/claude-code-adapter.ts +69 -12
- package/src/lib/conversation-data/codex-adapter.ts +108 -73
- package/src/lib/conversation-data/cursor-adapter.ts +77 -29
- package/src/lib/conversation-data/grok-adapter.ts +223 -0
- package/src/lib/conversation-data/index.ts +105 -11
- package/src/lib/conversation-data/kiro-adapter.ts +59 -12
- package/src/lib/conversation-data/message-selector.ts +2 -5
- package/src/lib/conversation-data/opencode-adapter.ts +67 -17
- package/src/lib/conversation-data/path-match.ts +1 -13
- package/src/lib/conversation-data/qoder-adapter.ts +67 -37
- package/src/lib/conversation-data/types.ts +43 -0
- package/src/lib/conversation-zip-export.ts +75 -0
- package/src/lib/cursor-db.ts +68 -28
- package/src/lib/cursor-id.ts +17 -0
- package/src/lib/cursor-recovery.ts +146 -35
- package/src/lib/cursor-transcript-phase.ts +40 -0
- package/src/lib/cursor-transcript.ts +21 -5
- package/src/lib/grok-db.ts +1068 -0
- package/src/lib/grok-exporter-types.ts +110 -0
- package/src/lib/grok-transcript-phase.ts +61 -0
- package/src/lib/grok-transcript.ts +167 -0
- package/src/lib/kiro-db.ts +187 -43
- package/src/lib/kiro-transcript.ts +0 -4
- package/src/lib/model-label.ts +11 -3
- package/src/lib/opencode-db.ts +599 -60
- package/src/lib/opencode-think-tags.ts +17 -3
- package/src/lib/opencode-transcript.ts +8 -5
- package/src/lib/portable-path.ts +9 -0
- package/src/lib/qoder-acp-client.ts +11 -2
- package/src/lib/qoder-db.ts +51 -17
- package/src/lib/qoder-transcript-phase.ts +64 -0
- package/src/lib/qoder-transcript.ts +30 -2
- package/src/lib/shared.ts +63 -15
- package/src/lib/sqlite-error.ts +31 -5
- package/src/lib/transcript-load-limiter.ts +87 -0
- package/src/lib/ui-cache.ts +185 -24
- package/src/lib/ui-export-archive.ts +58 -39
- package/src/lib/ui-export-files.ts +75 -15
- package/src/lib/ui-export-zip.ts +40 -0
- 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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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 <
|
|
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 [
|
|
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:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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.
|
|
119
|
-
onExport={(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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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 {
|
|
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 <
|
|
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 {
|
|
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 <
|
|
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
|
-
{
|
|
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(
|
|
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:
|
|
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 {
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
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 <
|
|
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<
|
|
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:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 =
|
|
75
|
-
|
|
76
|
-
session
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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.
|
|
110
|
-
onExport={(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
|
};
|