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
@@ -2,6 +2,7 @@ import type { ProjectSummary } from '@spiracha/lib/codex-browser-types';
2
2
  import { Link } from '@tanstack/react-router';
3
3
  import { createColumnHelper } from '@tanstack/react-table';
4
4
  import { MoreHorizontal, Trash2 } from 'lucide-react';
5
+ import { useMemo } from 'react';
5
6
  import { DataTable } from '#/components/data-table';
6
7
  import { Button } from '#/components/ui/button';
7
8
  import {
@@ -71,6 +72,7 @@ const columns = (onDeleteProject: (project: ProjectSummary) => void) =>
71
72
  <DropdownMenu>
72
73
  <DropdownMenuTrigger asChild>
73
74
  <Button
75
+ aria-label={`Actions for ${info.row.original.name}`}
74
76
  className="rounded-full"
75
77
  size="icon"
76
78
  type="button"
@@ -97,9 +99,10 @@ const columns = (onDeleteProject: (project: ProjectSummary) => void) =>
97
99
  ] as const;
98
100
 
99
101
  export function ProjectsTable({ projects, onDeleteProject }: ProjectsTableProps) {
102
+ const memoizedColumns = useMemo(() => columns(onDeleteProject), [onDeleteProject]);
100
103
  return (
101
104
  <DataTable
102
- columns={columns(onDeleteProject)}
105
+ columns={memoizedColumns}
103
106
  data={projects}
104
107
  emptyMessage="No projects match the current search."
105
108
  initialSorting={[{ desc: true, id: 'lastUpdatedAtMs' }]}
@@ -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,13 @@
1
+ import { getErrorPresentation } from '#/lib/error-presentation';
2
+ import { ReloadErrorPanel } from './reload-error-panel';
3
+
4
+ type RouteErrorPanelProps = {
5
+ error: Error;
6
+ title: string;
7
+ };
8
+
9
+ export const RouteErrorPanel = ({ error, title }: RouteErrorPanelProps) => {
10
+ const presentation = getErrorPresentation(error, { fallbackTitle: title });
11
+
12
+ return <ReloadErrorPanel description={presentation.description} title={presentation.title} />;
13
+ };
@@ -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
+ };
@@ -0,0 +1,50 @@
1
+ import type { ThreadGoal } from '@spiracha/lib/codex-browser-types';
2
+ import { Badge } from '#/components/ui/badge';
3
+ import { formatNumber } from '#/lib/formatters';
4
+
5
+ const formatGoalStatus = (status: string) => status.replaceAll(/[-_]+/gu, ' ');
6
+
7
+ const formatElapsedTime = (seconds: number) => {
8
+ const wholeSeconds = Math.max(0, Math.floor(seconds));
9
+ const hours = Math.floor(wholeSeconds / 3_600);
10
+ const minutes = Math.floor((wholeSeconds % 3_600) / 60);
11
+ const remainingSeconds = wholeSeconds % 60;
12
+
13
+ return [hours > 0 ? `${hours}h` : null, minutes > 0 ? `${minutes}m` : null, `${remainingSeconds}s`]
14
+ .filter(Boolean)
15
+ .join(' ');
16
+ };
17
+
18
+ const formatGoalTokens = (goal: ThreadGoal) => {
19
+ const used = formatNumber(goal.tokensUsed);
20
+ return goal.tokenBudget === null ? `${used} tokens used` : `${used} / ${formatNumber(goal.tokenBudget)} tokens`;
21
+ };
22
+
23
+ export function ThreadGoalsPanel({ goals }: { goals: ThreadGoal[] }) {
24
+ return (
25
+ <section className="rounded-[1.6rem] border border-[var(--border)] bg-[var(--panel)] p-5 shadow-[var(--panel-shadow)]">
26
+ <h3 className="font-semibold text-[var(--muted-foreground)] text-sm uppercase tracking-[0.18em]">Goals</h3>
27
+ {goals.length === 0 ? (
28
+ <p className="mt-4 text-[var(--muted-foreground)] text-sm">No goals were recorded for this thread.</p>
29
+ ) : (
30
+ <div className="mt-4 space-y-3">
31
+ {goals.map((goal) => (
32
+ <article
33
+ key={goal.goalId}
34
+ className="rounded-xl border border-[var(--border)] bg-[var(--panel-secondary)] p-3.5"
35
+ >
36
+ <div className="flex flex-wrap items-start justify-between gap-2">
37
+ <p className="font-medium text-sm leading-6">{goal.objective}</p>
38
+ <Badge variant="outline">{formatGoalStatus(goal.status)}</Badge>
39
+ </div>
40
+ <div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[var(--muted-foreground)] text-xs">
41
+ <span>{formatGoalTokens(goal)}</span>
42
+ <span>{formatElapsedTime(goal.timeUsedSeconds)}</span>
43
+ </div>
44
+ </article>
45
+ ))}
46
+ </div>
47
+ )}
48
+ </section>
49
+ );
50
+ }
@@ -0,0 +1,142 @@
1
+ import type { DynamicToolDefinition, ThreadEvent } from '@spiracha/lib/codex-browser-types';
2
+ import { type TranscriptSortOrder, TranscriptView } from '#/components/transcript-view';
3
+ import { Badge } from '#/components/ui/badge';
4
+ import { Button } from '#/components/ui/button';
5
+
6
+ type ThreadToolsPanelProps = {
7
+ assistantModel: string | null;
8
+ availableTools: DynamicToolDefinition[];
9
+ events: ThreadEvent[] | null;
10
+ loadingTranscript?: boolean;
11
+ projectPath: string | null;
12
+ showRawJson: boolean;
13
+ sortOrder: TranscriptSortOrder;
14
+ transcriptIsPartial?: boolean;
15
+ transcriptState: 'available' | 'deferred' | 'missing';
16
+ onLoadTranscript?: () => void;
17
+ onSortOrderChange?: (value: TranscriptSortOrder) => void;
18
+ };
19
+
20
+ const getToolActivityEvents = (events: ThreadEvent[]) =>
21
+ events.filter((event) => event.kind === 'tool_call' || event.kind === 'tool_output' || event.kind === 'web_search');
22
+
23
+ const sortJsonKeys = (value: unknown): unknown => {
24
+ if (Array.isArray(value)) {
25
+ return value.map(sortJsonKeys);
26
+ }
27
+ if (value && typeof value === 'object') {
28
+ return Object.fromEntries(
29
+ Object.entries(value)
30
+ .sort(([left], [right]) => left.localeCompare(right))
31
+ .map(([key, child]) => [key, sortJsonKeys(child)]),
32
+ );
33
+ }
34
+ return value;
35
+ };
36
+
37
+ const getToolDefinitionKey = (tool: DynamicToolDefinition) =>
38
+ JSON.stringify([tool.namespace, tool.name, tool.description, tool.deferLoading, sortJsonKeys(tool.inputSchema)]);
39
+
40
+ const ToolDefinitions = ({ tools }: { tools: DynamicToolDefinition[] }) => {
41
+ const uniqueTools = [...new Map(tools.map((tool) => [getToolDefinitionKey(tool), tool])).entries()];
42
+
43
+ return (
44
+ <section className="rounded-[1.6rem] border border-[var(--border)] bg-[var(--panel)] p-5 shadow-[var(--panel-shadow)]">
45
+ <h3 className="font-semibold text-[var(--muted-foreground)] text-sm uppercase tracking-[0.18em]">
46
+ Available tools
47
+ </h3>
48
+ {uniqueTools.length === 0 ? (
49
+ <p className="mt-4 text-[var(--muted-foreground)] text-sm">
50
+ No dynamic tool definitions were recorded for this thread.
51
+ </p>
52
+ ) : (
53
+ <div className="mt-4 grid gap-3 xl:grid-cols-2">
54
+ {uniqueTools.map(([toolKey, tool]) => (
55
+ <article
56
+ key={toolKey}
57
+ className="rounded-xl border border-[var(--border)] bg-[var(--panel-secondary)] p-3.5"
58
+ >
59
+ <div className="flex flex-wrap items-center gap-2">
60
+ <p className="font-medium font-mono text-sm">{tool.name}</p>
61
+ {tool.namespace ? <Badge variant="outline">{tool.namespace}</Badge> : null}
62
+ {tool.deferLoading ? <Badge variant="outline">deferred</Badge> : null}
63
+ </div>
64
+ <p className="mt-1.5 text-[var(--muted-foreground)] text-sm">
65
+ {tool.description || 'No description.'}
66
+ </p>
67
+ {tool.inputSchema ? (
68
+ <details className="mt-3 text-xs">
69
+ <summary className="cursor-pointer text-[var(--muted-foreground)]">
70
+ Input schema
71
+ </summary>
72
+ <pre className="mt-2 overflow-x-auto rounded-lg bg-[var(--code-background)] p-3 text-[var(--code-foreground)] leading-5">
73
+ {JSON.stringify(tool.inputSchema, null, 2)}
74
+ </pre>
75
+ </details>
76
+ ) : null}
77
+ </article>
78
+ ))}
79
+ </div>
80
+ )}
81
+ </section>
82
+ );
83
+ };
84
+
85
+ export function ThreadToolsPanel({
86
+ assistantModel,
87
+ availableTools,
88
+ events,
89
+ loadingTranscript = false,
90
+ projectPath,
91
+ showRawJson,
92
+ sortOrder,
93
+ transcriptIsPartial = false,
94
+ transcriptState,
95
+ onLoadTranscript,
96
+ onSortOrderChange,
97
+ }: ThreadToolsPanelProps) {
98
+ const toolEvents = events ? getToolActivityEvents(events) : [];
99
+ const canLoadMore = transcriptState === 'deferred' || transcriptIsPartial;
100
+
101
+ return (
102
+ <div className="space-y-4">
103
+ <ToolDefinitions tools={availableTools} />
104
+ <section className="space-y-3 rounded-[1.6rem] border border-[var(--border)] bg-[var(--panel)] p-5 shadow-[var(--panel-shadow)]">
105
+ <div className="flex flex-wrap items-center justify-between gap-3">
106
+ <h3 className="font-semibold text-[var(--muted-foreground)] text-sm uppercase tracking-[0.18em]">
107
+ Tool activity
108
+ </h3>
109
+ {canLoadMore && onLoadTranscript ? (
110
+ <Button disabled={loadingTranscript} size="sm" variant="outline" onClick={onLoadTranscript}>
111
+ {loadingTranscript ? 'Loading tool activity...' : 'Load tool activity'}
112
+ </Button>
113
+ ) : null}
114
+ </div>
115
+ {transcriptState === 'missing' ? (
116
+ <p className="text-[var(--muted-foreground)] text-sm">
117
+ The rollout file is missing, so recorded tool activity is unavailable.
118
+ </p>
119
+ ) : toolEvents.length === 0 ? (
120
+ <p className="text-[var(--muted-foreground)] text-sm">
121
+ {canLoadMore
122
+ ? 'The current transcript preview has no tool activity. Load the full thread to inspect every call.'
123
+ : 'No tool calls, outputs, or web searches were recorded for this thread.'}
124
+ </p>
125
+ ) : (
126
+ <TranscriptView
127
+ assistantModel={assistantModel}
128
+ events={toolEvents}
129
+ projectPath={projectPath}
130
+ showCommentary={false}
131
+ showExtraEvents
132
+ showRawJson={showRawJson}
133
+ showToolCalls
134
+ showUserMessages={false}
135
+ sortOrder={sortOrder}
136
+ onSortOrderChange={onSortOrderChange}
137
+ />
138
+ )}
139
+ </section>
140
+ </div>
141
+ );
142
+ }
@@ -2,8 +2,10 @@ 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
+ import { useMemo } from 'react';
6
7
  import { DataTable } from '#/components/data-table';
8
+ import { SelectionActionsToolbar } from '#/components/selection-actions-toolbar';
7
9
  import { Button } from '#/components/ui/button';
8
10
  import {
9
11
  DropdownMenu,
@@ -102,6 +104,7 @@ const columns = (
102
104
  <DropdownMenu>
103
105
  <DropdownMenuTrigger asChild>
104
106
  <Button
107
+ aria-label={`Actions for ${info.row.original.thread.title}`}
105
108
  className="rounded-full"
106
109
  size="icon"
107
110
  type="button"
@@ -139,62 +142,25 @@ export function ThreadsTable({
139
142
  onExportThread,
140
143
  onExportThreads,
141
144
  }: ThreadsTableProps) {
145
+ const memoizedColumns = useMemo(() => columns(onDeleteThread, onExportThread), [onDeleteThread, onExportThread]);
142
146
  return (
143
147
  <DataTable
144
- columns={columns(onDeleteThread, onExportThread)}
148
+ columns={memoizedColumns}
145
149
  data={threads}
146
150
  emptyMessage="No threads match the current project filter."
147
151
  enableRowSelection
148
152
  getRowId={(row) => row.thread.id}
149
153
  initialSorting={defaultSorting}
150
154
  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
155
  const selectedThreadIds = selectedRows.map((row) => row.thread.id);
160
156
  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>
157
+ <SelectionActionsToolbar
158
+ clearSelection={clearSelection}
159
+ itemLabel="thread"
160
+ selectedCount={selectedRows.length}
161
+ onDeleteSelected={() => onDeleteThreads(selectedThreadIds)}
162
+ onExportSelected={() => onExportThreads(selectedThreadIds)}
163
+ />
198
164
  );
199
165
  }}
200
166
  />