spiracha 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +6 -1
  2. package/apps/ui/README.md +20 -2
  3. package/apps/ui/package.json +2 -2
  4. package/apps/ui/src/components/antigravity-conversations-table.tsx +50 -11
  5. package/apps/ui/src/components/antigravity-workspaces-table.tsx +1 -1
  6. package/apps/ui/src/components/app-shell.tsx +1 -0
  7. package/apps/ui/src/components/breadcrumbs.tsx +21 -3
  8. package/apps/ui/src/components/claude-code-sessions-table.tsx +42 -6
  9. package/apps/ui/src/components/claude-code-workspaces-table.tsx +3 -1
  10. package/apps/ui/src/components/cursor-threads-table.tsx +10 -47
  11. package/apps/ui/src/components/data-table.tsx +7 -5
  12. package/apps/ui/src/components/export-dialog.tsx +2 -12
  13. package/apps/ui/src/components/grok-sessions-table.tsx +146 -0
  14. package/apps/ui/src/components/grok-workspaces-table.tsx +53 -0
  15. package/apps/ui/src/components/kiro-sessions-table.tsx +40 -4
  16. package/apps/ui/src/components/opencode-sessions-table.tsx +40 -4
  17. package/apps/ui/src/components/page-header.tsx +15 -9
  18. package/apps/ui/src/components/qoder-sessions-table.tsx +23 -2
  19. package/apps/ui/src/components/qoder-workspaces-table.tsx +5 -1
  20. package/apps/ui/src/components/selection-actions-toolbar.tsx +80 -0
  21. package/apps/ui/src/components/threads-table.tsx +9 -46
  22. package/apps/ui/src/components/transcript-view.tsx +148 -52
  23. package/apps/ui/src/components/ui/select.tsx +1 -1
  24. package/apps/ui/src/lib/antigravity-conversation-state.ts +17 -4
  25. package/apps/ui/src/lib/antigravity-server.ts +152 -7
  26. package/apps/ui/src/lib/antigravity-transcript-events.ts +11 -3
  27. package/apps/ui/src/lib/claude-code-queries.ts +8 -0
  28. package/apps/ui/src/lib/claude-code-server.ts +175 -7
  29. package/apps/ui/src/lib/claude-code-transcript-events.ts +8 -1
  30. package/apps/ui/src/lib/codex-queries.ts +19 -3
  31. package/apps/ui/src/lib/codex-server.ts +135 -53
  32. package/apps/ui/src/lib/cursor-server.ts +48 -5
  33. package/apps/ui/src/lib/formatters.ts +3 -5
  34. package/apps/ui/src/lib/grok-queries.ts +22 -0
  35. package/apps/ui/src/lib/grok-server.ts +169 -0
  36. package/apps/ui/src/lib/grok-transcript-events.ts +149 -0
  37. package/apps/ui/src/lib/kiro-server.ts +85 -7
  38. package/apps/ui/src/lib/opencode-server.ts +85 -7
  39. package/apps/ui/src/lib/qoder-server.ts +67 -11
  40. package/apps/ui/src/lib/route-search.ts +114 -0
  41. package/apps/ui/src/lib/source-session-export-server.ts +86 -3
  42. package/apps/ui/src/lib/thread-transcript-load.ts +15 -0
  43. package/apps/ui/src/lib/workspace-delete-navigation.ts +12 -0
  44. package/apps/ui/src/routeTree.gen.ts +107 -0
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +76 -10
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +269 -31
  47. package/apps/ui/src/routes/api.v1.conversations.$source.$id.ts +4 -0
  48. package/apps/ui/src/routes/api.v1.conversations.delete.ts +12 -0
  49. package/apps/ui/src/routes/api.v1.conversations.export.ts +12 -0
  50. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +143 -17
  51. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +165 -26
  52. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +2 -2
  53. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +11 -1
  54. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +388 -0
  55. package/apps/ui/src/routes/grok.$workspaceKey.tsx +301 -0
  56. package/apps/ui/src/routes/grok.index.tsx +48 -0
  57. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +66 -15
  58. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +199 -32
  59. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +105 -19
  60. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +236 -44
  61. package/apps/ui/src/routes/opencode.index.tsx +16 -3
  62. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +7 -4
  63. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +75 -28
  64. package/apps/ui/src/routes/threads.$threadId.tsx +548 -64
  65. package/package.json +19 -18
  66. package/src/client.ts +134 -1
  67. package/src/lib/antigravity-db.ts +208 -32
  68. package/src/lib/antigravity-exporter-types.ts +3 -0
  69. package/src/lib/antigravity-projects.ts +201 -0
  70. package/src/lib/claude-code-db.ts +498 -32
  71. package/src/lib/claude-code-exporter-types.ts +5 -0
  72. package/src/lib/claude-code-transcript-phase.ts +60 -1
  73. package/src/lib/claude-code-transcript.ts +8 -5
  74. package/src/lib/codex-browser-export.ts +23 -27
  75. package/src/lib/codex-thread-cache.ts +41 -13
  76. package/src/lib/codex-thread-parser.ts +86 -35
  77. package/src/lib/codex-transcript-filter.ts +31 -0
  78. package/src/lib/codex-transcript-renderer.ts +39 -19
  79. package/src/lib/concurrency.ts +41 -0
  80. package/src/lib/conversation-api.ts +399 -19
  81. package/src/lib/conversation-data/antigravity-adapter.ts +21 -2
  82. package/src/lib/conversation-data/claude-code-adapter.ts +37 -6
  83. package/src/lib/conversation-data/codex-adapter.ts +28 -4
  84. package/src/lib/conversation-data/cursor-adapter.ts +35 -1
  85. package/src/lib/conversation-data/grok-adapter.ts +210 -0
  86. package/src/lib/conversation-data/index.ts +76 -1
  87. package/src/lib/conversation-data/kiro-adapter.ts +41 -7
  88. package/src/lib/conversation-data/opencode-adapter.ts +24 -2
  89. package/src/lib/conversation-data/qoder-adapter.ts +44 -19
  90. package/src/lib/conversation-data/types.ts +43 -0
  91. package/src/lib/conversation-zip-export.ts +57 -0
  92. package/src/lib/cursor-db.ts +34 -5
  93. package/src/lib/grok-db.ts +1026 -0
  94. package/src/lib/grok-exporter-types.ts +110 -0
  95. package/src/lib/grok-transcript-phase.ts +52 -0
  96. package/src/lib/grok-transcript.ts +154 -0
  97. package/src/lib/kiro-db.ts +52 -1
  98. package/src/lib/model-label.ts +11 -3
  99. package/src/lib/opencode-db.ts +598 -55
  100. package/src/lib/opencode-transcript.ts +8 -5
  101. package/src/lib/shared.ts +12 -0
  102. package/src/lib/transcript-load-limiter.ts +82 -0
  103. package/src/lib/ui-cache.ts +43 -17
  104. package/src/lib/ui-export-archive.ts +60 -0
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![license](https://img.shields.io/npm/l/spiracha)](LICENSE.md)
8
8
  [![runtime](https://img.shields.io/badge/runtime-Bun-000000?logo=bun)](https://bun.sh)
9
9
 
10
- Spiracha is a local TanStack Start app for browsing and exporting agent conversation history from Codex, Claude Code, Kiro, Qoder, Cursor, Antigravity, and OpenCode.
10
+ Spiracha is a local TanStack Start app for browsing and exporting agent conversation history from Codex, Claude Code, Grok, Kiro, Qoder, Cursor, Antigravity, and OpenCode.
11
11
 
12
12
  The legacy CLI, MCP server, and Codex plugin surfaces have been removed in the 2.0 hard cut. Spiracha now exposes the UI and a stable local data API; client-specific workflows such as review collection belong in the client that calls the API.
13
13
 
@@ -54,11 +54,16 @@ GET /api/v1/conversations?cwd=/absolute/project&include_messages=true
54
54
  POST /api/v1/conversation-query
55
55
  GET /api/v1/conversations/:source/:id
56
56
  GET /api/v1/conversations/:source/:id/export
57
+ DELETE /api/v1/conversations/:source/:id
58
+ POST /api/v1/conversations/delete
59
+ POST /api/v1/conversations/export
57
60
  GET /api/v1/resolve?ref=<url-or-deeplink>
58
61
  ```
59
62
 
60
63
  The default list selector is `last_final_answer`, which keeps `fgh --collect` style clients fast and small. Use `message_selector=all` when a client needs the full normalized thread.
61
64
 
65
+ Batch delete requires an explicit source and ID list. It returns `deletedIds`, `missingIds`, and a result for each requested ID, so partial success is represented in a `200` response body. Batch export also requires an explicit source and ID list, but is atomic: any missing ID returns an error instead of a partial archive.
66
+
62
67
  Example:
63
68
 
64
69
  ```bash
package/apps/ui/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Spiracha UI
2
2
 
3
- The browser UI for browsing local Codex, Claude Code, Kiro, Qoder, Cursor, Antigravity, and OpenCode history, inspecting transcript details, exporting chats, and analyzing Codex usage patterns.
3
+ The browser UI for browsing local Codex, Claude Code, Grok, Kiro, Qoder, Cursor, Antigravity, and OpenCode history, inspecting transcript details, exporting chats, and analyzing Codex usage patterns.
4
4
 
5
5
  ## Stack
6
6
 
@@ -18,9 +18,11 @@ The browser UI for browsing local Codex, Claude Code, Kiro, Qoder, Cursor, Antig
18
18
  - lists derived Codex projects from the Codex SQLite database
19
19
  - lists Codex threads within a project in chronological order
20
20
  - shows Codex thread timelines, tool calls, metadata, and raw event context
21
- - exports Codex, Claude Code, Kiro, Qoder, Cursor, and OpenCode sessions or threads as Markdown, plain text, or optional zip archives with optional metadata, commentary, and tool-call inclusion
21
+ - exports Codex, Claude Code, Grok, Kiro, Qoder, Cursor, and OpenCode sessions or threads as Markdown, plain text, or optional zip archives with optional metadata, commentary, and tool-call inclusion
22
22
  - lists Claude Code workspaces and sessions from local `~/.claude/projects` JSONL files
23
23
  - shows dedicated Claude Code session detail pages with reasoning, tool calls, token metadata, and export actions
24
+ - lists Grok workspaces and sessions from local Grok session archives
25
+ - shows dedicated Grok session detail pages with compacted-history recovery, tool calls, metadata, export, and delete actions
24
26
  - lists Kiro workspaces and sessions from local Kiro workspace session files
25
27
  - shows dedicated Kiro session detail pages with image attachments, prompt logs, execution-derived tool calls, metadata, and export actions
26
28
  - lists Qoder workspaces and sessions from local Qoder history and checkpoint storage
@@ -67,9 +69,15 @@ Runtime configuration is intentionally small:
67
69
  - `SPIRACHA_ANALYTICS_TRANSCRIPT_CONCURRENCY`
68
70
  - Optional positive integer for Codex analytics transcript parsing concurrency.
69
71
  - Defaults to `8`.
72
+ - `SPIRACHA_TRANSCRIPT_LOAD_CONCURRENCY`
73
+ - Optional positive integer for detail-page transcript loading concurrency across sources.
74
+ - Defaults to `3` and is capped at `16` to protect the server from excessive parallel disk and database work.
70
75
  - `SPIRACHA_CLAUDE_CODE_PROJECTS_DIR`
71
76
  - Optional absolute path to the Claude Code projects directory.
72
77
  - If unset, Spiracha reads `${SPIRACHA_CLAUDE_CODE_DATA_DIR:-~/.claude}/projects`. `SPIRACHA_CLAUDE_CODE_DIR` and `SPIRACHA_CLAUDE_HOME` are also accepted aliases for the Claude Code data directory.
78
+ - `SPIRACHA_GROK_SESSIONS_DIR`
79
+ - Optional path to the Grok sessions directory.
80
+ - If unset, Spiracha reads `${SPIRACHA_GROK_HOME:-~/.grok}/sessions`. `SPIRACHA_GROK_DIR` is accepted as a Grok home-directory alias.
73
81
  - `SPIRACHA_KIRO_WORKSPACE_SESSIONS_DIR`
74
82
  - Optional absolute path to the Kiro workspace sessions directory.
75
83
  - If unset, Spiracha reads `${SPIRACHA_KIRO_DATA_DIR:-~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent}/workspace-sessions`. `SPIRACHA_KIRO_AGENT_DIR` and `SPIRACHA_KIRO_DIR` are also accepted aliases for the Kiro data directory.
@@ -82,6 +90,9 @@ Runtime configuration is intentionally small:
82
90
  - `SPIRACHA_OPENCODE_DB`
83
91
  - Optional absolute path to the OpenCode SQLite database.
84
92
  - If unset, Spiracha reads `${SPIRACHA_OPENCODE_DATA_DIR:-${XDG_DATA_HOME:-~/.local/share}/opencode}/opencode.db`. `SPIRACHA_OPENCODE_DIR` is also accepted as an OpenCode data-directory alias.
93
+ - `SPIRACHA_OPENCODE_DB_CONCURRENCY`
94
+ - Optional positive integer for concurrent OpenCode database reads.
95
+ - Defaults to `2`.
85
96
  - `SPIRACHA_CURSOR_USER_DIR`
86
97
  - Optional absolute path to Cursor's `User` directory.
87
98
  - If unset, Spiracha reads the platform default Cursor user-data directory.
@@ -100,6 +111,7 @@ Default source locations:
100
111
  | --- | --- | --- |
101
112
  | Codex | shared Codex DB probe list | `SPIRACHA_CODEX_DB` |
102
113
  | Claude Code | `~/.claude/projects` | `SPIRACHA_CLAUDE_CODE_PROJECTS_DIR` |
114
+ | Grok | `~/.grok/sessions` | `SPIRACHA_GROK_SESSIONS_DIR`, `SPIRACHA_GROK_HOME` |
103
115
  | Kiro | `~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent/workspace-sessions` | `SPIRACHA_KIRO_WORKSPACE_SESSIONS_DIR` |
104
116
  | Qoder | `~/Library/Application Support/Qoder/User/globalStorage/state.vscdb` + `~/Library/Application Support/Qoder/User/workspaceStorage` | `SPIRACHA_QODER_GLOBAL_STATE_DB`, `SPIRACHA_QODER_WORKSPACE_STORAGE_DIR` |
105
117
  | Cursor | `~/Library/Application Support/Cursor/User` on macOS | `SPIRACHA_CURSOR_USER_DIR`, `SPIRACHA_CURSOR_PROJECTS_DIR` |
@@ -125,6 +137,12 @@ Transcript detail pages expose the same display controls across sources: user me
125
137
  - Claude Code workspace session listing
126
138
  - `/claude-code-sessions/$sessionId`
127
139
  - Claude Code session detail and export
140
+ - `/grok`
141
+ - Grok workspace inventory and search
142
+ - `/grok/$workspaceKey`
143
+ - Grok workspace session listing
144
+ - `/grok-sessions/$sessionId`
145
+ - Grok session detail, export, and delete
128
146
  - `/kiro`
129
147
  - Kiro workspace inventory and search
130
148
  - `/kiro/$workspaceKey`
@@ -3,8 +3,8 @@
3
3
  "@tailwindcss/vite": "4.3.1",
4
4
  "@tanstack/match-sorter-utils": "8.19.4",
5
5
  "@tanstack/react-devtools": "0.10.5",
6
- "@tanstack/react-query": "5.101.0",
7
- "@tanstack/react-query-devtools": "5.101.0",
6
+ "@tanstack/react-query": "5.101.2",
7
+ "@tanstack/react-query-devtools": "5.101.2",
8
8
  "@tanstack/react-router": "1.170.15",
9
9
  "@tanstack/react-router-devtools": "1.167.0",
10
10
  "@tanstack/react-router-ssr-query": "1.167.1",
@@ -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,17 +144,13 @@ 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>
@@ -176,6 +186,13 @@ const columns = (
176
186
  Export artifacts
177
187
  </DropdownMenuItem>
178
188
  ) : null}
189
+ <DropdownMenuItem
190
+ className="text-[var(--destructive)]"
191
+ onClick={() => onDeleteConversation(info.row.original)}
192
+ >
193
+ <Trash2 className="mr-2 size-4" />
194
+ Delete conversation
195
+ </DropdownMenuItem>
179
196
  </DropdownMenuContent>
180
197
  </DropdownMenu>
181
198
  );
@@ -188,12 +205,15 @@ const columns = (
188
205
  export function AntigravityConversationsTable({
189
206
  conversations,
190
207
  decryptionState,
208
+ onDeleteConversation,
209
+ onDeleteConversations,
191
210
  onExportArtifacts,
192
211
  onExportConversation,
212
+ onExportConversations,
193
213
  }: AntigravityConversationsTableProps) {
194
214
  const tableColumns = useMemo(
195
- () => columns(decryptionState, onExportConversation, onExportArtifacts),
196
- [decryptionState, onExportArtifacts, onExportConversation],
215
+ () => columns(decryptionState, onDeleteConversation, onExportConversation, onExportArtifacts),
216
+ [decryptionState, onDeleteConversation, onExportArtifacts, onExportConversation],
197
217
  );
198
218
 
199
219
  return (
@@ -201,6 +221,25 @@ export function AntigravityConversationsTable({
201
221
  columns={tableColumns}
202
222
  data={conversations}
203
223
  emptyMessage="No Antigravity conversations match the current workspace filter."
224
+ enableRowSelection
225
+ getRowId={(row) => row.conversationId}
226
+ initialSorting={defaultSorting}
227
+ renderToolbar={({ clearSelection, selectedRows }) => {
228
+ const selectedConversationIds = selectedRows.map((row) => row.conversationId);
229
+ const hasNonExportableSelection = selectedRows.some(
230
+ (row) => !getConversationExportState(row, decryptionState).canExportConversation,
231
+ );
232
+ return (
233
+ <SelectionActionsToolbar
234
+ clearSelection={clearSelection}
235
+ exportDisabled={hasNonExportableSelection}
236
+ itemLabel="conversation"
237
+ selectedCount={selectedRows.length}
238
+ onDeleteSelected={() => onDeleteConversations(selectedConversationIds)}
239
+ onExportSelected={() => onExportConversations(selectedConversationIds)}
240
+ />
241
+ );
242
+ }}
204
243
  />
205
244
  );
206
245
  }
@@ -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
  }),
@@ -32,6 +32,7 @@ const navItems: readonly NavItem[] = [
32
32
  label: 'Claude Code',
33
33
  to: '/claude-code',
34
34
  },
35
+ { activePrefixes: ['/grok', '/grok-sessions'], icon: Bot, label: 'Grok', to: '/grok' },
35
36
  { activePrefixes: ['/kiro', '/kiro-sessions'], icon: BrainCircuit, label: 'Kiro', to: '/kiro' },
36
37
  { activePrefixes: ['/qoder', '/qoder-sessions'], icon: Workflow, label: 'Qoder', to: '/qoder' },
37
38
  {
@@ -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, 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,7 @@ 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);
82
83
 
83
84
  const updateSelectionForRow = (rowId: string, checked: boolean, shiftKey: boolean) => {
84
85
  const visibleRowIds = table.getRowModel().rows.map((row) => row.id);
@@ -101,17 +102,18 @@ export function DataTable<TData>({
101
102
  <Checkbox
102
103
  aria-label={`Select row ${row.id}`}
103
104
  checked={row.getIsSelected()}
104
- onClick={(event: MouseEvent<HTMLButtonElement>) => {
105
+ onPointerDown={(event) => {
105
106
  event.stopPropagation();
106
- event.preventDefault();
107
- updateSelectionForRow(row.id, !row.getIsSelected(), event.shiftKey);
107
+ pendingShiftSelectionRowIdRef.current = event.shiftKey ? row.id : null;
108
108
  }}
109
109
  onCheckedChange={(checked) => {
110
110
  if (typeof checked !== 'boolean') {
111
111
  return;
112
112
  }
113
113
 
114
- updateSelectionForRow(row.id, checked, false);
114
+ const shiftKey = pendingShiftSelectionRowIdRef.current === row.id;
115
+ pendingShiftSelectionRowIdRef.current = null;
116
+ updateSelectionForRow(row.id, checked, shiftKey);
115
117
  }}
116
118
  />
117
119
  ),
@@ -79,18 +79,8 @@ export function ExportDialog({
79
79
  <SelectValue placeholder="Choose a format" />
80
80
  </SelectTrigger>
81
81
  <SelectContent className="border-[var(--border)] bg-[var(--panel)] text-[var(--foreground)] shadow-[var(--panel-shadow)]">
82
- <SelectItem
83
- className="focus:bg-[var(--panel-secondary)] focus:text-[var(--foreground)] data-[highlighted]:bg-[var(--panel-secondary)] data-[highlighted]:text-[var(--foreground)]"
84
- value="md"
85
- >
86
- Markdown (.md)
87
- </SelectItem>
88
- <SelectItem
89
- className="focus:bg-[var(--panel-secondary)] focus:text-[var(--foreground)] data-[highlighted]:bg-[var(--panel-secondary)] data-[highlighted]:text-[var(--foreground)]"
90
- value="txt"
91
- >
92
- Plain text (.txt)
93
- </SelectItem>
82
+ <SelectItem value="md">Markdown (.md)</SelectItem>
83
+ <SelectItem value="txt">Plain text (.txt)</SelectItem>
94
84
  </SelectContent>
95
85
  </Select>
96
86
  </div>