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,4 +1,4 @@
1
- import { useEffect, useId, useState } from 'react';
1
+ import { useEffect, useId, useRef, useState } from 'react';
2
2
  import { Button } from '#/components/ui/button';
3
3
  import { Checkbox } from '#/components/ui/checkbox';
4
4
  import {
@@ -10,6 +10,8 @@ import {
10
10
  DialogTitle,
11
11
  } from '#/components/ui/dialog';
12
12
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '#/components/ui/select';
13
+ import type { ExportDialogOptions } from '#/lib/export-options';
14
+ import { useSettings } from '#/lib/settings-store';
13
15
 
14
16
  type ExportDialogProps = {
15
17
  disabled?: boolean;
@@ -17,14 +19,10 @@ type ExportDialogProps = {
17
19
  forceZipArchive?: boolean;
18
20
  open: boolean;
19
21
  pending?: boolean;
22
+ showCommentaryOption?: boolean;
23
+ showToolsOption?: boolean;
20
24
  title?: string;
21
- onExport: (options: {
22
- includeCommentary: boolean;
23
- includeMetadata: boolean;
24
- includeTools: boolean;
25
- outputFormat: 'md' | 'txt';
26
- zipArchive: boolean;
27
- }) => void;
25
+ onExport: (options: ExportDialogOptions) => void;
28
26
  onOpenChange: (open: boolean) => void;
29
27
  };
30
28
 
@@ -34,27 +32,49 @@ export function ExportDialog({
34
32
  forceZipArchive = false,
35
33
  open,
36
34
  pending = false,
35
+ showCommentaryOption = true,
36
+ showToolsOption = true,
37
37
  title = 'Export thread',
38
38
  onExport,
39
39
  onOpenChange,
40
40
  }: ExportDialogProps) {
41
- const [outputFormat, setOutputFormat] = useState<'md' | 'txt'>('md');
42
- const [includeMetadata, setIncludeMetadata] = useState(true);
43
- const [includeCommentary, setIncludeCommentary] = useState(false);
44
- const [includeTools, setIncludeTools] = useState(true);
45
- const [zipArchive, setZipArchive] = useState(false);
46
- const effectiveZipArchive = forceZipArchive || zipArchive;
41
+ const { settings, updateSetting } = useSettings();
42
+ const [options, setOptions] = useState<ExportDialogOptions>(settings.exportDefaults);
43
+ const [submitted, setSubmitted] = useState(false);
44
+ const submissionInProgress = useRef(false);
45
+ const previousPending = useRef(pending);
46
+ const effectiveZipArchive = forceZipArchive || options.zipArchive;
47
47
  const zipDescriptionId = useId();
48
48
 
49
49
  useEffect(() => {
50
50
  if (!open) {
51
- setOutputFormat('md');
52
- setIncludeMetadata(true);
53
- setIncludeCommentary(false);
54
- setIncludeTools(true);
55
- setZipArchive(false);
51
+ setOptions(settings.exportDefaults);
52
+ setSubmitted(false);
53
+ submissionInProgress.current = false;
56
54
  }
57
- }, [open]);
55
+ }, [open, settings.exportDefaults]);
56
+
57
+ useEffect(() => {
58
+ if ((previousPending.current && !pending) || errorMessage) {
59
+ setSubmitted(false);
60
+ submissionInProgress.current = false;
61
+ }
62
+ previousPending.current = pending;
63
+ }, [errorMessage, pending]);
64
+
65
+ const updateOption = <K extends keyof ExportDialogOptions>(key: K, value: ExportDialogOptions[K]) => {
66
+ setOptions((current) => ({ ...current, [key]: value }));
67
+ };
68
+
69
+ const submitExport = () => {
70
+ if (submissionInProgress.current) {
71
+ return;
72
+ }
73
+ submissionInProgress.current = true;
74
+ setSubmitted(true);
75
+ updateSetting('exportDefaults', options);
76
+ onExport({ ...options, zipArchive: effectiveZipArchive });
77
+ };
58
78
 
59
79
  return (
60
80
  <Dialog open={open} onOpenChange={onOpenChange}>
@@ -62,7 +82,7 @@ export function ExportDialog({
62
82
  <DialogHeader>
63
83
  <DialogTitle>{title}</DialogTitle>
64
84
  <DialogDescription className="text-[var(--muted-foreground)]">
65
- Choose the transcript format and whether the export includes tool calls.
85
+ Choose the transcript format and export options.
66
86
  </DialogDescription>
67
87
  </DialogHeader>
68
88
 
@@ -71,7 +91,10 @@ export function ExportDialog({
71
91
  <label className="font-medium text-sm" htmlFor="output-format">
72
92
  Output format
73
93
  </label>
74
- <Select value={outputFormat} onValueChange={(value) => setOutputFormat(value as 'md' | 'txt')}>
94
+ <Select
95
+ value={options.outputFormat}
96
+ onValueChange={(value) => updateOption('outputFormat', value as 'md' | 'txt')}
97
+ >
75
98
  <SelectTrigger
76
99
  id="output-format"
77
100
  className="border-[var(--border)] bg-[var(--panel-secondary)] text-[var(--foreground)]"
@@ -79,18 +102,8 @@ export function ExportDialog({
79
102
  <SelectValue placeholder="Choose a format" />
80
103
  </SelectTrigger>
81
104
  <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>
105
+ <SelectItem value="md">Markdown (.md)</SelectItem>
106
+ <SelectItem value="txt">Plain text (.txt)</SelectItem>
94
107
  </SelectContent>
95
108
  </Select>
96
109
  </div>
@@ -98,8 +111,8 @@ export function ExportDialog({
98
111
  <div className="flex items-center gap-3 rounded-2xl border border-[var(--border)] bg-[var(--panel-secondary)] p-3">
99
112
  <Checkbox
100
113
  aria-label="Include metadata"
101
- checked={includeMetadata}
102
- onCheckedChange={(checked) => setIncludeMetadata(checked === true)}
114
+ checked={options.includeMetadata}
115
+ onCheckedChange={(checked) => updateOption('includeMetadata', checked === true)}
103
116
  />
104
117
  <span className="space-y-1">
105
118
  <span className="block font-medium text-sm">Include metadata</span>
@@ -109,33 +122,37 @@ export function ExportDialog({
109
122
  </span>
110
123
  </div>
111
124
 
112
- <div className="flex items-center gap-3 rounded-2xl border border-[var(--border)] bg-[var(--panel-secondary)] p-3">
113
- <Checkbox
114
- aria-label="Include commentary"
115
- checked={includeCommentary}
116
- onCheckedChange={(checked) => setIncludeCommentary(checked === true)}
117
- />
118
- <span className="space-y-1">
119
- <span className="block font-medium text-sm">Include commentary</span>
120
- <span className="block text-[var(--muted-foreground)] text-sm">
121
- Includes assistant commentary-phase updates in the exported transcript.
125
+ {showCommentaryOption ? (
126
+ <div className="flex items-center gap-3 rounded-2xl border border-[var(--border)] bg-[var(--panel-secondary)] p-3">
127
+ <Checkbox
128
+ aria-label="Include commentary"
129
+ checked={options.includeCommentary}
130
+ onCheckedChange={(checked) => updateOption('includeCommentary', checked === true)}
131
+ />
132
+ <span className="space-y-1">
133
+ <span className="block font-medium text-sm">Include commentary</span>
134
+ <span className="block text-[var(--muted-foreground)] text-sm">
135
+ Includes assistant commentary-phase updates in the exported transcript.
136
+ </span>
122
137
  </span>
123
- </span>
124
- </div>
138
+ </div>
139
+ ) : null}
125
140
 
126
- <div className="flex items-center gap-3 rounded-2xl border border-[var(--border)] bg-[var(--panel-secondary)] p-3">
127
- <Checkbox
128
- aria-label="Include tool calls"
129
- checked={includeTools}
130
- onCheckedChange={(checked) => setIncludeTools(checked === true)}
131
- />
132
- <span className="space-y-1">
133
- <span className="block font-medium text-sm">Include tool calls</span>
134
- <span className="block text-[var(--muted-foreground)] text-sm">
135
- Includes tool-call summaries and tool-output summaries in the export.
141
+ {showToolsOption ? (
142
+ <div className="flex items-center gap-3 rounded-2xl border border-[var(--border)] bg-[var(--panel-secondary)] p-3">
143
+ <Checkbox
144
+ aria-label="Include tool calls"
145
+ checked={options.includeTools}
146
+ onCheckedChange={(checked) => updateOption('includeTools', checked === true)}
147
+ />
148
+ <span className="space-y-1">
149
+ <span className="block font-medium text-sm">Include tool calls</span>
150
+ <span className="block text-[var(--muted-foreground)] text-sm">
151
+ Includes tool-call summaries and tool-output summaries in the export.
152
+ </span>
136
153
  </span>
137
- </span>
138
- </div>
154
+ </div>
155
+ ) : null}
139
156
 
140
157
  <div className="flex items-center gap-3 rounded-2xl border border-[var(--border)] bg-[var(--panel-secondary)] p-3">
141
158
  <Checkbox
@@ -143,7 +160,7 @@ export function ExportDialog({
143
160
  aria-describedby={zipDescriptionId}
144
161
  checked={effectiveZipArchive}
145
162
  disabled={forceZipArchive}
146
- onCheckedChange={(checked) => setZipArchive(checked === true)}
163
+ onCheckedChange={(checked) => updateOption('zipArchive', checked === true)}
147
164
  />
148
165
  <span className="space-y-1">
149
166
  <span className="block font-medium text-sm">Zip archive</span>
@@ -162,19 +179,7 @@ export function ExportDialog({
162
179
  <Button className="rounded-full" variant="outline" onClick={() => onOpenChange(false)}>
163
180
  Cancel
164
181
  </Button>
165
- <Button
166
- className="rounded-full"
167
- disabled={pending || disabled}
168
- onClick={() =>
169
- onExport({
170
- includeCommentary,
171
- includeMetadata,
172
- includeTools,
173
- outputFormat,
174
- zipArchive: effectiveZipArchive,
175
- })
176
- }
177
- >
182
+ <Button className="rounded-full" disabled={pending || disabled || submitted} onClick={submitExport}>
178
183
  {pending ? 'Exporting...' : 'Download export'}
179
184
  </Button>
180
185
  </DialogFooter>
@@ -0,0 +1,146 @@
1
+ import type { GrokSessionSummary } from '@spiracha/lib/grok-exporter-types';
2
+ import { Link } from '@tanstack/react-router';
3
+ import type { SortingState } from '@tanstack/react-table';
4
+ import { createColumnHelper } from '@tanstack/react-table';
5
+ import { Download, MoreHorizontal, Trash2 } from 'lucide-react';
6
+ import { useMemo } from 'react';
7
+ import { DataTable } from '#/components/data-table';
8
+ import { SelectionActionsToolbar } from '#/components/selection-actions-toolbar';
9
+ import { Button } from '#/components/ui/button';
10
+ import {
11
+ DropdownMenu,
12
+ DropdownMenuContent,
13
+ DropdownMenuItem,
14
+ DropdownMenuTrigger,
15
+ } from '#/components/ui/dropdown-menu';
16
+ import { formatDateTime, formatNumber } from '#/lib/formatters';
17
+
18
+ type GrokSessionsTableProps = {
19
+ onDeleteSession: (session: GrokSessionSummary) => void;
20
+ onDeleteSessions: (sessionIds: string[]) => void;
21
+ onExportSession: (session: GrokSessionSummary) => void;
22
+ onExportSessions: (sessionIds: string[]) => void;
23
+ sessions: GrokSessionSummary[];
24
+ };
25
+
26
+ const columnHelper = createColumnHelper<GrokSessionSummary>();
27
+ const defaultSorting: SortingState = [{ desc: true, id: 'lastActive' }];
28
+
29
+ const columns = (
30
+ onDeleteSession: (session: GrokSessionSummary) => void,
31
+ onExportSession: (session: GrokSessionSummary) => void,
32
+ ) =>
33
+ [
34
+ columnHelper.accessor('title', {
35
+ cell: (info) => (
36
+ <Link
37
+ className="block w-[16rem] max-w-[22rem] space-y-1 rounded-md outline-none transition hover:opacity-80 focus-visible:ring-2 focus-visible:ring-[var(--accent)] lg:w-auto"
38
+ params={{ sessionId: info.row.original.sessionId }}
39
+ to="/grok-sessions/$sessionId"
40
+ >
41
+ <p className="truncate font-medium underline-offset-2 hover:underline">{info.getValue()}</p>
42
+ <p className="truncate text-[var(--muted-foreground)] text-xs">{info.row.original.sessionId}</p>
43
+ </Link>
44
+ ),
45
+ header: 'Session',
46
+ }),
47
+ columnHelper.accessor('lastActiveAtMs', {
48
+ cell: (info) => (
49
+ <span className="whitespace-nowrap text-sm" suppressHydrationWarning>
50
+ {formatDateTime(info.getValue())}
51
+ </span>
52
+ ),
53
+ header: 'Updated',
54
+ id: 'lastActive',
55
+ }),
56
+ columnHelper.accessor('modelLabel', {
57
+ cell: (info) => (
58
+ <span className="text-sm">{info.getValue() ?? info.row.original.currentModelId ?? 'unknown'}</span>
59
+ ),
60
+ header: 'Model',
61
+ }),
62
+ columnHelper.accessor('agentName', {
63
+ cell: (info) => <span className="font-mono text-sm">{info.getValue() ?? 'unknown'}</span>,
64
+ header: 'Agent',
65
+ }),
66
+ columnHelper.accessor('messageCount', {
67
+ cell: (info) => <span className="font-mono text-sm">{formatNumber(info.getValue())}</span>,
68
+ header: 'Messages',
69
+ }),
70
+ columnHelper.accessor('toolCallCount', {
71
+ cell: (info) => <span className="font-mono text-sm">{formatNumber(info.getValue())}</span>,
72
+ header: 'Tools',
73
+ }),
74
+ columnHelper.display({
75
+ cell: (info) => (
76
+ <DropdownMenu>
77
+ <DropdownMenuTrigger asChild>
78
+ <Button
79
+ aria-label={`Actions for ${info.row.original.title}`}
80
+ className="rounded-full"
81
+ size="icon"
82
+ type="button"
83
+ variant="ghost"
84
+ onClick={(event) => event.stopPropagation()}
85
+ >
86
+ <MoreHorizontal className="size-4" />
87
+ </Button>
88
+ </DropdownMenuTrigger>
89
+ <DropdownMenuContent align="end">
90
+ <DropdownMenuItem
91
+ disabled={info.row.original.renderablePartCount === 0}
92
+ onClick={() => onExportSession(info.row.original)}
93
+ >
94
+ <Download className="mr-2 size-4" />
95
+ Export session
96
+ </DropdownMenuItem>
97
+ <DropdownMenuItem
98
+ className="text-[var(--destructive)]"
99
+ onClick={() => onDeleteSession(info.row.original)}
100
+ >
101
+ <Trash2 className="mr-2 size-4" />
102
+ Delete session
103
+ </DropdownMenuItem>
104
+ </DropdownMenuContent>
105
+ </DropdownMenu>
106
+ ),
107
+ enableSorting: false,
108
+ header: '',
109
+ id: 'actions',
110
+ }),
111
+ ] as const;
112
+
113
+ export const GrokSessionsTable = ({
114
+ onDeleteSession,
115
+ onDeleteSessions,
116
+ onExportSession,
117
+ onExportSessions,
118
+ sessions,
119
+ }: GrokSessionsTableProps) => {
120
+ const tableColumns = useMemo(() => columns(onDeleteSession, onExportSession), [onDeleteSession, onExportSession]);
121
+
122
+ return (
123
+ <DataTable
124
+ columns={tableColumns}
125
+ data={sessions}
126
+ emptyMessage="No Grok sessions match the current workspace filter."
127
+ enableRowSelection
128
+ getRowId={(row) => row.sessionId}
129
+ initialSorting={defaultSorting}
130
+ renderToolbar={({ clearSelection, selectedRows }) => {
131
+ const selectedSessionIds = selectedRows.map((row) => row.sessionId);
132
+ const hasEmptySelection = selectedRows.some((row) => row.renderablePartCount === 0);
133
+ return (
134
+ <SelectionActionsToolbar
135
+ clearSelection={clearSelection}
136
+ exportDisabled={hasEmptySelection}
137
+ itemLabel="session"
138
+ selectedCount={selectedRows.length}
139
+ onDeleteSelected={() => onDeleteSessions(selectedSessionIds)}
140
+ onExportSelected={() => onExportSessions(selectedSessionIds)}
141
+ />
142
+ );
143
+ }}
144
+ />
145
+ );
146
+ };
@@ -0,0 +1,53 @@
1
+ import type { GrokWorkspaceGroup } from '@spiracha/lib/grok-exporter-types';
2
+ import { Link } from '@tanstack/react-router';
3
+ import { createColumnHelper } from '@tanstack/react-table';
4
+ import { DataTable } from '#/components/data-table';
5
+ import { formatDateTime, formatNumber } from '#/lib/formatters';
6
+
7
+ type GrokWorkspacesTableProps = {
8
+ workspaces: GrokWorkspaceGroup[];
9
+ };
10
+
11
+ const columnHelper = createColumnHelper<GrokWorkspaceGroup>();
12
+
13
+ const columns = [
14
+ columnHelper.accessor('label', {
15
+ cell: (info) => (
16
+ <Link
17
+ className="block w-[16rem] max-w-[22rem] space-y-1 rounded-md outline-none transition hover:opacity-80 focus-visible:ring-2 focus-visible:ring-[var(--accent)] lg:w-auto"
18
+ params={{ workspaceKey: info.row.original.key }}
19
+ to="/grok/$workspaceKey"
20
+ >
21
+ <p className="truncate font-medium underline-offset-2 hover:underline">{info.getValue()}</p>
22
+ <p className="truncate text-[var(--muted-foreground)] text-xs">{info.row.original.worktree}</p>
23
+ </Link>
24
+ ),
25
+ header: 'Workspace',
26
+ }),
27
+ columnHelper.accessor('sessionCount', {
28
+ cell: (info) => <span className="font-mono text-sm">{formatNumber(info.getValue())}</span>,
29
+ header: 'Sessions',
30
+ }),
31
+ columnHelper.accessor('messageCount', {
32
+ cell: (info) => <span className="font-mono text-sm">{formatNumber(info.getValue())}</span>,
33
+ header: 'Messages',
34
+ }),
35
+ columnHelper.accessor('toolCallCount', {
36
+ cell: (info) => <span className="font-mono text-sm">{formatNumber(info.getValue())}</span>,
37
+ header: 'Tools',
38
+ }),
39
+ columnHelper.accessor('lastActiveAtMs', {
40
+ cell: (info) => (
41
+ <span className="whitespace-nowrap text-sm" suppressHydrationWarning>
42
+ {formatDateTime(info.getValue())}
43
+ </span>
44
+ ),
45
+ header: 'Last updated',
46
+ }),
47
+ ] as const;
48
+
49
+ export const GrokWorkspacesTable = ({ workspaces }: GrokWorkspacesTableProps) => {
50
+ return (
51
+ <DataTable columns={columns} data={workspaces} emptyMessage="No Grok workspaces match the current search." />
52
+ );
53
+ };
@@ -2,9 +2,10 @@ import type { KiroSessionSummary } from '@spiracha/lib/kiro-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 } 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 } from '#/lib/formatters';
16
17
 
17
18
  type KiroSessionsTableProps = {
19
+ onDeleteSession: (session: KiroSessionSummary) => void;
20
+ onDeleteSessions: (sessionIds: string[]) => void;
18
21
  onExportSession: (session: KiroSessionSummary) => void;
22
+ onExportSessions: (sessionIds: string[]) => void;
19
23
  sessions: KiroSessionSummary[];
20
24
  };
21
25
 
22
26
  const columnHelper = createColumnHelper<KiroSessionSummary>();
23
27
  const defaultSorting: SortingState = [{ desc: true, id: 'lastActive' }];
24
28
 
25
- const columns = (onExportSession: (session: KiroSessionSummary) => void) =>
29
+ const columns = (
30
+ onDeleteSession: (session: KiroSessionSummary) => void,
31
+ onExportSession: (session: KiroSessionSummary) => void,
32
+ ) =>
26
33
  [
27
34
  columnHelper.accessor('title', {
28
35
  cell: (info) => (
@@ -89,6 +96,13 @@ const columns = (onExportSession: (session: KiroSessionSummary) => void) =>
89
96
  <Download className="mr-2 size-4" />
90
97
  Export session
91
98
  </DropdownMenuItem>
99
+ <DropdownMenuItem
100
+ className="text-[var(--destructive)]"
101
+ onClick={() => onDeleteSession(info.row.original)}
102
+ >
103
+ <Trash2 className="mr-2 size-4" />
104
+ Delete session
105
+ </DropdownMenuItem>
92
106
  </DropdownMenuContent>
93
107
  </DropdownMenu>
94
108
  ),
@@ -98,15 +112,37 @@ const columns = (onExportSession: (session: KiroSessionSummary) => void) =>
98
112
  }),
99
113
  ] as const;
100
114
 
101
- export const KiroSessionsTable = ({ onExportSession, sessions }: KiroSessionsTableProps) => {
102
- const tableColumns = useMemo(() => columns(onExportSession), [onExportSession]);
115
+ export const KiroSessionsTable = ({
116
+ onDeleteSession,
117
+ onDeleteSessions,
118
+ onExportSession,
119
+ onExportSessions,
120
+ sessions,
121
+ }: KiroSessionsTableProps) => {
122
+ const tableColumns = useMemo(() => columns(onDeleteSession, onExportSession), [onDeleteSession, onExportSession]);
103
123
 
104
124
  return (
105
125
  <DataTable
106
126
  columns={tableColumns}
107
127
  data={sessions}
108
128
  emptyMessage="No Kiro sessions match the current workspace filter."
129
+ enableRowSelection
130
+ getRowId={(row) => row.sessionId}
109
131
  initialSorting={defaultSorting}
132
+ renderToolbar={({ clearSelection, selectedRows }) => {
133
+ const selectedSessionIds = selectedRows.map((row) => row.sessionId);
134
+ const hasEmptySelection = selectedRows.some((row) => row.renderablePartCount === 0);
135
+ return (
136
+ <SelectionActionsToolbar
137
+ clearSelection={clearSelection}
138
+ exportDisabled={hasEmptySelection}
139
+ itemLabel="session"
140
+ selectedCount={selectedRows.length}
141
+ onDeleteSelected={() => onDeleteSessions(selectedSessionIds)}
142
+ onExportSelected={() => onExportSessions(selectedSessionIds)}
143
+ />
144
+ );
145
+ }}
110
146
  />
111
147
  );
112
148
  };
@@ -2,9 +2,10 @@ import type { OpenCodeSessionSummary } from '@spiracha/lib/opencode-exporter-typ
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 { Badge } from '#/components/ui/badge';
9
10
  import { Button } from '#/components/ui/button';
10
11
  import {
@@ -16,7 +17,10 @@ import {
16
17
  import { formatDateTime, formatNumber, formatTokens } from '#/lib/formatters';
17
18
 
18
19
  type OpenCodeSessionsTableProps = {
20
+ onDeleteSession: (session: OpenCodeSessionSummary) => void;
21
+ onDeleteSessions: (sessionIds: string[]) => void;
19
22
  onExportSession: (session: OpenCodeSessionSummary) => void;
23
+ onExportSessions: (sessionIds: string[]) => void;
20
24
  sessions: OpenCodeSessionSummary[];
21
25
  };
22
26
 
@@ -31,7 +35,10 @@ const formatCost = (value: number) => {
31
35
  return `$${value.toFixed(value < 0.01 ? 4 : 2)}`;
32
36
  };
33
37
 
34
- const columns = (onExportSession: (session: OpenCodeSessionSummary) => void) =>
38
+ const columns = (
39
+ onDeleteSession: (session: OpenCodeSessionSummary) => void,
40
+ onExportSession: (session: OpenCodeSessionSummary) => void,
41
+ ) =>
35
42
  [
36
43
  columnHelper.accessor('title', {
37
44
  cell: (info) => (
@@ -105,6 +112,13 @@ const columns = (onExportSession: (session: OpenCodeSessionSummary) => void) =>
105
112
  <Download className="mr-2 size-4" />
106
113
  Export session
107
114
  </DropdownMenuItem>
115
+ <DropdownMenuItem
116
+ className="text-[var(--destructive)]"
117
+ onClick={() => onDeleteSession(info.row.original)}
118
+ >
119
+ <Trash2 className="mr-2 size-4" />
120
+ Delete session
121
+ </DropdownMenuItem>
108
122
  </DropdownMenuContent>
109
123
  </DropdownMenu>
110
124
  ),
@@ -114,15 +128,37 @@ const columns = (onExportSession: (session: OpenCodeSessionSummary) => void) =>
114
128
  }),
115
129
  ] as const;
116
130
 
117
- export const OpenCodeSessionsTable = ({ onExportSession, sessions }: OpenCodeSessionsTableProps) => {
118
- const tableColumns = useMemo(() => columns(onExportSession), [onExportSession]);
131
+ export const OpenCodeSessionsTable = ({
132
+ onDeleteSession,
133
+ onDeleteSessions,
134
+ onExportSession,
135
+ onExportSessions,
136
+ sessions,
137
+ }: OpenCodeSessionsTableProps) => {
138
+ const tableColumns = useMemo(() => columns(onDeleteSession, onExportSession), [onDeleteSession, onExportSession]);
119
139
 
120
140
  return (
121
141
  <DataTable
122
142
  columns={tableColumns}
123
143
  data={sessions}
124
144
  emptyMessage="No OpenCode sessions match the current workspace filter."
145
+ enableRowSelection
146
+ getRowId={(row) => row.sessionId}
125
147
  initialSorting={defaultSorting}
148
+ renderToolbar={({ clearSelection, selectedRows }) => {
149
+ const selectedSessionIds = selectedRows.map((row) => row.sessionId);
150
+ const hasEmptySelection = selectedRows.some((row) => row.renderablePartCount === 0);
151
+ return (
152
+ <SelectionActionsToolbar
153
+ clearSelection={clearSelection}
154
+ exportDisabled={hasEmptySelection}
155
+ itemLabel="session"
156
+ selectedCount={selectedRows.length}
157
+ onDeleteSelected={() => onDeleteSessions(selectedSessionIds)}
158
+ onExportSelected={() => onExportSessions(selectedSessionIds)}
159
+ />
160
+ );
161
+ }}
126
162
  />
127
163
  );
128
164
  };
@@ -5,10 +5,12 @@ type PageHeaderProps = {
5
5
  breadcrumb?: ReactNode;
6
6
  eyebrow?: string;
7
7
  subtitle?: string;
8
- title: string;
8
+ title?: string;
9
9
  };
10
10
 
11
11
  export function PageHeader({ actions, breadcrumb, eyebrow, subtitle, title }: PageHeaderProps) {
12
+ const hasHeading = Boolean(title || subtitle);
13
+
12
14
  return (
13
15
  <div className="flex flex-col gap-4 border-[var(--border)] border-b pb-5 sm:flex-row sm:items-end sm:justify-between">
14
16
  <div className="space-y-2">
@@ -18,14 +20,18 @@ export function PageHeader({ actions, breadcrumb, eyebrow, subtitle, title }: Pa
18
20
  {eyebrow}
19
21
  </p>
20
22
  ) : null}
21
- <div>
22
- <h2 className="font-semibold text-2xl tracking-[-0.03em] sm:text-[2rem]">{title}</h2>
23
- {subtitle ? (
24
- <p className="mt-2 max-w-[60rem] whitespace-pre-wrap break-words text-[var(--muted-foreground)] text-sm">
25
- {subtitle}
26
- </p>
27
- ) : null}
28
- </div>
23
+ {hasHeading ? (
24
+ <div>
25
+ {title ? (
26
+ <h2 className="font-semibold text-2xl tracking-[-0.03em] sm:text-[2rem]">{title}</h2>
27
+ ) : null}
28
+ {subtitle ? (
29
+ <p className="mt-2 max-w-[60rem] whitespace-pre-wrap break-words text-[var(--muted-foreground)] text-sm">
30
+ {subtitle}
31
+ </p>
32
+ ) : null}
33
+ </div>
34
+ ) : null}
29
35
  </div>
30
36
  {actions ? <div className="flex flex-wrap items-center gap-2">{actions}</div> : null}
31
37
  </div>