spiracha 2.1.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 (125) hide show
  1. package/AGENTS.md +27 -12
  2. package/README.md +37 -19
  3. package/apps/ui/AGENTS.md +18 -10
  4. package/apps/ui/README.md +17 -10
  5. package/apps/ui/src/components/analytics-breakdowns.tsx +106 -0
  6. package/apps/ui/src/components/antigravity-conversations-table.tsx +1 -0
  7. package/apps/ui/src/components/app-shell.tsx +45 -3
  8. package/apps/ui/src/components/data-table.tsx +17 -1
  9. package/apps/ui/src/components/delete-confirm-dialog.tsx +4 -3
  10. package/apps/ui/src/components/export-dialog.tsx +77 -62
  11. package/apps/ui/src/components/projects-table.tsx +4 -1
  12. package/apps/ui/src/components/route-error-panel.tsx +13 -0
  13. package/apps/ui/src/components/thread-goals-panel.tsx +50 -0
  14. package/apps/ui/src/components/thread-tools-panel.tsx +142 -0
  15. package/apps/ui/src/components/threads-table.tsx +4 -1
  16. package/apps/ui/src/lib/antigravity-server.ts +70 -17
  17. package/apps/ui/src/lib/antigravity-transcript-events.ts +46 -70
  18. package/apps/ui/src/lib/claude-code-server.ts +14 -4
  19. package/apps/ui/src/lib/codex-queries.ts +3 -3
  20. package/apps/ui/src/lib/codex-server.ts +3 -3
  21. package/apps/ui/src/lib/cursor-server.ts +41 -69
  22. package/apps/ui/src/lib/cursor-transcript-events.ts +11 -86
  23. package/apps/ui/src/lib/delete-batch.ts +52 -0
  24. package/apps/ui/src/lib/error-presentation.ts +43 -0
  25. package/apps/ui/src/lib/export-mutation.ts +20 -0
  26. package/apps/ui/src/lib/export-options.ts +15 -0
  27. package/apps/ui/src/lib/grok-server.ts +2 -3
  28. package/apps/ui/src/lib/kiro-server.ts +14 -2
  29. package/apps/ui/src/lib/mutation-error.ts +15 -0
  30. package/apps/ui/src/lib/opencode-server.ts +13 -2
  31. package/apps/ui/src/lib/path-utils.ts +1 -1
  32. package/apps/ui/src/lib/qoder-queries.ts +15 -0
  33. package/apps/ui/src/lib/qoder-transcript-events.ts +1 -1
  34. package/apps/ui/src/lib/route-state-reset.tsx +10 -0
  35. package/apps/ui/src/lib/settings-server.ts +33 -0
  36. package/apps/ui/src/lib/settings-store.tsx +82 -38
  37. package/apps/ui/src/lib/settings.ts +65 -0
  38. package/apps/ui/src/lib/source-session-export-server.ts +1 -1
  39. package/apps/ui/src/lib/thread-metadata.ts +22 -0
  40. package/apps/ui/src/lib/thread-transcript-stats.ts +10 -1
  41. package/apps/ui/src/lib/workspace-delete-navigation.ts +8 -0
  42. package/apps/ui/src/routeTree.gen.ts +282 -282
  43. package/apps/ui/src/routes/__root.tsx +12 -15
  44. package/apps/ui/src/routes/analytics.tsx +10 -61
  45. package/apps/ui/src/routes/antigravity-conversations.$conversationId.tsx +73 -19
  46. package/apps/ui/src/routes/antigravity.$workspaceKey.tsx +42 -60
  47. package/apps/ui/src/routes/antigravity.index.tsx +2 -2
  48. package/apps/ui/src/routes/claude-code-sessions.$sessionId.tsx +25 -11
  49. package/apps/ui/src/routes/claude-code.$workspaceKey.tsx +27 -32
  50. package/apps/ui/src/routes/claude-code.index.tsx +2 -2
  51. package/apps/ui/src/routes/codex.$project.tsx +33 -31
  52. package/apps/ui/src/routes/codex.index.tsx +5 -10
  53. package/apps/ui/src/routes/cursor-threads.$composerId.tsx +29 -16
  54. package/apps/ui/src/routes/cursor.$workspaceKey.tsx +33 -27
  55. package/apps/ui/src/routes/cursor.index.tsx +5 -2
  56. package/apps/ui/src/routes/grok-sessions.$sessionId.tsx +25 -12
  57. package/apps/ui/src/routes/grok.$workspaceKey.tsx +20 -26
  58. package/apps/ui/src/routes/grok.index.tsx +2 -2
  59. package/apps/ui/src/routes/index.tsx +2 -18
  60. package/apps/ui/src/routes/kiro-sessions.$sessionId.tsx +25 -12
  61. package/apps/ui/src/routes/kiro.$workspaceKey.tsx +27 -32
  62. package/apps/ui/src/routes/kiro.index.tsx +2 -2
  63. package/apps/ui/src/routes/opencode-sessions.$sessionId.tsx +29 -36
  64. package/apps/ui/src/routes/opencode.$workspaceKey.tsx +45 -61
  65. package/apps/ui/src/routes/opencode.index.tsx +8 -17
  66. package/apps/ui/src/routes/qoder-sessions.$sessionId.tsx +12 -11
  67. package/apps/ui/src/routes/qoder.$workspaceKey.tsx +19 -25
  68. package/apps/ui/src/routes/qoder.index.tsx +2 -2
  69. package/apps/ui/src/routes/threads.$threadId.tsx +87 -56
  70. package/apps/ui/vite.config.ts +22 -12
  71. package/bin/spiracha.ts +28 -1
  72. package/package.json +26 -17
  73. package/src/client.ts +61 -16
  74. package/src/lib/antigravity-db.ts +236 -82
  75. package/src/lib/antigravity-keychain.ts +7 -8
  76. package/src/lib/antigravity-transcript-phase.ts +47 -0
  77. package/src/lib/claude-code-db.ts +45 -19
  78. package/src/lib/codex-analytics.ts +32 -5
  79. package/src/lib/codex-browser-db.ts +540 -137
  80. package/src/lib/codex-browser-export.ts +2 -7
  81. package/src/lib/codex-browser-types.ts +21 -1
  82. package/src/lib/codex-thread-cache.ts +88 -33
  83. package/src/lib/codex-thread-parser.ts +78 -35
  84. package/src/lib/codex-thread-recovery.ts +38 -11
  85. package/src/lib/codex-transcript-renderer.ts +49 -61
  86. package/src/lib/concurrency.ts +15 -3
  87. package/src/lib/conversation-api.ts +34 -31
  88. package/src/lib/conversation-data/adapter-helpers.ts +25 -3
  89. package/src/lib/conversation-data/antigravity-adapter.ts +54 -29
  90. package/src/lib/conversation-data/claude-code-adapter.ts +32 -6
  91. package/src/lib/conversation-data/codex-adapter.ts +91 -80
  92. package/src/lib/conversation-data/cursor-adapter.ts +48 -34
  93. package/src/lib/conversation-data/grok-adapter.ts +19 -6
  94. package/src/lib/conversation-data/index.ts +29 -10
  95. package/src/lib/conversation-data/kiro-adapter.ts +18 -5
  96. package/src/lib/conversation-data/message-selector.ts +2 -5
  97. package/src/lib/conversation-data/opencode-adapter.ts +43 -15
  98. package/src/lib/conversation-data/path-match.ts +1 -13
  99. package/src/lib/conversation-data/qoder-adapter.ts +23 -18
  100. package/src/lib/conversation-zip-export.ts +20 -2
  101. package/src/lib/cursor-db.ts +34 -23
  102. package/src/lib/cursor-id.ts +17 -0
  103. package/src/lib/cursor-recovery.ts +146 -35
  104. package/src/lib/cursor-transcript-phase.ts +40 -0
  105. package/src/lib/cursor-transcript.ts +21 -5
  106. package/src/lib/grok-db.ts +76 -34
  107. package/src/lib/grok-transcript-phase.ts +11 -2
  108. package/src/lib/grok-transcript.ts +15 -2
  109. package/src/lib/kiro-db.ts +161 -68
  110. package/src/lib/kiro-transcript.ts +0 -4
  111. package/src/lib/opencode-db.ts +4 -8
  112. package/src/lib/opencode-think-tags.ts +17 -3
  113. package/src/lib/portable-path.ts +9 -0
  114. package/src/lib/qoder-acp-client.ts +11 -2
  115. package/src/lib/qoder-db.ts +51 -17
  116. package/src/lib/qoder-transcript-phase.ts +64 -0
  117. package/src/lib/qoder-transcript.ts +30 -2
  118. package/src/lib/shared.ts +51 -15
  119. package/src/lib/sqlite-error.ts +31 -5
  120. package/src/lib/transcript-load-limiter.ts +8 -3
  121. package/src/lib/ui-cache.ts +152 -17
  122. package/src/lib/ui-export-archive.ts +14 -55
  123. package/src/lib/ui-export-files.ts +75 -15
  124. package/src/lib/ui-export-zip.ts +40 -0
  125. package/apps/ui/package.json +0 -65
@@ -1,7 +1,6 @@
1
1
  import { useEffect, useId, useState } from 'react';
2
2
  import {
3
3
  AlertDialog,
4
- AlertDialogAction,
5
4
  AlertDialogCancel,
6
5
  AlertDialogContent,
7
6
  AlertDialogDescription,
@@ -9,6 +8,7 @@ import {
9
8
  AlertDialogHeader,
10
9
  AlertDialogTitle,
11
10
  } from '#/components/ui/alert-dialog';
11
+ import { Button } from '#/components/ui/button';
12
12
  import { Checkbox } from '#/components/ui/checkbox';
13
13
 
14
14
  type DeleteConfirmDialogProps = {
@@ -75,12 +75,13 @@ export function DeleteConfirmDialog({
75
75
  {errorMessage ? <p className="text-[var(--destructive)] text-sm">{errorMessage}</p> : null}
76
76
  <AlertDialogFooter>
77
77
  <AlertDialogCancel className="border-[var(--border)]">Cancel</AlertDialogCancel>
78
- <AlertDialogAction
78
+ <Button
79
79
  className="bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:bg-[var(--destructive)]/90"
80
+ type="button"
80
81
  onClick={() => onConfirm({ deleteSessionFiles })}
81
82
  >
82
83
  {confirmLabel}
83
- </AlertDialogAction>
84
+ </Button>
84
85
  </AlertDialogFooter>
85
86
  </AlertDialogContent>
86
87
  </AlertDialog>
@@ -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)]"
@@ -88,8 +111,8 @@ export function ExportDialog({
88
111
  <div className="flex items-center gap-3 rounded-2xl border border-[var(--border)] bg-[var(--panel-secondary)] p-3">
89
112
  <Checkbox
90
113
  aria-label="Include metadata"
91
- checked={includeMetadata}
92
- onCheckedChange={(checked) => setIncludeMetadata(checked === true)}
114
+ checked={options.includeMetadata}
115
+ onCheckedChange={(checked) => updateOption('includeMetadata', checked === true)}
93
116
  />
94
117
  <span className="space-y-1">
95
118
  <span className="block font-medium text-sm">Include metadata</span>
@@ -99,33 +122,37 @@ export function ExportDialog({
99
122
  </span>
100
123
  </div>
101
124
 
102
- <div className="flex items-center gap-3 rounded-2xl border border-[var(--border)] bg-[var(--panel-secondary)] p-3">
103
- <Checkbox
104
- aria-label="Include commentary"
105
- checked={includeCommentary}
106
- onCheckedChange={(checked) => setIncludeCommentary(checked === true)}
107
- />
108
- <span className="space-y-1">
109
- <span className="block font-medium text-sm">Include commentary</span>
110
- <span className="block text-[var(--muted-foreground)] text-sm">
111
- 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>
112
137
  </span>
113
- </span>
114
- </div>
138
+ </div>
139
+ ) : null}
115
140
 
116
- <div className="flex items-center gap-3 rounded-2xl border border-[var(--border)] bg-[var(--panel-secondary)] p-3">
117
- <Checkbox
118
- aria-label="Include tool calls"
119
- checked={includeTools}
120
- onCheckedChange={(checked) => setIncludeTools(checked === true)}
121
- />
122
- <span className="space-y-1">
123
- <span className="block font-medium text-sm">Include tool calls</span>
124
- <span className="block text-[var(--muted-foreground)] text-sm">
125
- 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>
126
153
  </span>
127
- </span>
128
- </div>
154
+ </div>
155
+ ) : null}
129
156
 
130
157
  <div className="flex items-center gap-3 rounded-2xl border border-[var(--border)] bg-[var(--panel-secondary)] p-3">
131
158
  <Checkbox
@@ -133,7 +160,7 @@ export function ExportDialog({
133
160
  aria-describedby={zipDescriptionId}
134
161
  checked={effectiveZipArchive}
135
162
  disabled={forceZipArchive}
136
- onCheckedChange={(checked) => setZipArchive(checked === true)}
163
+ onCheckedChange={(checked) => updateOption('zipArchive', checked === true)}
137
164
  />
138
165
  <span className="space-y-1">
139
166
  <span className="block font-medium text-sm">Zip archive</span>
@@ -152,19 +179,7 @@ export function ExportDialog({
152
179
  <Button className="rounded-full" variant="outline" onClick={() => onOpenChange(false)}>
153
180
  Cancel
154
181
  </Button>
155
- <Button
156
- className="rounded-full"
157
- disabled={pending || disabled}
158
- onClick={() =>
159
- onExport({
160
- includeCommentary,
161
- includeMetadata,
162
- includeTools,
163
- outputFormat,
164
- zipArchive: effectiveZipArchive,
165
- })
166
- }
167
- >
182
+ <Button className="rounded-full" disabled={pending || disabled || submitted} onClick={submitExport}>
168
183
  {pending ? 'Exporting...' : 'Download export'}
169
184
  </Button>
170
185
  </DialogFooter>
@@ -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' }]}
@@ -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,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
+ }
@@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router';
3
3
  import type { SortingState } from '@tanstack/react-table';
4
4
  import { createColumnHelper } from '@tanstack/react-table';
5
5
  import { Download, MoreHorizontal, Trash2 } from 'lucide-react';
6
+ import { useMemo } from 'react';
6
7
  import { DataTable } from '#/components/data-table';
7
8
  import { SelectionActionsToolbar } from '#/components/selection-actions-toolbar';
8
9
  import { Button } from '#/components/ui/button';
@@ -103,6 +104,7 @@ const columns = (
103
104
  <DropdownMenu>
104
105
  <DropdownMenuTrigger asChild>
105
106
  <Button
107
+ aria-label={`Actions for ${info.row.original.thread.title}`}
106
108
  className="rounded-full"
107
109
  size="icon"
108
110
  type="button"
@@ -140,9 +142,10 @@ export function ThreadsTable({
140
142
  onExportThread,
141
143
  onExportThreads,
142
144
  }: ThreadsTableProps) {
145
+ const memoizedColumns = useMemo(() => columns(onDeleteThread, onExportThread), [onDeleteThread, onExportThread]);
143
146
  return (
144
147
  <DataTable
145
- columns={columns(onDeleteThread, onExportThread)}
148
+ columns={memoizedColumns}
146
149
  data={threads}
147
150
  emptyMessage="No threads match the current project filter."
148
151
  enableRowSelection