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,10 +1,12 @@
|
|
|
1
1
|
import type { AntigravityConversation } from '@spiracha/lib/antigravity-exporter-types';
|
|
2
2
|
import type { AntigravityDecryptionState } from '@spiracha/lib/antigravity-keychain';
|
|
3
3
|
import { Link } from '@tanstack/react-router';
|
|
4
|
+
import type { SortingState } from '@tanstack/react-table';
|
|
4
5
|
import { createColumnHelper } from '@tanstack/react-table';
|
|
5
|
-
import { Download, LockKeyhole, MoreHorizontal, ScrollText } from 'lucide-react';
|
|
6
|
+
import { Download, LockKeyhole, MoreHorizontal, ScrollText, Trash2 } from 'lucide-react';
|
|
6
7
|
import { useMemo } from 'react';
|
|
7
8
|
import { DataTable } from '#/components/data-table';
|
|
9
|
+
import { SelectionActionsToolbar } from '#/components/selection-actions-toolbar';
|
|
8
10
|
import { Badge } from '#/components/ui/badge';
|
|
9
11
|
import { Button } from '#/components/ui/button';
|
|
10
12
|
import {
|
|
@@ -24,8 +26,11 @@ import { formatBytes, formatDateTime, formatNumber } from '#/lib/formatters';
|
|
|
24
26
|
type AntigravityConversationsTableProps = {
|
|
25
27
|
conversations: AntigravityConversation[];
|
|
26
28
|
decryptionState: AntigravityDecryptionState | null;
|
|
29
|
+
onDeleteConversation: (conversation: AntigravityConversation) => void;
|
|
30
|
+
onDeleteConversations: (conversationIds: string[]) => void;
|
|
27
31
|
onExportArtifacts: (conversation: AntigravityConversation) => void;
|
|
28
32
|
onExportConversation: (conversation: AntigravityConversation) => void;
|
|
33
|
+
onExportConversations: (conversationIds: string[]) => void;
|
|
29
34
|
};
|
|
30
35
|
|
|
31
36
|
type ConversationExportState = {
|
|
@@ -34,10 +39,10 @@ type ConversationExportState = {
|
|
|
34
39
|
hasTranscript: boolean;
|
|
35
40
|
lockedTranscript: boolean;
|
|
36
41
|
showConversationAction: boolean;
|
|
37
|
-
showActions: boolean;
|
|
38
42
|
};
|
|
39
43
|
|
|
40
44
|
const columnHelper = createColumnHelper<AntigravityConversation>();
|
|
45
|
+
const defaultSorting: SortingState = [{ desc: true, id: 'updatedAt' }];
|
|
41
46
|
|
|
42
47
|
const getConversationExportState = (
|
|
43
48
|
conversation: AntigravityConversation,
|
|
@@ -56,7 +61,6 @@ const getConversationExportState = (
|
|
|
56
61
|
hasArtifacts,
|
|
57
62
|
hasTranscript,
|
|
58
63
|
lockedTranscript,
|
|
59
|
-
showActions: showConversationAction || hasArtifacts,
|
|
60
64
|
showConversationAction,
|
|
61
65
|
};
|
|
62
66
|
};
|
|
@@ -74,8 +78,17 @@ const getTranscriptLabel = (
|
|
|
74
78
|
return `${source} · ${status}`;
|
|
75
79
|
};
|
|
76
80
|
|
|
81
|
+
const getConversationSizeLabel = (conversation: AntigravityConversation): string => {
|
|
82
|
+
if (conversation.totalBytes > 0) {
|
|
83
|
+
return formatBytes(conversation.totalBytes);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return conversation.summaryPath ? 'Summary' : formatBytes(conversation.totalBytes);
|
|
87
|
+
};
|
|
88
|
+
|
|
77
89
|
const columns = (
|
|
78
90
|
decryptionState: AntigravityDecryptionState | null,
|
|
91
|
+
onDeleteConversation: (conversation: AntigravityConversation) => void,
|
|
79
92
|
onExportConversation: (conversation: AntigravityConversation) => void,
|
|
80
93
|
onExportArtifacts: (conversation: AntigravityConversation) => void,
|
|
81
94
|
) =>
|
|
@@ -110,6 +123,7 @@ const columns = (
|
|
|
110
123
|
</span>
|
|
111
124
|
),
|
|
112
125
|
header: 'Updated',
|
|
126
|
+
id: 'updatedAt',
|
|
113
127
|
}),
|
|
114
128
|
columnHelper.display({
|
|
115
129
|
cell: (info) => {
|
|
@@ -130,21 +144,18 @@ const columns = (
|
|
|
130
144
|
cell: (info) => <span className="font-mono text-sm">{formatNumber(info.getValue())}</span>,
|
|
131
145
|
header: 'Artifacts',
|
|
132
146
|
}),
|
|
133
|
-
columnHelper.accessor('
|
|
134
|
-
cell: (info) => <span className="font-mono text-sm">{
|
|
147
|
+
columnHelper.accessor('totalBytes', {
|
|
148
|
+
cell: (info) => <span className="font-mono text-sm">{getConversationSizeLabel(info.row.original)}</span>,
|
|
135
149
|
header: 'Size',
|
|
136
150
|
}),
|
|
137
151
|
columnHelper.display({
|
|
138
152
|
cell: (info) => {
|
|
139
153
|
const exportState = getConversationExportState(info.row.original, decryptionState);
|
|
140
|
-
if (!exportState.showActions) {
|
|
141
|
-
return <span className="text-[var(--muted-foreground)] text-sm">No export</span>;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
154
|
return (
|
|
145
155
|
<DropdownMenu>
|
|
146
156
|
<DropdownMenuTrigger asChild>
|
|
147
157
|
<Button
|
|
158
|
+
aria-label={`Actions for ${info.row.original.title}`}
|
|
148
159
|
className="rounded-full"
|
|
149
160
|
size="icon"
|
|
150
161
|
type="button"
|
|
@@ -176,6 +187,13 @@ const columns = (
|
|
|
176
187
|
Export artifacts
|
|
177
188
|
</DropdownMenuItem>
|
|
178
189
|
) : null}
|
|
190
|
+
<DropdownMenuItem
|
|
191
|
+
className="text-[var(--destructive)]"
|
|
192
|
+
onClick={() => onDeleteConversation(info.row.original)}
|
|
193
|
+
>
|
|
194
|
+
<Trash2 className="mr-2 size-4" />
|
|
195
|
+
Delete conversation
|
|
196
|
+
</DropdownMenuItem>
|
|
179
197
|
</DropdownMenuContent>
|
|
180
198
|
</DropdownMenu>
|
|
181
199
|
);
|
|
@@ -188,12 +206,15 @@ const columns = (
|
|
|
188
206
|
export function AntigravityConversationsTable({
|
|
189
207
|
conversations,
|
|
190
208
|
decryptionState,
|
|
209
|
+
onDeleteConversation,
|
|
210
|
+
onDeleteConversations,
|
|
191
211
|
onExportArtifacts,
|
|
192
212
|
onExportConversation,
|
|
213
|
+
onExportConversations,
|
|
193
214
|
}: AntigravityConversationsTableProps) {
|
|
194
215
|
const tableColumns = useMemo(
|
|
195
|
-
() => columns(decryptionState, onExportConversation, onExportArtifacts),
|
|
196
|
-
[decryptionState, onExportArtifacts, onExportConversation],
|
|
216
|
+
() => columns(decryptionState, onDeleteConversation, onExportConversation, onExportArtifacts),
|
|
217
|
+
[decryptionState, onDeleteConversation, onExportArtifacts, onExportConversation],
|
|
197
218
|
);
|
|
198
219
|
|
|
199
220
|
return (
|
|
@@ -201,6 +222,25 @@ export function AntigravityConversationsTable({
|
|
|
201
222
|
columns={tableColumns}
|
|
202
223
|
data={conversations}
|
|
203
224
|
emptyMessage="No Antigravity conversations match the current workspace filter."
|
|
225
|
+
enableRowSelection
|
|
226
|
+
getRowId={(row) => row.conversationId}
|
|
227
|
+
initialSorting={defaultSorting}
|
|
228
|
+
renderToolbar={({ clearSelection, selectedRows }) => {
|
|
229
|
+
const selectedConversationIds = selectedRows.map((row) => row.conversationId);
|
|
230
|
+
const hasNonExportableSelection = selectedRows.some(
|
|
231
|
+
(row) => !getConversationExportState(row, decryptionState).canExportConversation,
|
|
232
|
+
);
|
|
233
|
+
return (
|
|
234
|
+
<SelectionActionsToolbar
|
|
235
|
+
clearSelection={clearSelection}
|
|
236
|
+
exportDisabled={hasNonExportableSelection}
|
|
237
|
+
itemLabel="conversation"
|
|
238
|
+
selectedCount={selectedRows.length}
|
|
239
|
+
onDeleteSelected={() => onDeleteConversations(selectedConversationIds)}
|
|
240
|
+
onExportSelected={() => onExportConversations(selectedConversationIds)}
|
|
241
|
+
/>
|
|
242
|
+
);
|
|
243
|
+
}}
|
|
204
244
|
/>
|
|
205
245
|
);
|
|
206
246
|
}
|
|
@@ -38,7 +38,7 @@ const columns = [
|
|
|
38
38
|
cell: (info) => <span className="font-mono text-sm">{formatNumber(info.getValue())}</span>,
|
|
39
39
|
header: 'Artifacts',
|
|
40
40
|
}),
|
|
41
|
-
columnHelper.accessor('
|
|
41
|
+
columnHelper.accessor('totalBytes', {
|
|
42
42
|
cell: (info) => <span className="font-mono text-sm">{formatBytes(info.getValue())}</span>,
|
|
43
43
|
header: 'Size',
|
|
44
44
|
}),
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Link, useRouterState } from '@tanstack/react-router';
|
|
1
|
+
import { Link, useNavigate, useRouterState } from '@tanstack/react-router';
|
|
2
2
|
import {
|
|
3
3
|
BarChart3,
|
|
4
4
|
Bot,
|
|
@@ -6,12 +6,13 @@ import {
|
|
|
6
6
|
Code2,
|
|
7
7
|
FolderOpen,
|
|
8
8
|
LayoutDashboard,
|
|
9
|
+
Search,
|
|
9
10
|
Settings2,
|
|
10
11
|
Sparkles,
|
|
11
12
|
SquareTerminal,
|
|
12
13
|
Workflow,
|
|
13
14
|
} from 'lucide-react';
|
|
14
|
-
import type
|
|
15
|
+
import { type FormEvent, type PropsWithChildren, useEffect, useState } from 'react';
|
|
15
16
|
import { packageMetadata } from '#/lib/package-metadata';
|
|
16
17
|
import { cn } from '#/lib/utils';
|
|
17
18
|
import { ThemeToggle } from './theme-toggle';
|
|
@@ -32,6 +33,7 @@ const navItems: readonly NavItem[] = [
|
|
|
32
33
|
label: 'Claude Code',
|
|
33
34
|
to: '/claude-code',
|
|
34
35
|
},
|
|
36
|
+
{ activePrefixes: ['/grok', '/grok-sessions'], icon: Bot, label: 'Grok', to: '/grok' },
|
|
35
37
|
{ activePrefixes: ['/kiro', '/kiro-sessions'], icon: BrainCircuit, label: 'Kiro', to: '/kiro' },
|
|
36
38
|
{ activePrefixes: ['/qoder', '/qoder-sessions'], icon: Workflow, label: 'Qoder', to: '/qoder' },
|
|
37
39
|
{
|
|
@@ -58,9 +60,33 @@ const GitHubIcon = ({ className }: { className?: string }) => (
|
|
|
58
60
|
);
|
|
59
61
|
|
|
60
62
|
export function AppShell({ children }: PropsWithChildren) {
|
|
63
|
+
const navigate = useNavigate();
|
|
61
64
|
const pathname = useRouterState({
|
|
62
65
|
select: (state) => state.location.pathname,
|
|
63
66
|
});
|
|
67
|
+
const routeProjectQuery = useRouterState({
|
|
68
|
+
select: (state) => {
|
|
69
|
+
if (state.location.pathname !== '/codex' && state.location.pathname !== '/codex/') {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
const query = (state.location.search as Record<string, unknown>).q;
|
|
73
|
+
return typeof query === 'string' ? query : '';
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const [projectQuery, setProjectQuery] = useState(routeProjectQuery);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
setProjectQuery(routeProjectQuery);
|
|
80
|
+
}, [routeProjectQuery]);
|
|
81
|
+
|
|
82
|
+
const handleProjectSearch = (event: FormEvent<HTMLFormElement>) => {
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
const query = projectQuery.trim();
|
|
85
|
+
void navigate({
|
|
86
|
+
search: query ? { q: query } : {},
|
|
87
|
+
to: '/codex',
|
|
88
|
+
});
|
|
89
|
+
};
|
|
64
90
|
|
|
65
91
|
return (
|
|
66
92
|
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
|
|
@@ -91,7 +117,24 @@ export function AppShell({ children }: PropsWithChildren) {
|
|
|
91
117
|
<ThemeToggle />
|
|
92
118
|
</div>
|
|
93
119
|
|
|
94
|
-
<
|
|
120
|
+
<search aria-label="Global project search" className="mt-5">
|
|
121
|
+
<form className="relative" onSubmit={handleProjectSearch}>
|
|
122
|
+
<Search
|
|
123
|
+
aria-hidden="true"
|
|
124
|
+
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
|
|
125
|
+
/>
|
|
126
|
+
<input
|
|
127
|
+
aria-label="Search Codex projects"
|
|
128
|
+
className="h-9 w-full rounded-lg border border-[var(--border)] bg-[var(--panel-secondary)] pr-3 pl-9 text-sm outline-none transition placeholder:text-[var(--muted-foreground)] focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent)]/20"
|
|
129
|
+
placeholder="Search Codex projects"
|
|
130
|
+
type="search"
|
|
131
|
+
value={projectQuery}
|
|
132
|
+
onChange={(event) => setProjectQuery(event.target.value)}
|
|
133
|
+
/>
|
|
134
|
+
</form>
|
|
135
|
+
</search>
|
|
136
|
+
|
|
137
|
+
<nav className="mt-3 grid gap-1">
|
|
95
138
|
{navItems.map((item) => {
|
|
96
139
|
const active = isNavItemActive(pathname, item);
|
|
97
140
|
const Icon = item.icon;
|
|
@@ -5,11 +5,15 @@ import type { ReactNode } from 'react';
|
|
|
5
5
|
type BreadcrumbItem =
|
|
6
6
|
| {
|
|
7
7
|
label: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
truncate?: boolean;
|
|
8
10
|
}
|
|
9
11
|
| {
|
|
10
12
|
label: string;
|
|
11
13
|
params?: Record<string, string>;
|
|
14
|
+
title?: string;
|
|
12
15
|
to: string;
|
|
16
|
+
truncate?: boolean;
|
|
13
17
|
};
|
|
14
18
|
|
|
15
19
|
type BreadcrumbsProps = {
|
|
@@ -20,27 +24,41 @@ const isLinkItem = (item: BreadcrumbItem): item is Extract<BreadcrumbItem, { to:
|
|
|
20
24
|
return 'to' in item;
|
|
21
25
|
};
|
|
22
26
|
|
|
27
|
+
const classNames = (...values: string[]) => values.filter(Boolean).join(' ');
|
|
28
|
+
|
|
23
29
|
export const Breadcrumbs = ({ items }: BreadcrumbsProps) => {
|
|
24
30
|
return (
|
|
25
31
|
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-1 text-sm">
|
|
26
32
|
{items.map((item, index) => {
|
|
33
|
+
const title = item.title ?? (item.truncate ? item.label : undefined);
|
|
34
|
+
const truncateClassName = item.truncate
|
|
35
|
+
? 'inline-block max-w-[min(34rem,62vw)] truncate align-bottom'
|
|
36
|
+
: '';
|
|
27
37
|
const content: ReactNode = isLinkItem(item) ? (
|
|
28
38
|
<Link
|
|
29
|
-
className=
|
|
39
|
+
className={classNames(
|
|
40
|
+
truncateClassName,
|
|
41
|
+
'text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]',
|
|
42
|
+
)}
|
|
30
43
|
params={item.params}
|
|
44
|
+
title={title}
|
|
31
45
|
to={item.to}
|
|
32
46
|
>
|
|
33
47
|
{item.label}
|
|
34
48
|
</Link>
|
|
35
49
|
) : (
|
|
36
|
-
<span
|
|
50
|
+
<span
|
|
51
|
+
aria-current="page"
|
|
52
|
+
className={classNames(truncateClassName, 'font-medium text-[var(--foreground)]')}
|
|
53
|
+
title={title}
|
|
54
|
+
>
|
|
37
55
|
{item.label}
|
|
38
56
|
</span>
|
|
39
57
|
);
|
|
40
58
|
|
|
41
59
|
return (
|
|
42
60
|
<div
|
|
43
|
-
className="flex items-center gap-1"
|
|
61
|
+
className="flex min-w-0 items-center gap-1"
|
|
44
62
|
key={isLinkItem(item) ? `${item.label}-${item.to}-${index}` : `${item.label}-current-${index}`}
|
|
45
63
|
>
|
|
46
64
|
{index > 0 ? <ChevronRight className="size-3.5 text-[var(--muted-foreground)]" /> : null}
|
|
@@ -2,9 +2,10 @@ import type { ClaudeCodeSessionSummary } from '@spiracha/lib/claude-code-exporte
|
|
|
2
2
|
import { Link } from '@tanstack/react-router';
|
|
3
3
|
import type { SortingState } from '@tanstack/react-table';
|
|
4
4
|
import { createColumnHelper } from '@tanstack/react-table';
|
|
5
|
-
import { Download, MoreHorizontal } from 'lucide-react';
|
|
5
|
+
import { Download, MoreHorizontal, Trash2 } from 'lucide-react';
|
|
6
6
|
import { useMemo } from 'react';
|
|
7
7
|
import { DataTable } from '#/components/data-table';
|
|
8
|
+
import { SelectionActionsToolbar } from '#/components/selection-actions-toolbar';
|
|
8
9
|
import { Button } from '#/components/ui/button';
|
|
9
10
|
import {
|
|
10
11
|
DropdownMenu,
|
|
@@ -15,14 +16,20 @@ import {
|
|
|
15
16
|
import { formatDateTime, formatNumber, formatTokens } from '#/lib/formatters';
|
|
16
17
|
|
|
17
18
|
type ClaudeCodeSessionsTableProps = {
|
|
19
|
+
onDeleteSession: (session: ClaudeCodeSessionSummary) => void;
|
|
20
|
+
onDeleteSessions: (sessionIds: string[]) => void;
|
|
18
21
|
onExportSession: (session: ClaudeCodeSessionSummary) => void;
|
|
22
|
+
onExportSessions: (sessionIds: string[]) => void;
|
|
19
23
|
sessions: ClaudeCodeSessionSummary[];
|
|
20
24
|
};
|
|
21
25
|
|
|
22
26
|
const columnHelper = createColumnHelper<ClaudeCodeSessionSummary>();
|
|
23
27
|
const defaultSorting: SortingState = [{ desc: true, id: 'lastActive' }];
|
|
24
28
|
|
|
25
|
-
const columns = (
|
|
29
|
+
const columns = (
|
|
30
|
+
onDeleteSession: (session: ClaudeCodeSessionSummary) => void,
|
|
31
|
+
onExportSession: (session: ClaudeCodeSessionSummary) => void,
|
|
32
|
+
) =>
|
|
26
33
|
[
|
|
27
34
|
columnHelper.accessor('title', {
|
|
28
35
|
cell: (info) => (
|
|
@@ -39,8 +46,8 @@ const columns = (onExportSession: (session: ClaudeCodeSessionSummary) => void) =
|
|
|
39
46
|
}),
|
|
40
47
|
columnHelper.accessor('lastActiveAtMs', {
|
|
41
48
|
cell: (info) => (
|
|
42
|
-
<span className="whitespace-nowrap text-sm">
|
|
43
|
-
{formatDateTime(info.getValue()
|
|
49
|
+
<span className="whitespace-nowrap text-sm" suppressHydrationWarning>
|
|
50
|
+
{formatDateTime(info.getValue())}
|
|
44
51
|
</span>
|
|
45
52
|
),
|
|
46
53
|
header: 'Updated',
|
|
@@ -91,6 +98,13 @@ const columns = (onExportSession: (session: ClaudeCodeSessionSummary) => void) =
|
|
|
91
98
|
<Download className="mr-2 size-4" />
|
|
92
99
|
Export session
|
|
93
100
|
</DropdownMenuItem>
|
|
101
|
+
<DropdownMenuItem
|
|
102
|
+
className="text-[var(--destructive)]"
|
|
103
|
+
onClick={() => onDeleteSession(info.row.original)}
|
|
104
|
+
>
|
|
105
|
+
<Trash2 className="mr-2 size-4" />
|
|
106
|
+
Delete session
|
|
107
|
+
</DropdownMenuItem>
|
|
94
108
|
</DropdownMenuContent>
|
|
95
109
|
</DropdownMenu>
|
|
96
110
|
),
|
|
@@ -100,15 +114,37 @@ const columns = (onExportSession: (session: ClaudeCodeSessionSummary) => void) =
|
|
|
100
114
|
}),
|
|
101
115
|
] as const;
|
|
102
116
|
|
|
103
|
-
export function ClaudeCodeSessionsTable({
|
|
104
|
-
|
|
117
|
+
export function ClaudeCodeSessionsTable({
|
|
118
|
+
onDeleteSession,
|
|
119
|
+
onDeleteSessions,
|
|
120
|
+
onExportSession,
|
|
121
|
+
onExportSessions,
|
|
122
|
+
sessions,
|
|
123
|
+
}: ClaudeCodeSessionsTableProps) {
|
|
124
|
+
const tableColumns = useMemo(() => columns(onDeleteSession, onExportSession), [onDeleteSession, onExportSession]);
|
|
105
125
|
|
|
106
126
|
return (
|
|
107
127
|
<DataTable
|
|
108
128
|
columns={tableColumns}
|
|
109
129
|
data={sessions}
|
|
110
130
|
emptyMessage="No Claude Code sessions match the current workspace filter."
|
|
131
|
+
enableRowSelection
|
|
132
|
+
getRowId={(row) => row.sessionId}
|
|
111
133
|
initialSorting={defaultSorting}
|
|
134
|
+
renderToolbar={({ clearSelection, selectedRows }) => {
|
|
135
|
+
const selectedSessionIds = selectedRows.map((row) => row.sessionId);
|
|
136
|
+
const hasEmptySelection = selectedRows.some((row) => row.renderablePartCount === 0);
|
|
137
|
+
return (
|
|
138
|
+
<SelectionActionsToolbar
|
|
139
|
+
clearSelection={clearSelection}
|
|
140
|
+
exportDisabled={hasEmptySelection}
|
|
141
|
+
itemLabel="session"
|
|
142
|
+
selectedCount={selectedRows.length}
|
|
143
|
+
onDeleteSelected={() => onDeleteSessions(selectedSessionIds)}
|
|
144
|
+
onExportSelected={() => onExportSessions(selectedSessionIds)}
|
|
145
|
+
/>
|
|
146
|
+
);
|
|
147
|
+
}}
|
|
112
148
|
/>
|
|
113
149
|
);
|
|
114
150
|
}
|
|
@@ -38,7 +38,9 @@ const columns = [
|
|
|
38
38
|
}),
|
|
39
39
|
columnHelper.accessor('lastActiveAtMs', {
|
|
40
40
|
cell: (info) => (
|
|
41
|
-
<span className="whitespace-nowrap text-sm"
|
|
41
|
+
<span className="whitespace-nowrap text-sm" suppressHydrationWarning>
|
|
42
|
+
{formatDateTime(info.getValue())}
|
|
43
|
+
</span>
|
|
42
44
|
),
|
|
43
45
|
header: 'Last active',
|
|
44
46
|
}),
|
|
@@ -2,9 +2,10 @@ import type { CursorThreadSummary } from '@spiracha/lib/cursor-exporter-types';
|
|
|
2
2
|
import { Link } from '@tanstack/react-router';
|
|
3
3
|
import type { SortingState } from '@tanstack/react-table';
|
|
4
4
|
import { createColumnHelper } from '@tanstack/react-table';
|
|
5
|
-
import { Download, MoreHorizontal, Trash2
|
|
5
|
+
import { Download, MoreHorizontal, Trash2 } from 'lucide-react';
|
|
6
6
|
import { useMemo } from 'react';
|
|
7
7
|
import { DataTable } from '#/components/data-table';
|
|
8
|
+
import { SelectionActionsToolbar } from '#/components/selection-actions-toolbar';
|
|
8
9
|
import { Button } from '#/components/ui/button';
|
|
9
10
|
import {
|
|
10
11
|
DropdownMenu,
|
|
@@ -133,55 +134,17 @@ export const CursorThreadsTable = ({
|
|
|
133
134
|
getRowId={(row) => row.composerId}
|
|
134
135
|
initialSorting={defaultSorting}
|
|
135
136
|
renderToolbar={({ clearSelection, selectedRows }) => {
|
|
136
|
-
if (selectedRows.length === 0) {
|
|
137
|
-
return (
|
|
138
|
-
<p className="text-[var(--muted-foreground)] text-sm">
|
|
139
|
-
Select threads to export or delete them in a batch.
|
|
140
|
-
</p>
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
137
|
const selectedComposerIds = selectedRows.map((row) => row.composerId);
|
|
145
138
|
const hasEmptySelection = selectedRows.some((row) => row.bubbleCount === 0);
|
|
146
139
|
return (
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
size="sm"
|
|
156
|
-
type="button"
|
|
157
|
-
variant="outline"
|
|
158
|
-
onClick={() => onExportThreads(selectedComposerIds)}
|
|
159
|
-
>
|
|
160
|
-
<Download className="mr-2 size-4" />
|
|
161
|
-
Export selected threads
|
|
162
|
-
</Button>
|
|
163
|
-
<Button
|
|
164
|
-
className="rounded-full border-[var(--destructive)]/20 text-[var(--destructive)]"
|
|
165
|
-
size="sm"
|
|
166
|
-
type="button"
|
|
167
|
-
variant="outline"
|
|
168
|
-
onClick={() => onDeleteThreads(selectedComposerIds)}
|
|
169
|
-
>
|
|
170
|
-
<Trash2 className="mr-2 size-4" />
|
|
171
|
-
Delete selected threads
|
|
172
|
-
</Button>
|
|
173
|
-
<Button
|
|
174
|
-
className="rounded-full"
|
|
175
|
-
size="sm"
|
|
176
|
-
type="button"
|
|
177
|
-
variant="ghost"
|
|
178
|
-
onClick={clearSelection}
|
|
179
|
-
>
|
|
180
|
-
<X className="mr-2 size-4" />
|
|
181
|
-
Clear selection
|
|
182
|
-
</Button>
|
|
183
|
-
</div>
|
|
184
|
-
</div>
|
|
140
|
+
<SelectionActionsToolbar
|
|
141
|
+
clearSelection={clearSelection}
|
|
142
|
+
exportDisabled={hasEmptySelection}
|
|
143
|
+
itemLabel="thread"
|
|
144
|
+
selectedCount={selectedRows.length}
|
|
145
|
+
onDeleteSelected={() => onDeleteThreads(selectedComposerIds)}
|
|
146
|
+
onExportSelected={() => onExportThreads(selectedComposerIds)}
|
|
147
|
+
/>
|
|
185
148
|
);
|
|
186
149
|
}}
|
|
187
150
|
/>
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
useReactTable,
|
|
10
10
|
} from '@tanstack/react-table';
|
|
11
11
|
import { ArrowDownUp } from 'lucide-react';
|
|
12
|
-
import { type
|
|
12
|
+
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
13
13
|
import { Checkbox } from '#/components/ui/checkbox';
|
|
14
14
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '#/components/ui/table';
|
|
15
15
|
import { cn } from '#/lib/utils';
|
|
@@ -79,6 +79,23 @@ export function DataTable<TData>({
|
|
|
79
79
|
const [sorting, setSorting] = useState<SortingState>(initialSorting);
|
|
80
80
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
|
81
81
|
const lastSelectedRowIdRef = useRef<string | null>(null);
|
|
82
|
+
const pendingShiftSelectionRowIdRef = useRef<string | null>(null);
|
|
83
|
+
const currentRowIds = useMemo(
|
|
84
|
+
() => new Set(data.map((row, index) => (getRowId ? getRowId(row, index) : String(index)))),
|
|
85
|
+
[data, getRowId],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
setRowSelection((selection) => {
|
|
90
|
+
const next = Object.fromEntries(
|
|
91
|
+
Object.entries(selection).filter(([rowId, selected]) => selected && currentRowIds.has(rowId)),
|
|
92
|
+
);
|
|
93
|
+
return Object.keys(next).length === Object.keys(selection).length ? selection : next;
|
|
94
|
+
});
|
|
95
|
+
if (lastSelectedRowIdRef.current && !currentRowIds.has(lastSelectedRowIdRef.current)) {
|
|
96
|
+
lastSelectedRowIdRef.current = null;
|
|
97
|
+
}
|
|
98
|
+
}, [currentRowIds]);
|
|
82
99
|
|
|
83
100
|
const updateSelectionForRow = (rowId: string, checked: boolean, shiftKey: boolean) => {
|
|
84
101
|
const visibleRowIds = table.getRowModel().rows.map((row) => row.id);
|
|
@@ -101,17 +118,18 @@ export function DataTable<TData>({
|
|
|
101
118
|
<Checkbox
|
|
102
119
|
aria-label={`Select row ${row.id}`}
|
|
103
120
|
checked={row.getIsSelected()}
|
|
104
|
-
|
|
121
|
+
onPointerDown={(event) => {
|
|
105
122
|
event.stopPropagation();
|
|
106
|
-
event.
|
|
107
|
-
updateSelectionForRow(row.id, !row.getIsSelected(), event.shiftKey);
|
|
123
|
+
pendingShiftSelectionRowIdRef.current = event.shiftKey ? row.id : null;
|
|
108
124
|
}}
|
|
109
125
|
onCheckedChange={(checked) => {
|
|
110
126
|
if (typeof checked !== 'boolean') {
|
|
111
127
|
return;
|
|
112
128
|
}
|
|
113
129
|
|
|
114
|
-
|
|
130
|
+
const shiftKey = pendingShiftSelectionRowIdRef.current === row.id;
|
|
131
|
+
pendingShiftSelectionRowIdRef.current = null;
|
|
132
|
+
updateSelectionForRow(row.id, checked, shiftKey);
|
|
115
133
|
}}
|
|
116
134
|
/>
|
|
117
135
|
),
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { useEffect, useId, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
AlertDialog,
|
|
4
|
-
AlertDialogAction,
|
|
5
4
|
AlertDialogCancel,
|
|
6
5
|
AlertDialogContent,
|
|
7
6
|
AlertDialogDescription,
|
|
@@ -9,6 +8,7 @@ import {
|
|
|
9
8
|
AlertDialogHeader,
|
|
10
9
|
AlertDialogTitle,
|
|
11
10
|
} from '#/components/ui/alert-dialog';
|
|
11
|
+
import { Button } from '#/components/ui/button';
|
|
12
12
|
import { Checkbox } from '#/components/ui/checkbox';
|
|
13
13
|
|
|
14
14
|
type DeleteConfirmDialogProps = {
|
|
@@ -75,12 +75,13 @@ export function DeleteConfirmDialog({
|
|
|
75
75
|
{errorMessage ? <p className="text-[var(--destructive)] text-sm">{errorMessage}</p> : null}
|
|
76
76
|
<AlertDialogFooter>
|
|
77
77
|
<AlertDialogCancel className="border-[var(--border)]">Cancel</AlertDialogCancel>
|
|
78
|
-
<
|
|
78
|
+
<Button
|
|
79
79
|
className="bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:bg-[var(--destructive)]/90"
|
|
80
|
+
type="button"
|
|
80
81
|
onClick={() => onConfirm({ deleteSessionFiles })}
|
|
81
82
|
>
|
|
82
83
|
{confirmLabel}
|
|
83
|
-
</
|
|
84
|
+
</Button>
|
|
84
85
|
</AlertDialogFooter>
|
|
85
86
|
</AlertDialogContent>
|
|
86
87
|
</AlertDialog>
|