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
@@ -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>
@@ -5,6 +5,7 @@ import { createColumnHelper } from '@tanstack/react-table';
5
5
  import { Download, MoreHorizontal } 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,
@@ -16,6 +17,7 @@ import { formatDateTime, formatNumber } from '#/lib/formatters';
16
17
 
17
18
  type QoderSessionsTableProps = {
18
19
  onExportSession: (session: QoderSessionSummary) => void;
20
+ onExportSessions: (sessionIds: string[]) => void;
19
21
  sessions: QoderSessionSummary[];
20
22
  };
21
23
 
@@ -38,7 +40,11 @@ const columns = (onExportSession: (session: QoderSessionSummary) => void) =>
38
40
  header: 'Session',
39
41
  }),
40
42
  columnHelper.accessor('lastActiveAtMs', {
41
- cell: (info) => <span className="whitespace-nowrap text-sm">{formatDateTime(info.getValue())}</span>,
43
+ cell: (info) => (
44
+ <span className="whitespace-nowrap text-sm" suppressHydrationWarning>
45
+ {formatDateTime(info.getValue())}
46
+ </span>
47
+ ),
42
48
  header: 'Updated',
43
49
  id: 'lastActive',
44
50
  }),
@@ -98,7 +104,7 @@ const columns = (onExportSession: (session: QoderSessionSummary) => void) =>
98
104
  }),
99
105
  ] as const;
100
106
 
101
- export const QoderSessionsTable = ({ onExportSession, sessions }: QoderSessionsTableProps) => {
107
+ export const QoderSessionsTable = ({ onExportSession, onExportSessions, sessions }: QoderSessionsTableProps) => {
102
108
  const tableColumns = useMemo(() => columns(onExportSession), [onExportSession]);
103
109
 
104
110
  return (
@@ -106,7 +112,22 @@ export const QoderSessionsTable = ({ onExportSession, sessions }: QoderSessionsT
106
112
  columns={tableColumns}
107
113
  data={sessions}
108
114
  emptyMessage="No Qoder sessions match the current workspace filter."
115
+ enableRowSelection
116
+ getRowId={(row) => row.sessionId}
109
117
  initialSorting={defaultSorting}
118
+ renderToolbar={({ clearSelection, selectedRows }) => {
119
+ const selectedSessionIds = selectedRows.map((row) => row.sessionId);
120
+ const hasEmptySelection = selectedRows.some((row) => row.renderablePartCount === 0);
121
+ return (
122
+ <SelectionActionsToolbar
123
+ clearSelection={clearSelection}
124
+ exportDisabled={hasEmptySelection}
125
+ itemLabel="session"
126
+ selectedCount={selectedRows.length}
127
+ onExportSelected={() => onExportSessions(selectedSessionIds)}
128
+ />
129
+ );
130
+ }}
110
131
  />
111
132
  );
112
133
  };
@@ -41,7 +41,11 @@ const columns = [
41
41
  header: 'Snapshots',
42
42
  }),
43
43
  columnHelper.accessor('lastActiveAtMs', {
44
- cell: (info) => <span className="whitespace-nowrap text-sm">{formatDateTime(info.getValue())}</span>,
44
+ cell: (info) => (
45
+ <span className="whitespace-nowrap text-sm" suppressHydrationWarning>
46
+ {formatDateTime(info.getValue())}
47
+ </span>
48
+ ),
45
49
  header: 'Last active',
46
50
  }),
47
51
  ] as const;
@@ -0,0 +1,80 @@
1
+ import { Download, Trash2, X } from 'lucide-react';
2
+ import { Button } from '#/components/ui/button';
3
+
4
+ type SelectionActionsToolbarProps = {
5
+ clearSelection: () => void;
6
+ deleteDisabled?: boolean;
7
+ exportDisabled?: boolean;
8
+ itemLabel: string;
9
+ onDeleteSelected?: () => void;
10
+ onExportSelected?: () => void;
11
+ selectedCount: number;
12
+ };
13
+
14
+ const pluralize = (count: number, itemLabel: string) => `${itemLabel}${count === 1 ? '' : 's'}`;
15
+
16
+ export const SelectionActionsToolbar = ({
17
+ clearSelection,
18
+ deleteDisabled = false,
19
+ exportDisabled = false,
20
+ itemLabel,
21
+ onDeleteSelected,
22
+ onExportSelected,
23
+ selectedCount,
24
+ }: SelectionActionsToolbarProps) => {
25
+ if (selectedCount === 0) {
26
+ const actions = [onExportSelected ? 'export' : null, onDeleteSelected ? 'delete' : null].filter(Boolean);
27
+ const actionText =
28
+ actions.length === 2
29
+ ? `${actions[0]} or ${actions[1]} them in a batch`
30
+ : actions.length === 1
31
+ ? `${actions[0]} them in a batch`
32
+ : 'manage them in a batch';
33
+
34
+ return (
35
+ <p className="text-[var(--muted-foreground)] text-sm">
36
+ Select {pluralize(2, itemLabel)} to {actionText}.
37
+ </p>
38
+ );
39
+ }
40
+
41
+ return (
42
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
43
+ <p className="text-sm">
44
+ {selectedCount} {pluralize(selectedCount, itemLabel)} selected
45
+ </p>
46
+ <div className="flex flex-wrap gap-2">
47
+ {onExportSelected ? (
48
+ <Button
49
+ className="rounded-full"
50
+ disabled={exportDisabled}
51
+ size="sm"
52
+ type="button"
53
+ variant="outline"
54
+ onClick={onExportSelected}
55
+ >
56
+ <Download className="mr-2 size-4" />
57
+ Export selected {pluralize(selectedCount, itemLabel)}
58
+ </Button>
59
+ ) : null}
60
+ {onDeleteSelected ? (
61
+ <Button
62
+ className="rounded-full border-[var(--destructive)]/20 text-[var(--destructive)]"
63
+ disabled={deleteDisabled}
64
+ size="sm"
65
+ type="button"
66
+ variant="outline"
67
+ onClick={onDeleteSelected}
68
+ >
69
+ <Trash2 className="mr-2 size-4" />
70
+ Delete selected {pluralize(selectedCount, itemLabel)}
71
+ </Button>
72
+ ) : null}
73
+ <Button className="rounded-full" size="sm" type="button" variant="ghost" onClick={clearSelection}>
74
+ <X className="mr-2 size-4" />
75
+ Clear selection
76
+ </Button>
77
+ </div>
78
+ </div>
79
+ );
80
+ };
@@ -2,8 +2,9 @@ import type { ThreadListEntry } from '@spiracha/lib/codex-browser-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 { DataTable } from '#/components/data-table';
7
+ import { SelectionActionsToolbar } from '#/components/selection-actions-toolbar';
7
8
  import { Button } from '#/components/ui/button';
8
9
  import {
9
10
  DropdownMenu,
@@ -148,53 +149,15 @@ export function ThreadsTable({
148
149
  getRowId={(row) => row.thread.id}
149
150
  initialSorting={defaultSorting}
150
151
  renderToolbar={({ clearSelection, selectedRows }) => {
151
- if (selectedRows.length === 0) {
152
- return (
153
- <p className="text-[var(--muted-foreground)] text-sm">
154
- Select threads to export or delete them in a batch.
155
- </p>
156
- );
157
- }
158
-
159
152
  const selectedThreadIds = selectedRows.map((row) => row.thread.id);
160
153
  return (
161
- <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
162
- <p className="text-sm">
163
- {selectedRows.length} thread{selectedRows.length === 1 ? '' : 's'} selected
164
- </p>
165
- <div className="flex flex-wrap gap-2">
166
- <Button
167
- className="rounded-full"
168
- size="sm"
169
- type="button"
170
- variant="outline"
171
- onClick={() => onExportThreads(selectedThreadIds)}
172
- >
173
- <Download className="mr-2 size-4" />
174
- Export selected threads
175
- </Button>
176
- <Button
177
- className="rounded-full border-[var(--destructive)]/20 text-[var(--destructive)]"
178
- size="sm"
179
- type="button"
180
- variant="outline"
181
- onClick={() => onDeleteThreads(selectedThreadIds)}
182
- >
183
- <Trash2 className="mr-2 size-4" />
184
- Delete selected threads
185
- </Button>
186
- <Button
187
- className="rounded-full"
188
- size="sm"
189
- type="button"
190
- variant="ghost"
191
- onClick={clearSelection}
192
- >
193
- <X className="mr-2 size-4" />
194
- Clear selection
195
- </Button>
196
- </div>
197
- </div>
154
+ <SelectionActionsToolbar
155
+ clearSelection={clearSelection}
156
+ itemLabel="thread"
157
+ selectedCount={selectedRows.length}
158
+ onDeleteSelected={() => onDeleteThreads(selectedThreadIds)}
159
+ onExportSelected={() => onExportThreads(selectedThreadIds)}
160
+ />
198
161
  );
199
162
  }}
200
163
  />