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.
Files changed (162) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +42 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +36 -11
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +51 -11
  7. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  8. package/apps/ui/src/components/app-shell.tsx +46 -3
  9. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  10. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  11. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  12. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  13. package/apps/ui/src/components/data-table.tsx +23 -5
  14. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  15. package/apps/ui/src/components/export-dialog.tsx +79 -74
  16. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  17. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  18. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  19. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  20. package/apps/ui/src/components/page-header.tsx +15 -9
  21. package/apps/ui/src/components/projects-table.tsx +4 -1
  22. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  23. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  24. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  25. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  26. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  27. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  28. package/apps/ui/src/components/threads-table.tsx +13 -47
  29. package/apps/ui/src/components/transcript-view.tsx +148 -52
  30. package/apps/ui/src/components/ui/select.tsx +1 -1
  31. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  32. package/apps/ui/src/lib/antigravity-server.ts +209 -11
  33. package/apps/ui/src/lib/antigravity-transcript-events.ts +57 -73
  34. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  35. package/apps/ui/src/lib/claude-code-server.ts +185 -7
  36. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  37. package/apps/ui/src/lib/codex-queries.ts +16 -0
  38. package/apps/ui/src/lib/codex-server.ts +134 -52
  39. package/apps/ui/src/lib/cursor-server.ts +68 -53
  40. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  41. package/apps/ui/src/lib/delete-batch.ts +52 -0
  42. package/apps/ui/src/lib/error-presentation.ts +43 -0
  43. package/apps/ui/src/lib/export-mutation.ts +20 -0
  44. package/apps/ui/src/lib/export-options.ts +15 -0
  45. package/apps/ui/src/lib/formatters.ts +3 -5
  46. package/apps/ui/src/lib/grok-queries.ts +22 -0
  47. package/apps/ui/src/lib/grok-server.ts +168 -0
  48. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  49. package/apps/ui/src/lib/kiro-server.ts +97 -7
  50. package/apps/ui/src/lib/mutation-error.ts +15 -0
  51. package/apps/ui/src/lib/opencode-server.ts +96 -7
  52. package/apps/ui/src/lib/path-utils.ts +1 -1
  53. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  54. package/apps/ui/src/lib/qoder-server.ts +67 -11
  55. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  56. package/apps/ui/src/lib/route-search.ts +114 -0
  57. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  58. package/apps/ui/src/lib/settings-server.ts +33 -0
  59. package/apps/ui/src/lib/settings-store.tsx +82 -38
  60. package/apps/ui/src/lib/settings.ts +65 -0
  61. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  62. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  63. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  64. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  65. package/apps/ui/src/lib/workspace-delete-navigation.ts +20 -0
  66. package/apps/ui/src/routeTree.gen.ts +341 -234
  67. package/apps/ui/src/routes/__root.tsx +12 -15
  68. package/apps/ui/src/routes/analytics.tsx +10 -61
  69. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +149 -29
  70. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +262 -42
  71. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  72. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  73. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  74. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  75. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +168 -28
  76. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +179 -45
  77. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  78. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  79. package/apps/ui/src/routes/codex.index.tsx +5 -10
  80. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +31 -18
  81. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +44 -28
  82. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  83. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +401 -0
  84. package/apps/ui/src/routes/grok.$workspaceKey.tsx +295 -0
  85. package/apps/ui/src/routes/grok.index.tsx +48 -0
  86. package/apps/ui/src/routes/index.tsx +2 -18
  87. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +91 -27
  88. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +213 -51
  89. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  90. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +108 -29
  91. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +231 -55
  92. package/apps/ui/src/routes/opencode.index.tsx +6 -2
  93. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +19 -15
  94. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +89 -48
  95. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  96. package/apps/ui/src/routes/threads.$threadId.tsx +620 -105
  97. package/apps/ui/vite.config.ts +22 -12
  98. package/bin/spiracha.ts +28 -1
  99. package/package.json +37 -27
  100. package/src/client.ts +183 -5
  101. package/src/lib/antigravity-db.ts +424 -94
  102. package/src/lib/antigravity-exporter-types.ts +3 -0
  103. package/src/lib/antigravity-keychain.ts +7 -8
  104. package/src/lib/antigravity-projects.ts +201 -0
  105. package/src/lib/antigravity-transcript-phase.ts +47 -0
  106. package/src/lib/claude-code-db.ts +534 -42
  107. package/src/lib/claude-code-exporter-types.ts +5 -0
  108. package/src/lib/claude-code-transcript-phase.ts +60 -1
  109. package/src/lib/claude-code-transcript.ts +8 -5
  110. package/src/lib/codex-analytics.ts +32 -5
  111. package/src/lib/codex-browser-db.ts +540 -137
  112. package/src/lib/codex-browser-export.ts +18 -27
  113. package/src/lib/codex-browser-types.ts +21 -1
  114. package/src/lib/codex-thread-cache.ts +106 -23
  115. package/src/lib/codex-thread-parser.ts +149 -55
  116. package/src/lib/codex-thread-recovery.ts +38 -11
  117. package/src/lib/codex-transcript-filter.ts +31 -0
  118. package/src/lib/codex-transcript-renderer.ts +87 -79
  119. package/src/lib/concurrency.ts +56 -3
  120. package/src/lib/conversation-api.ts +410 -27
  121. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  122. package/src/lib/conversation-data/antigravity-adapter.ts +70 -26
  123. package/src/lib/conversation-data/claude-code-adapter.ts +69 -12
  124. package/src/lib/conversation-data/codex-adapter.ts +108 -73
  125. package/src/lib/conversation-data/cursor-adapter.ts +77 -29
  126. package/src/lib/conversation-data/grok-adapter.ts +223 -0
  127. package/src/lib/conversation-data/index.ts +105 -11
  128. package/src/lib/conversation-data/kiro-adapter.ts +59 -12
  129. package/src/lib/conversation-data/message-selector.ts +2 -5
  130. package/src/lib/conversation-data/opencode-adapter.ts +67 -17
  131. package/src/lib/conversation-data/path-match.ts +1 -13
  132. package/src/lib/conversation-data/qoder-adapter.ts +67 -37
  133. package/src/lib/conversation-data/types.ts +43 -0
  134. package/src/lib/conversation-zip-export.ts +75 -0
  135. package/src/lib/cursor-db.ts +68 -28
  136. package/src/lib/cursor-id.ts +17 -0
  137. package/src/lib/cursor-recovery.ts +146 -35
  138. package/src/lib/cursor-transcript-phase.ts +40 -0
  139. package/src/lib/cursor-transcript.ts +21 -5
  140. package/src/lib/grok-db.ts +1068 -0
  141. package/src/lib/grok-exporter-types.ts +110 -0
  142. package/src/lib/grok-transcript-phase.ts +61 -0
  143. package/src/lib/grok-transcript.ts +167 -0
  144. package/src/lib/kiro-db.ts +187 -43
  145. package/src/lib/kiro-transcript.ts +0 -4
  146. package/src/lib/model-label.ts +11 -3
  147. package/src/lib/opencode-db.ts +599 -60
  148. package/src/lib/opencode-think-tags.ts +17 -3
  149. package/src/lib/opencode-transcript.ts +8 -5
  150. package/src/lib/portable-path.ts +9 -0
  151. package/src/lib/qoder-acp-client.ts +11 -2
  152. package/src/lib/qoder-db.ts +51 -17
  153. package/src/lib/qoder-transcript-phase.ts +64 -0
  154. package/src/lib/qoder-transcript.ts +30 -2
  155. package/src/lib/shared.ts +63 -15
  156. package/src/lib/sqlite-error.ts +31 -5
  157. package/src/lib/transcript-load-limiter.ts +87 -0
  158. package/src/lib/ui-cache.ts +185 -24
  159. package/src/lib/ui-export-archive.ts +58 -39
  160. package/src/lib/ui-export-files.ts +75 -15
  161. package/src/lib/ui-export-zip.ts +40 -0
  162. 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('conversationBytes', {
134
- cell: (info) => <span className="font-mono text-sm">{formatBytes(info.getValue())}</span>,
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('conversationBytes', {
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 { PropsWithChildren } from 'react';
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
- <nav className="mt-5 grid gap-1">
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="text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]"
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 aria-current="page" className="font-medium text-[var(--foreground)]">
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 = (onExportSession: (session: ClaudeCodeSessionSummary) => void) =>
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(), { timeZone: 'UTC' })}
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({ onExportSession, sessions }: ClaudeCodeSessionsTableProps) {
104
- const tableColumns = useMemo(() => columns(onExportSession), [onExportSession]);
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">{formatDateTime(info.getValue(), { timeZone: 'UTC' })}</span>
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, X } 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,
@@ -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
- <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
148
- <p className="text-sm">
149
- {selectedRows.length} thread{selectedRows.length === 1 ? '' : 's'} selected
150
- </p>
151
- <div className="flex flex-wrap gap-2">
152
- <Button
153
- className="rounded-full"
154
- disabled={hasEmptySelection}
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 MouseEvent, type ReactNode, useRef, useState } from 'react';
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
- onClick={(event: MouseEvent<HTMLButtonElement>) => {
121
+ onPointerDown={(event) => {
105
122
  event.stopPropagation();
106
- event.preventDefault();
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
- updateSelectionForRow(row.id, checked, false);
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
- <AlertDialogAction
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
- </AlertDialogAction>
84
+ </Button>
84
85
  </AlertDialogFooter>
85
86
  </AlertDialogContent>
86
87
  </AlertDialog>