groove-dev 0.27.197 → 0.27.199

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 (76) hide show
  1. package/axom-integration/Screenshot_2026-07-25_at_5.25.30_PM.png +0 -0
  2. package/axom-integration/Screenshot_2026-07-25_at_5.33.26_PM.png +0 -0
  3. package/axom-integration/Screenshot_2026-07-25_at_6.11.53_PM.png +0 -0
  4. package/axom-integration/Screenshot_2026-07-25_at_6.44.56_PM.png +0 -0
  5. package/axom-integration/Screenshot_2026-07-25_at_6.45.35_PM.png +0 -0
  6. package/node_modules/@groove-dev/cli/package.json +1 -1
  7. package/node_modules/@groove-dev/daemon/package.json +1 -1
  8. package/node_modules/@groove-dev/daemon/src/api.js +2 -0
  9. package/node_modules/@groove-dev/daemon/src/axom-connector.js +368 -0
  10. package/node_modules/@groove-dev/daemon/src/axom-install.js +162 -0
  11. package/node_modules/@groove-dev/daemon/src/axom-remote.js +230 -0
  12. package/node_modules/@groove-dev/daemon/src/axom-server.js +249 -0
  13. package/node_modules/@groove-dev/daemon/src/federation.js +6 -0
  14. package/node_modules/@groove-dev/daemon/src/index.js +14 -0
  15. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +8 -1
  16. package/node_modules/@groove-dev/daemon/src/innerchat-relay.js +89 -0
  17. package/node_modules/@groove-dev/daemon/src/innerchat.js +261 -1
  18. package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
  19. package/node_modules/@groove-dev/daemon/src/network-guard.js +56 -0
  20. package/node_modules/@groove-dev/daemon/src/process.js +1 -1
  21. package/node_modules/@groove-dev/daemon/src/providers/axom.js +66 -0
  22. package/node_modules/@groove-dev/daemon/src/providers/index.js +2 -0
  23. package/node_modules/@groove-dev/daemon/src/routes/axom.js +267 -0
  24. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +334 -20
  25. package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +455 -0
  26. package/node_modules/@groove-dev/daemon/test/axom-remote.test.js +115 -0
  27. package/node_modules/@groove-dev/daemon/test/axom-server.test.js +228 -0
  28. package/node_modules/@groove-dev/daemon/test/innerchat-relay.test.js +251 -0
  29. package/node_modules/@groove-dev/gui/dist/assets/index-BbA-X4CE.js +1084 -0
  30. package/node_modules/@groove-dev/gui/dist/assets/index-BbE3qX83.css +1 -0
  31. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  32. package/node_modules/@groove-dev/gui/package.json +1 -1
  33. package/node_modules/@groove-dev/gui/src/App.jsx +2 -0
  34. package/node_modules/@groove-dev/gui/src/app.css +53 -0
  35. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +3 -1
  36. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +2 -1
  37. package/node_modules/@groove-dev/gui/src/stores/groove.js +22 -0
  38. package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +279 -0
  39. package/node_modules/@groove-dev/gui/src/views/axom.jsx +1897 -0
  40. package/node_modules/@groove-dev/gui/src/views/settings.jsx +232 -4
  41. package/package.json +2 -2
  42. package/packages/cli/package.json +1 -1
  43. package/packages/daemon/package.json +1 -1
  44. package/packages/daemon/src/api.js +2 -0
  45. package/packages/daemon/src/axom-connector.js +368 -0
  46. package/packages/daemon/src/axom-install.js +162 -0
  47. package/packages/daemon/src/axom-remote.js +230 -0
  48. package/packages/daemon/src/axom-server.js +249 -0
  49. package/packages/daemon/src/federation.js +6 -0
  50. package/packages/daemon/src/index.js +14 -0
  51. package/packages/daemon/src/innerchat-docs.js +8 -1
  52. package/packages/daemon/src/innerchat-relay.js +89 -0
  53. package/packages/daemon/src/innerchat.js +261 -1
  54. package/packages/daemon/src/introducer.js +1 -1
  55. package/packages/daemon/src/network-guard.js +56 -0
  56. package/packages/daemon/src/process.js +1 -1
  57. package/packages/daemon/src/providers/axom.js +66 -0
  58. package/packages/daemon/src/providers/index.js +2 -0
  59. package/packages/daemon/src/routes/axom.js +267 -0
  60. package/packages/daemon/src/routes/innerchat.js +334 -20
  61. package/packages/gui/dist/assets/index-BbA-X4CE.js +1084 -0
  62. package/packages/gui/dist/assets/index-BbE3qX83.css +1 -0
  63. package/packages/gui/dist/index.html +2 -2
  64. package/packages/gui/package.json +1 -1
  65. package/packages/gui/src/App.jsx +2 -0
  66. package/packages/gui/src/app.css +53 -0
  67. package/packages/gui/src/components/agents/agent-feed.jsx +3 -1
  68. package/packages/gui/src/components/layout/activity-bar.jsx +2 -1
  69. package/packages/gui/src/stores/groove.js +22 -0
  70. package/packages/gui/src/stores/slices/axom-slice.js +279 -0
  71. package/packages/gui/src/views/axom.jsx +1897 -0
  72. package/packages/gui/src/views/settings.jsx +232 -4
  73. package/node_modules/@groove-dev/gui/dist/assets/index-Da2nbd6M.js +0 -1043
  74. package/node_modules/@groove-dev/gui/dist/assets/index-zmrIbwNm.css +0 -1
  75. package/packages/gui/dist/assets/index-Da2nbd6M.js +0 -1043
  76. package/packages/gui/dist/assets/index-zmrIbwNm.css +0 -1
@@ -0,0 +1,1897 @@
1
+ // GROOVE GUI v2 — Axom View
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+ //
4
+ // The Axom provider tab (M2: shell + onboarding + raw ticker + hot input).
5
+ // House laws from the integration contract: every rendered claim traces to an
6
+ // event; interrupt feedback ("⚡ heard") comes from the `interrupt` EVENT on
7
+ // the stream, never from our own POST succeeding; the stop button's pressed
8
+ // state releases on `pipeline_done` even without `stop_effected` and says
9
+ // "resolved before stop" (§7 — a stuck pressed button is a small
10
+ // fail-deceptive).
11
+
12
+ import { useEffect, useMemo, useRef, useState } from 'react';
13
+ import { Atom, Zap, OctagonX, Plug, Radio, MessageSquareQuote, Shirt, TriangleAlert, Trophy, Download, Square, Play, Plus, MemoryStick, HardDrive, Cpu, Gauge, CheckCircle2, Globe, Copy, Settings2, ArrowLeft, Loader2, ChevronDown, SendHorizontal, X, Wrench, Brain, Power, Unplug } from 'lucide-react';
14
+ import { motion } from 'framer-motion';
15
+ import { useGrooveStore } from '../stores/groove';
16
+ import { axomSessionKey } from '../stores/slices/axom-slice';
17
+ import { Button } from '../components/ui/button';
18
+ import { Input } from '../components/ui/input';
19
+ import { Badge } from '../components/ui/badge';
20
+ import { StatusDot } from '../components/ui/status-dot';
21
+ import { ThinkingIndicator } from '../components/ui/thinking-indicator';
22
+ // The Axom conversation renders agent prose with the fleet chat's renderer —
23
+ // one look for agent output across the app, not a parallel implementation.
24
+ import { StructuredMessage } from '../components/agents/agent-feed';
25
+ import { cn } from '../lib/cn';
26
+
27
+ // ── Onboarding — the front door ─────────────────────────────────────────────
28
+
29
+ function formatBytes(n) {
30
+ if (!n) return '0 MB';
31
+ return n > 1e9 ? `${(n / 1e9).toFixed(2)} GB` : `${Math.round(n / 1e6)} MB`;
32
+ }
33
+
34
+ // Stat tile per the house dataviz spec: muted label, mono numeral, optional
35
+ // sub-line and severity meter (fill carries state; track is a step of the
36
+ // same surface ramp). Value color follows measurement, never optimism — and
37
+ // state never rides color alone (the verdict pill carries icon + words).
38
+ // `unit` is split off the numeral so the number reads first at a glance.
39
+ function HardwareStat({ icon: Icon, label, value, unit, sub, tone = 'text-text-0', meter }) {
40
+ return (
41
+ <div className="relative rounded-lg bg-surface-1 border border-border-subtle px-3.5 py-3 flex flex-col gap-1.5 min-w-0">
42
+ <span className="flex items-center gap-1.5 text-2xs uppercase tracking-[0.12em] text-text-4 font-semibold">
43
+ <Icon size={11} strokeWidth={1.75} /> {label}
44
+ </span>
45
+ <span className="flex items-baseline gap-1 min-w-0">
46
+ <span className={cn('font-mono text-xl font-semibold leading-none tabular-nums truncate', tone)}>{value}</span>
47
+ {unit && <span className="text-xs text-text-3 font-medium flex-shrink-0">{unit}</span>}
48
+ </span>
49
+ {sub && <span className="text-2xs text-text-4 truncate">{sub}</span>}
50
+ {meter && (
51
+ <div className="h-[3px] mt-0.5 rounded-full bg-surface-4 overflow-hidden">
52
+ <div
53
+ className={cn('h-full rounded-full transition-[width] duration-700 ease-out', meter.tone)}
54
+ style={{ width: `${meter.pct}%` }}
55
+ />
56
+ </div>
57
+ )}
58
+ </div>
59
+ );
60
+ }
61
+
62
+ const VERDICT_COPY = {
63
+ ready: {
64
+ icon: CheckCircle2,
65
+ pill: 'Ready for local Axom',
66
+ tone: 'text-success border-success/30 bg-success/10',
67
+ meterTone: 'bg-success',
68
+ detail: 'Meets the recommended spec — expect a comfortable ride.',
69
+ },
70
+ marginal: {
71
+ icon: Gauge,
72
+ pill: 'Meets the minimum',
73
+ tone: 'text-warning border-warning/30 bg-warning/10',
74
+ meterTone: 'bg-warning',
75
+ detail: 'Local Axom will run, but expect slower turns and tight memory. Close heavy apps first.',
76
+ },
77
+ insufficient: {
78
+ icon: TriangleAlert,
79
+ pill: 'Not enough memory',
80
+ tone: 'text-danger border-danger/30 bg-danger/10',
81
+ meterTone: 'bg-danger',
82
+ detail: 'Loading the 4 GB chassis here risks freezing the whole machine — so GROOVE won’t. Connect to an Axom on a capable machine instead; same tab, same experience.',
83
+ },
84
+ };
85
+
86
+ const FEATURE_CHIPS = [
87
+ { icon: Shirt, label: 'Hot-swap skill leaves' },
88
+ { icon: Zap, label: 'Steer it mid-thought' },
89
+ { icon: Trophy, label: 'Answers that crystallize' },
90
+ { icon: HardDrive, label: 'Memory that stays yours' },
91
+ ];
92
+
93
+ // ── Connect diagnosis — a failed connect must say WHY ──────────────────────
94
+ //
95
+ // The trap this exists for: `axom serve` binds 127.0.0.1, so an endpoint
96
+ // naming any other host can NEVER be reached directly, no matter how right
97
+ // the URL looks. That one fact turns a silent dead workspace into a
98
+ // two-second fix, so it is surfaced BEFORE the attempt, not only after it.
99
+
100
+ function parseEndpoint(raw) {
101
+ const s = (raw || '').trim();
102
+ if (!s) return null;
103
+ let u;
104
+ try { u = new URL(/^[a-z]+:\/\//i.test(s) ? s : `http://${s}`); } catch { return { invalid: true }; }
105
+ const host = u.hostname;
106
+ return {
107
+ invalid: false,
108
+ host,
109
+ port: u.port || (u.protocol === 'https:' ? '443' : '80'),
110
+ local: host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '0.0.0.0',
111
+ };
112
+ }
113
+
114
+ function tunnelFor(parsed) {
115
+ return `ssh -N -L ${parsed.port}:127.0.0.1:${parsed.port} ${parsed.host}`;
116
+ }
117
+
118
+ // Plain language for what the connector reported. The runtime's own error
119
+ // text is always shown verbatim alongside this — the reading never replaces
120
+ // the evidence.
121
+ function diagnose(error, parsed) {
122
+ const e = (error || '').toLowerCase();
123
+ if (parsed && !parsed.local) {
124
+ return {
125
+ cause: `Axom binds 127.0.0.1 only, so ${parsed.host} can't be reached from this machine directly — this address cannot work without a tunnel.`,
126
+ tunnel: true,
127
+ };
128
+ }
129
+ if (e.includes('econnrefused') || e.includes('refused')) {
130
+ return { cause: 'Nothing is listening on that port — the runtime is probably not started.' };
131
+ }
132
+ if (e.includes('enotfound') || e.includes('eai_again') || e.includes('dns')) {
133
+ return { cause: "That hostname doesn't resolve from this machine." };
134
+ }
135
+ if (e.includes('timeout') || e.includes('abort')) {
136
+ return { cause: 'No answer in time — wrong host, or something between here and there is dropping it.' };
137
+ }
138
+ if (e.includes('404') || e.includes('about') || e.includes('json') || e.includes('parse')) {
139
+ return { cause: "Something answered, but it didn't serve /about — that address isn't an Axom runtime." };
140
+ }
141
+ return { cause: "The runtime didn't answer /about." };
142
+ }
143
+
144
+ // Tunnel instruction + copy, used both pre-flight and after a failure.
145
+ function TunnelHint({ parsed, onCopy }) {
146
+ const cmd = tunnelFor(parsed);
147
+ return (
148
+ <div className="flex flex-col gap-1.5">
149
+ <p className="text-xs text-text-3 leading-relaxed">
150
+ Run this on this machine first, then connect to{' '}
151
+ <code className="font-mono text-text-2">http://127.0.0.1:{parsed.port}</code> instead:
152
+ </p>
153
+ <div className="flex items-center gap-1.5">
154
+ <code className="flex-1 px-2 py-1 bg-surface-0 border border-border-subtle rounded font-mono text-2xs text-text-2 truncate select-all">
155
+ {cmd}
156
+ </code>
157
+ <button
158
+ onClick={() => onCopy(cmd)}
159
+ className="p-1 rounded text-text-4 hover:text-accent hover:bg-surface-3 transition-colors cursor-pointer"
160
+ title="Copy tunnel command"
161
+ >
162
+ <Copy size={11} />
163
+ </button>
164
+ </div>
165
+ </div>
166
+ );
167
+ }
168
+
169
+ function Onboarding({ onBack }) {
170
+ const axomStatus = useGrooveStore((s) => s.axomStatus);
171
+ const saveAxomEndpoints = useGrooveStore((s) => s.saveAxomEndpoints);
172
+ const startAxomInstall = useGrooveStore((s) => s.startAxomInstall);
173
+ const startAxomInstance = useGrooveStore((s) => s.startAxomInstance);
174
+ const fetchAxomHardware = useGrooveStore((s) => s.fetchAxomHardware);
175
+ const hw = useGrooveStore((s) => s.axomHardware);
176
+ const myEndpoint = useGrooveStore((s) => s.axomMyEndpoint);
177
+ const install = useGrooveStore((s) => s.axomInstall);
178
+ const addToast = useGrooveStore((s) => s.addToast);
179
+ const [url, setUrl] = useState('');
180
+ const [saving, setSaving] = useState(false);
181
+
182
+ useEffect(() => { fetchAxomHardware(); }, [fetchAxomHardware]);
183
+
184
+ const installing = install.phase === 'manifest' || install.phase === 'runtime' || install.phase === 'models';
185
+ const req = hw?.requirements;
186
+ const verdict = hw ? VERDICT_COPY[hw.verdict] : null;
187
+ const canInstall = hw ? hw.verdict !== 'insufficient' && hw.diskOk !== false : true;
188
+
189
+ async function installLocally() {
190
+ try {
191
+ await startAxomInstall();
192
+ } catch (err) {
193
+ addToast('error', 'Install could not start', err.message);
194
+ }
195
+ }
196
+
197
+ async function startInstance() {
198
+ try {
199
+ await startAxomInstance('default');
200
+ } catch (err) {
201
+ addToast('error', 'Instance failed to start', err.message);
202
+ }
203
+ }
204
+
205
+ async function connect() {
206
+ setSaving(true);
207
+ try {
208
+ // Replace rather than append — one configured endpoint at a time in v0,
209
+ // so re-pointing a wrong URL is a single action.
210
+ await saveAxomEndpoints([{ name: 'local', url: url.trim() }]);
211
+ onBack?.();
212
+ } catch (err) {
213
+ addToast('error', 'Could not save endpoint', err.message);
214
+ } finally {
215
+ setSaving(false);
216
+ }
217
+ }
218
+
219
+ // Remove the configured endpoint entirely — the way out of a dead one.
220
+ async function disconnect() {
221
+ setSaving(true);
222
+ try {
223
+ await saveAxomEndpoints([]);
224
+ addToast('success', 'Endpoint removed');
225
+ } catch (err) {
226
+ addToast('error', 'Could not remove endpoint', err.message);
227
+ } finally {
228
+ setSaving(false);
229
+ }
230
+ }
231
+
232
+ const configured = axomStatus?.endpoints?.[0] || null;
233
+
234
+ function copyText(value) {
235
+ navigator.clipboard.writeText(value);
236
+ addToast('success', 'Copied');
237
+ }
238
+
239
+ // What the user is typing, read for the bind trap before they commit to it.
240
+ const typed = useMemo(() => parseEndpoint(url), [url]);
241
+ const configuredParsed = useMemo(() => parseEndpoint(configured?.url), [configured?.url]);
242
+ const failure = configured?.status === 'error' ? diagnose(configured.error, configuredParsed) : null;
243
+
244
+ const insufficient = hw?.verdict === 'insufficient';
245
+ // Distribution availability is data, not an error. `available === false`
246
+ // means this build has no local-install path yet — the card says so quietly
247
+ // and says nothing else. Undefined means we haven't heard back, so the
248
+ // button waits rather than flashing an action that may not exist.
249
+ const gated = install.available === false;
250
+ const checkingAvailability = install.available === undefined;
251
+ // Coming-soon outranks the RAM floor: telling someone their machine is too
252
+ // small for software they can't obtain is the worst of both messages.
253
+ const connectFirst = gated || insufficient;
254
+ const ramPct = hw && req ? Math.min(100, (hw.totalRamGb / req.recommendedRamGb) * 100) : 0;
255
+
256
+ return (
257
+ <div className="h-full axom-hero-bg overflow-y-auto lg:overflow-hidden relative">
258
+ {/* Currently-configured endpoint: state + the way back / the way out */}
259
+ {(onBack || configured) && (
260
+ <div className="absolute top-0 inset-x-0 z-10 flex items-center gap-3 px-6 xl:px-12 py-2.5 border-b border-border-subtle bg-surface-1/70 backdrop-blur">
261
+ {onBack && (
262
+ <button onClick={onBack} className="flex items-center gap-1.5 text-xs text-text-3 hover:text-accent cursor-pointer transition-colors">
263
+ <ArrowLeft size={13} /> Back to workspace
264
+ </button>
265
+ )}
266
+ {configured && (
267
+ <div className="ml-auto flex items-center gap-2 min-w-0">
268
+ <StatusDot status={configured.status === 'connected' ? 'running' : configured.status === 'error' ? 'crashed' : 'starting'} size="sm" />
269
+ <span className="text-xs text-text-3 font-mono truncate max-w-[22rem]" title={configured.error || configured.url}>
270
+ {configured.url}
271
+ </span>
272
+ {configured.status === 'error' && (
273
+ <span className="text-xs text-danger truncate">{configured.error || 'unreachable'}</span>
274
+ )}
275
+ <Button size="sm" variant="ghost" onClick={disconnect} disabled={saving}>Disconnect</Button>
276
+ </div>
277
+ )}
278
+ </div>
279
+ )}
280
+ <div className={cn(
281
+ 'min-h-full lg:h-full w-full max-w-[1600px] mx-auto px-6 xl:px-12 py-6 grid grid-cols-1 lg:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)] gap-8 xl:gap-14 items-center',
282
+ (onBack || configured) && 'pt-16',
283
+ )}>
284
+
285
+ {/* ── Identity pane — who Axom is, stated once ────────────────── */}
286
+ <motion.div
287
+ initial={{ opacity: 0, x: -14 }}
288
+ animate={{ opacity: 1, x: 0 }}
289
+ transition={{ duration: 0.5, ease: 'easeOut' }}
290
+ className="flex flex-col gap-6 min-w-0"
291
+ >
292
+ <div className="flex items-center gap-5 min-w-0">
293
+ {/* Orbiting mark */}
294
+ <div className="relative h-24 w-24 flex-shrink-0 flex items-center justify-center">
295
+ <span aria-hidden className="absolute h-20 w-20 rounded-full bg-accent/20 blur-2xl animate-axom-breathe" />
296
+ <span aria-hidden className="absolute inset-0 animate-axom-orbit">
297
+ <span className="axom-ring absolute inset-0 scale-y-[0.42]" />
298
+ </span>
299
+ <span aria-hidden className="absolute inset-1 animate-axom-orbit-slow">
300
+ <span className="axom-ring absolute inset-0 scale-x-[0.42]" />
301
+ </span>
302
+ <span aria-hidden className="absolute inset-2 rounded-full border border-accent/10" />
303
+ <Atom size={46} strokeWidth={0.9} className="relative text-accent" />
304
+ </div>
305
+ <div className="flex flex-col gap-2 min-w-0">
306
+ <span className="inline-flex items-center gap-2 text-2xs uppercase tracking-[0.22em] text-text-4 font-semibold">
307
+ Sovereign runtime
308
+ <span aria-hidden className="h-px w-8 axom-edge-muted" />
309
+ </span>
310
+ <h2 className="axom-display text-[2.75rem] xl:text-5xl font-semibold tracking-[-0.025em] leading-[1.05]">
311
+ Your own Axom
312
+ </h2>
313
+ </div>
314
+ </div>
315
+
316
+ <p className="text-[0.9375rem] text-text-2 leading-relaxed max-w-xl">
317
+ A sovereign AI runtime that lives on your hardware, remembers per
318
+ project, and shows its work — every claim on screen traces to a
319
+ real event underneath.
320
+ </p>
321
+
322
+ {/* Capability ledger — one law per row, hairline separated */}
323
+ <div className="max-w-xl rounded-lg border border-border-subtle bg-surface-1/50 divide-y divide-border-subtle overflow-hidden">
324
+ {FEATURE_CHIPS.map(({ icon: Icon, label }) => (
325
+ <span key={label} className="flex items-center gap-3 px-4 py-2.5">
326
+ <span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded border border-accent/20 bg-accent/10 text-accent">
327
+ <Icon size={12} strokeWidth={1.75} />
328
+ </span>
329
+ <span className="text-xs text-text-2 leading-snug">{label}</span>
330
+ </span>
331
+ ))}
332
+ </div>
333
+
334
+ <p className="text-xs text-text-4 leading-relaxed max-w-md">
335
+ Axom speaks an open provider protocol — local today, your rack tomorrow, the mesh after that. One socket, every tier.
336
+ </p>
337
+ </motion.div>
338
+
339
+ {/* ── Operations pane — what this machine can do about it ──────── */}
340
+ <div className="flex flex-col gap-4 min-w-0">
341
+
342
+ {/* ── This machine — readiness, verdict integrated ─────────────── */}
343
+ {hw && (
344
+ <motion.section
345
+ initial={{ opacity: 0, y: 10 }}
346
+ animate={{ opacity: 1, y: 0 }}
347
+ transition={{ duration: 0.4, delay: 0.1, ease: 'easeOut' }}
348
+ className="relative rounded-xl axom-panel border border-border overflow-hidden"
349
+ >
350
+ <span aria-hidden className="absolute inset-x-0 top-0 h-px axom-edge-muted" />
351
+ <div className="px-5 py-4 flex flex-col gap-4">
352
+ <div className="flex items-center justify-between gap-3 flex-wrap">
353
+ <span className="flex items-center gap-2 text-2xs uppercase tracking-[0.16em] text-text-3 font-semibold">
354
+ <Gauge size={12} strokeWidth={1.75} className="text-text-4" />
355
+ This machine
356
+ </span>
357
+ {verdict && (
358
+ <span className={cn('inline-flex items-center gap-1.5 px-3 py-1 rounded-full border text-xs font-semibold', verdict.tone)}>
359
+ <verdict.icon size={13} strokeWidth={2} /> {verdict.pill}
360
+ </span>
361
+ )}
362
+ </div>
363
+
364
+ <div className="grid grid-cols-2 xl:grid-cols-4 gap-2.5">
365
+ <HardwareStat
366
+ icon={MemoryStick} label="Memory" value={hw.totalRamGb} unit="GB"
367
+ sub={req && `of ${req.recommendedRamGb} GB recommended`}
368
+ tone={insufficient ? 'text-danger' : hw.verdict === 'marginal' ? 'text-warning' : 'text-success'}
369
+ meter={verdict && { pct: ramPct, tone: verdict.meterTone }}
370
+ />
371
+ <HardwareStat
372
+ icon={HardDrive} label="Free disk"
373
+ value={hw.freeDiskGb == null ? '—' : Math.round(hw.freeDiskGb)}
374
+ unit={hw.freeDiskGb == null ? null : 'GB'}
375
+ sub={req && `${req.minDiskGb} GB needed`}
376
+ tone={hw.diskOk === false ? 'text-danger' : 'text-text-0'}
377
+ />
378
+ <HardwareStat icon={Cpu} label="Processor" value={hw.cpuCores} unit="cores" sub={hw.appleSilicon ? 'Apple Silicon' : hw.arch} />
379
+ <HardwareStat icon={Zap} label="Accelerator" value={hw.gpu ? 'Metal' : '—'} sub={hw.gpu ? 'unified memory' : 'none detected'} />
380
+ </div>
381
+
382
+ {verdict && (
383
+ <p className="text-xs text-text-3 leading-relaxed max-w-3xl">
384
+ {verdict.detail}
385
+ {hw.diskOk === false && ` Also low on disk: ${Math.round(hw.freeDiskGb)} GB free, ${req?.minDiskGb} GB needed.`}
386
+ </p>
387
+ )}
388
+ </div>
389
+ </motion.section>
390
+ )}
391
+
392
+ {/* ── Two paths to an Axom — the machine's verdict picks the primary ── */}
393
+ <motion.div
394
+ initial={{ opacity: 0, y: 10 }}
395
+ animate={{ opacity: 1, y: 0 }}
396
+ transition={{ duration: 0.4, delay: 0.18, ease: 'easeOut' }}
397
+ className="grid md:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2 gap-4 items-stretch"
398
+ >
399
+
400
+ {/* Path 1 — run it here */}
401
+ <section className={cn(
402
+ 'relative rounded-xl border p-5 flex flex-col gap-3.5 overflow-hidden transition-colors',
403
+ connectFirst ? 'bg-surface-1/60 border-border-subtle' : 'axom-panel border-accent/25',
404
+ )}>
405
+ <span aria-hidden className={cn('absolute inset-x-0 top-0 h-px', connectFirst ? 'axom-edge-muted' : 'axom-edge-accent')} />
406
+ <div className="flex items-center justify-between gap-2">
407
+ <div className="flex items-center gap-2.5">
408
+ <span className={cn(
409
+ 'flex h-7 w-7 items-center justify-center rounded-md border',
410
+ connectFirst ? 'bg-surface-2 border-border-subtle text-text-4' : 'bg-accent/10 border-accent/25 text-accent',
411
+ )}>
412
+ <Download size={14} strokeWidth={1.75} />
413
+ </span>
414
+ <span className={cn('text-sm font-semibold tracking-tight', connectFirst ? 'text-text-3' : 'text-text-0')}>Run it here</span>
415
+ </div>
416
+ {!connectFirst && hw && <Badge variant="accent">recommended here</Badge>}
417
+ </div>
418
+ <p className="text-xs text-text-3 leading-relaxed flex-1">
419
+ One verified download — runtime and models (~{req?.downloadGb ?? 4.4} GB) —
420
+ then your Axom lives on this machine, fully self-contained.
421
+ </p>
422
+
423
+ {gated ? (
424
+ <div className="rounded-md border border-border-subtle bg-surface-2/50 px-3 py-3 flex flex-col items-center gap-1.5 text-center">
425
+ <span className="font-mono text-2xs uppercase tracking-[0.2em] text-text-2">
426
+ {install.unavailableReason || 'Coming soon'}
427
+ </span>
428
+ <span className="text-xs text-text-4 leading-relaxed">
429
+ Running Axom on your own machine arrives in a future release.
430
+ </span>
431
+ </div>
432
+ ) : insufficient ? (
433
+ <div className="rounded-md bg-surface-2/60 border border-border-subtle px-3 py-2.5 flex items-start gap-2 text-xs text-text-3 leading-relaxed">
434
+ <TriangleAlert size={13} className="flex-shrink-0 mt-0.5 text-text-4" />
435
+ Locked below the {req?.minRamGb} GB memory floor — this machine stays comfortable.
436
+ </div>
437
+ ) : install.phase === 'done' ? (
438
+ <Button variant="primary" size="lg" onClick={startInstance} className="w-full">
439
+ <Atom size={15} className="mr-1.5" /> Start your Axom
440
+ </Button>
441
+ ) : installing ? (
442
+ <div className="rounded-md bg-surface-1 border border-border-subtle px-3 py-2.5 flex flex-col gap-2">
443
+ <div className="flex items-center justify-between gap-2 text-xs text-text-2">
444
+ <span className="truncate">{install.phase === 'models' ? `Downloading ${install.file}` : install.phase === 'runtime' ? 'Installing runtime…' : 'Fetching manifest…'}</span>
445
+ {install.totalBytes > 0 && (
446
+ <span className="font-mono text-2xs text-text-4 tabular-nums flex-shrink-0">
447
+ {formatBytes(install.receivedBytes)} / {formatBytes(install.totalBytes)}
448
+ </span>
449
+ )}
450
+ </div>
451
+ {install.totalBytes > 0 && (
452
+ <div className="h-[3px] rounded-full bg-surface-4 overflow-hidden">
453
+ <div
454
+ className="h-full bg-accent rounded-full transition-[width] duration-500 ease-out"
455
+ style={{ width: `${Math.min(100, (install.receivedBytes / install.totalBytes) * 100)}%` }}
456
+ />
457
+ </div>
458
+ )}
459
+ </div>
460
+ ) : (
461
+ <Button
462
+ variant="primary" size="lg" onClick={installLocally}
463
+ disabled={!canInstall || checkingAvailability}
464
+ className="w-full"
465
+ >
466
+ <Download size={15} className="mr-1.5" /> Install Axom locally
467
+ </Button>
468
+ )}
469
+ {install.phase === 'error' && (
470
+ <p className="flex items-start gap-1.5 text-xs text-danger leading-relaxed">
471
+ <TriangleAlert size={13} className="flex-shrink-0 mt-0.5" /> {install.error}
472
+ </p>
473
+ )}
474
+ </section>
475
+
476
+ {/* Path 2 — connect to one */}
477
+ <section className={cn(
478
+ 'relative rounded-xl border p-5 flex flex-col gap-3.5 overflow-hidden transition-colors',
479
+ connectFirst ? 'axom-panel border-accent/25' : 'bg-surface-1/60 border-border-subtle',
480
+ )}>
481
+ <span aria-hidden className={cn('absolute inset-x-0 top-0 h-px', connectFirst ? 'axom-edge-accent' : 'axom-edge-muted')} />
482
+ <div className="flex items-center justify-between gap-2">
483
+ <div className="flex items-center gap-2.5">
484
+ <span className={cn(
485
+ 'flex h-7 w-7 items-center justify-center rounded-md border',
486
+ connectFirst ? 'bg-accent/10 border-accent/25 text-accent' : 'bg-surface-2 border-border-subtle text-text-3',
487
+ )}>
488
+ <Globe size={14} strokeWidth={1.75} />
489
+ </span>
490
+ <span className="text-sm font-semibold tracking-tight text-text-0">Connect to an Axom</span>
491
+ </div>
492
+ {connectFirst && <Badge variant="accent">recommended here</Badge>}
493
+ </div>
494
+
495
+ <div className="flex flex-col gap-2">
496
+ <p className="text-2xs uppercase tracking-[0.12em] text-text-4 font-semibold">Enter endpoint</p>
497
+ <div className="flex gap-2">
498
+ <Input
499
+ value={url}
500
+ onChange={(e) => setUrl(e.target.value)}
501
+ placeholder="Paste an Axom endpoint…"
502
+ className="flex-1 font-mono text-xs"
503
+ />
504
+ <Button variant={connectFirst ? 'primary' : 'secondary'} onClick={connect} disabled={saving || !url.trim()}>
505
+ <Plug size={14} className="mr-1.5" />
506
+ {saving ? 'Connecting…' : 'Connect'}
507
+ </Button>
508
+ </div>
509
+ {typed?.invalid && (
510
+ <p className="text-xs text-danger leading-relaxed">That isn't a URL GROOVE can parse.</p>
511
+ )}
512
+
513
+ {/* Pre-flight: the bind trap, caught before the dead workspace */}
514
+ {typed && !typed.invalid && !typed.local && (
515
+ <div className="rounded-md border border-warning/25 bg-warning/[0.06] px-3 py-2.5 flex flex-col gap-1.5">
516
+ <span className="flex items-center gap-1.5 text-xs font-semibold text-warning">
517
+ <TriangleAlert size={12} /> {typed.host} can't be reached directly
518
+ </span>
519
+ <TunnelHint parsed={typed} onCopy={copyText} />
520
+ </div>
521
+ )}
522
+
523
+ {/* Post-flight: why the configured endpoint is dead */}
524
+ {failure && (
525
+ <div className="rounded-md border border-danger/25 bg-danger/[0.06] px-3 py-2.5 flex flex-col gap-1.5">
526
+ <span className="flex items-center gap-1.5 text-xs font-semibold text-danger">
527
+ <TriangleAlert size={12} /> Can't reach {configured.url}
528
+ </span>
529
+ <p className="text-xs text-text-2 leading-relaxed">{failure.cause}</p>
530
+ {configured.error && (
531
+ <code className="font-mono text-2xs text-text-4 break-all">{configured.error}</code>
532
+ )}
533
+ {failure.tunnel && configuredParsed && (
534
+ <TunnelHint parsed={configuredParsed} onCopy={copyText} />
535
+ )}
536
+ </div>
537
+ )}
538
+
539
+ <p className="text-xs text-text-4 leading-relaxed">
540
+ Copied from another GROOVE's "My endpoint", over your own secure channel.
541
+ </p>
542
+ </div>
543
+
544
+ {myEndpoint && (
545
+ <div className="flex flex-col gap-2 mt-auto pt-3.5 border-t border-border-subtle">
546
+ <div className="flex items-center gap-1.5">
547
+ <p className="text-2xs uppercase tracking-[0.12em] text-text-4 font-semibold">My endpoint</p>
548
+ <StatusDot status={myEndpoint.running ? 'running' : 'completed'} size="sm" />
549
+ <span className="text-2xs text-text-4">{myEndpoint.running ? 'live' : 'idle'}</span>
550
+ </div>
551
+ <div className="flex items-center gap-1.5">
552
+ <code className="flex-1 px-2 py-1.5 bg-surface-0 border border-border-subtle rounded font-mono text-xs text-text-1 truncate select-all">
553
+ {myEndpoint.url}
554
+ </code>
555
+ <Button
556
+ size="sm"
557
+ onClick={() => { navigator.clipboard.writeText(myEndpoint.url); addToast('success', 'Endpoint copied'); }}
558
+ >
559
+ <Copy size={12} className="mr-1" /> Copy
560
+ </Button>
561
+ </div>
562
+ <p className="text-xs text-text-4 leading-relaxed">
563
+ {myEndpoint.running ? 'Your Axom is live here.' : 'Where your Axom listens once started.'}{' '}
564
+ Binds to this machine only — from another machine, tunnel it:
565
+ </p>
566
+ <div className="flex items-center gap-1.5">
567
+ <code className="flex-1 px-2 py-1 bg-surface-0 border border-border-subtle rounded font-mono text-2xs text-text-3 truncate select-all">
568
+ {myEndpoint.tunnelCommand}
569
+ </code>
570
+ <button
571
+ onClick={() => { navigator.clipboard.writeText(myEndpoint.tunnelCommand); addToast('success', 'Tunnel command copied'); }}
572
+ className="p-1 rounded text-text-4 hover:text-accent hover:bg-surface-3 transition-colors cursor-pointer"
573
+ title="Copy tunnel command"
574
+ >
575
+ <Copy size={11} />
576
+ </button>
577
+ </div>
578
+ </div>
579
+ )}
580
+ </section>
581
+ </motion.div>
582
+ </div>
583
+ </div>
584
+ </div>
585
+ );
586
+ }
587
+
588
+ // ── Header — the runtime wears its receipts ────────────────────────────────
589
+ //
590
+ // Monochrome discipline: identity and capability facts are mono ink on the
591
+ // surface, not filled badges. Hue is reserved for things that are genuinely
592
+ // STATE — an unreachable endpoint, schema drift, a contract anomaly.
593
+
594
+ // Runtime lifecycle — scoped to the runtime, deliberately far from the input
595
+ // row so it never competes with turn-stop. What's offered depends on what we
596
+ // can actually honor: a runtime GROOVE spawned can be shut down; one on
597
+ // another machine can only be let go of. When Axom ships a /shutdown verb the
598
+ // remote branch gains a real control — a swap, not a redesign.
599
+ const LIFECYCLE_LABEL = { idle: 'Shut down', confirm: 'Confirm', force: 'Force stop', busy: 'Stopping…' };
600
+
601
+ function RuntimeLifecycle({ endpoint }) {
602
+ const stopAxomInstance = useGrooveStore((s) => s.stopAxomInstance);
603
+ const shutdownAxomRuntime = useGrooveStore((s) => s.shutdownAxomRuntime);
604
+ const saveAxomEndpoints = useGrooveStore((s) => s.saveAxomEndpoints);
605
+ const addToast = useGrooveStore((s) => s.addToast);
606
+ const [phase, setPhase] = useState('idle'); // idle | confirm | force | busy
607
+ const [note, setNote] = useState(null);
608
+
609
+ // A runtime GROOVE spawned is stopped through the instance API — a real
610
+ // process kill that also cleans up our own bookkeeping. Anything else goes
611
+ // through §14, which reaches runtimes no signal of ours ever could.
612
+ const managed = !!endpoint.managed && !!endpoint.instanceId;
613
+
614
+ useEffect(() => {
615
+ if (phase !== 'confirm' && phase !== 'force') return;
616
+ const t = setTimeout(() => setPhase('idle'), 5000);
617
+ return () => clearTimeout(t);
618
+ }, [phase]);
619
+
620
+ async function shutdown(force) {
621
+ setPhase('busy');
622
+ try {
623
+ if (managed) {
624
+ await stopAxomInstance(endpoint.instanceId);
625
+ } else {
626
+ // §14 outcomes come back as data, not exceptions — they are things
627
+ // this UI must SAY, so they never travel through the catch.
628
+ const result = await shutdownAxomRuntime(endpoint.name, { force });
629
+
630
+ // A turn is in flight. The contract allows forcing, but forcing
631
+ // silently would kill work the user never agreed to lose, so the
632
+ // escalation is offered rather than taken.
633
+ if (result.turnInFlight) {
634
+ setNote('turn in flight');
635
+ setPhase('force');
636
+ addToast('warning', 'A turn is in flight', 'Force stop to end it anyway — that turn will not finish.');
637
+ return;
638
+ }
639
+
640
+ // Too old for §14. Never claim the runtime is gone; it is very much
641
+ // still running, and pretending otherwise is the fake kill switch we
642
+ // agreed never to ship.
643
+ if (result.unsupported) {
644
+ setNote('no remote shutdown');
645
+ setPhase('idle');
646
+ addToast('error', 'This runtime is still running',
647
+ "It predates remote shutdown, so GROOVE can't stop it from here — stop it on the machine it runs on.");
648
+ return;
649
+ }
650
+ }
651
+ addToast('success', force ? 'Runtime force-stopped' : 'Runtime shutting down');
652
+ setNote(null);
653
+ setPhase('idle');
654
+ } catch (err) {
655
+ setPhase('idle');
656
+ addToast('error', 'Shut down failed', err.message);
657
+ }
658
+ }
659
+
660
+ async function disconnect() {
661
+ try {
662
+ await saveAxomEndpoints([]);
663
+ addToast('success', 'Disconnected', 'The runtime keeps running — GROOVE just stopped pointing at it.');
664
+ } catch (err) {
665
+ addToast('error', 'Disconnect failed', err.message);
666
+ }
667
+ }
668
+
669
+ const armed = phase === 'confirm' || phase === 'force';
670
+
671
+ return (
672
+ <div className="flex items-center gap-1">
673
+ {note && (
674
+ <span className={cn('font-mono text-2xs', phase === 'force' ? 'text-warning' : 'text-text-4')}>
675
+ {note}
676
+ </span>
677
+ )}
678
+ {!managed && (
679
+ <button
680
+ onClick={disconnect}
681
+ className="flex items-center gap-1.5 h-7 px-2 rounded-md text-2xs font-medium text-text-4 hover:text-text-1 hover:bg-surface-2 transition-colors cursor-pointer"
682
+ title="Stop pointing at this endpoint. The runtime keeps running."
683
+ >
684
+ <Unplug size={12} /> Disconnect
685
+ </button>
686
+ )}
687
+ <button
688
+ onClick={() => {
689
+ if (phase === 'idle') { setPhase('confirm'); return; }
690
+ if (phase === 'confirm') shutdown(false);
691
+ if (phase === 'force') shutdown(true);
692
+ }}
693
+ disabled={phase === 'busy'}
694
+ className={cn(
695
+ 'flex items-center gap-1.5 h-7 px-2 rounded-md text-2xs font-medium transition-colors cursor-pointer',
696
+ 'disabled:opacity-40 disabled:pointer-events-none',
697
+ armed ? 'bg-danger/10 text-danger' : 'text-text-4 hover:text-text-1 hover:bg-surface-2',
698
+ )}
699
+ title={managed
700
+ ? 'Stop the local runtime GROOVE started — its port is released and sessions end'
701
+ : 'Ask this runtime to shut itself down (§14). It may refuse while a turn is in flight.'}
702
+ >
703
+ <Power size={12} /> {LIFECYCLE_LABEL[phase]}
704
+ </button>
705
+ </div>
706
+ );
707
+ }
708
+
709
+ function RuntimeHeader({ endpoint, anomalies, onSetup, tickerOpen, onToggleTicker, eventCount }) {
710
+ const addToast = useGrooveStore((s) => s.addToast);
711
+ const about = endpoint.about;
712
+ const dotStatus = endpoint.status === 'connected' ? 'running'
713
+ : endpoint.status === 'connecting' ? 'starting' : 'crashed';
714
+ const facts = [
715
+ about?.family,
716
+ about?.record?.benchmark && `${about.record['pass@1']} ${about.record.benchmark}`,
717
+ about?.narrator && `narrator ${about.narrator}`,
718
+ ].filter(Boolean);
719
+
720
+ return (
721
+ <div className="flex-shrink-0 h-10 border-b border-border-subtle bg-surface-1 px-3 flex items-center gap-3">
722
+ <div className="flex items-center gap-2 min-w-0 flex-shrink-0">
723
+ <StatusDot status={dotStatus} size="sm" />
724
+ <span className="text-xs font-semibold text-text-0 truncate">{endpoint.name}</span>
725
+ <button
726
+ onClick={() => { navigator.clipboard.writeText(endpoint.url); addToast('success', 'Endpoint copied'); }}
727
+ className="font-mono text-2xs text-text-4 hover:text-accent truncate max-w-[15rem] cursor-pointer transition-colors"
728
+ title="Copy endpoint URL"
729
+ >
730
+ {endpoint.url}
731
+ </button>
732
+ </div>
733
+
734
+ {facts.length > 0 && (
735
+ <span className="hidden lg:flex items-center gap-2 font-mono text-2xs text-text-4 min-w-0 truncate">
736
+ <span aria-hidden className="h-3 w-px bg-border" />
737
+ {facts.map((f, i) => (
738
+ <span key={f} className="flex items-center gap-2 truncate">
739
+ {i > 0 && <span aria-hidden className="text-text-4/40">·</span>}
740
+ {f}
741
+ </span>
742
+ ))}
743
+ </span>
744
+ )}
745
+
746
+ {/* An idle-unloaded chassis is a healthy runtime resting, not a fault —
747
+ it reloads on the next message, so it reads as state, not error. */}
748
+ {about?.chassis?.loaded === false && (
749
+ <span className="font-mono text-2xs text-text-4 flex-shrink-0" title="The chassis unloaded after an idle timeout — the session and its ledger are intact">
750
+ chassis idle · reloads on next message
751
+ </span>
752
+ )}
753
+
754
+ {/* State — the only place hue is allowed up here */}
755
+ {endpoint.status === 'error' && (
756
+ <span className="flex items-center gap-1.5 text-2xs text-danger flex-shrink-0">
757
+ <TriangleAlert size={11} /> {endpoint.error || 'unreachable'}
758
+ </span>
759
+ )}
760
+ {(endpoint.drift?.novel?.length > 0 || endpoint.drift?.missing?.length > 0) && (
761
+ <span
762
+ className="text-2xs text-warning font-mono flex-shrink-0"
763
+ title={`novel: ${endpoint.drift.novel.join(', ') || '—'} · missing: ${endpoint.drift.missing.join(', ') || '—'}`}
764
+ >
765
+ drift +{endpoint.drift.novel.length}/−{endpoint.drift.missing.length}
766
+ </span>
767
+ )}
768
+ {anomalies?.length > 0 && (
769
+ <span
770
+ className="flex items-center gap-1 text-2xs text-warning flex-shrink-0"
771
+ title={anomalies.slice(-3).map((a) => `${a.eventId}: ${a.message}`).join('\n')}
772
+ >
773
+ <TriangleAlert size={11} /> {anomalies.length} contract {anomalies.length === 1 ? 'anomaly' : 'anomalies'}
774
+ </span>
775
+ )}
776
+
777
+ <div className="ml-auto flex items-center gap-1 flex-shrink-0">
778
+ <button
779
+ onClick={onToggleTicker}
780
+ className={cn(
781
+ 'flex items-center gap-1.5 h-7 px-2 rounded-md text-2xs font-medium transition-colors cursor-pointer',
782
+ tickerOpen ? 'bg-surface-3 text-text-1' : 'text-text-4 hover:text-text-1 hover:bg-surface-2',
783
+ )}
784
+ title="Raw event stream — the ground truth behind every line above"
785
+ >
786
+ <Radio size={12} />
787
+ Ground truth
788
+ <span className="font-mono text-text-4">{eventCount}</span>
789
+ </button>
790
+ {onSetup && (
791
+ <button
792
+ onClick={onSetup}
793
+ className={cn(
794
+ 'flex items-center gap-1.5 h-7 px-2 rounded-md text-2xs font-medium transition-colors cursor-pointer',
795
+ endpoint.status === 'error'
796
+ ? 'text-danger hover:bg-danger/10'
797
+ : 'text-text-4 hover:text-text-1 hover:bg-surface-2',
798
+ )}
799
+ title="Change endpoint or set up a runtime"
800
+ >
801
+ <Settings2 size={12} />
802
+ {endpoint.status === 'error' ? 'Fix connection' : 'Setup'}
803
+ </button>
804
+ )}
805
+ <RuntimeLifecycle endpoint={endpoint} />
806
+ </div>
807
+ </div>
808
+ );
809
+ }
810
+
811
+ // ── Sessions — a tab strip, not a sidebar ──────────────────────────────────
812
+
813
+ function SessionTabs({ endpoint }) {
814
+ const axomSelected = useGrooveStore((s) => s.axomSelected);
815
+ const selectAxomSession = useGrooveStore((s) => s.selectAxomSession);
816
+ if (endpoint.sessions.length === 0) return null;
817
+ return (
818
+ <div className="flex-shrink-0 h-8 border-b border-border-subtle bg-surface-1 px-2 flex items-center gap-1 overflow-x-auto">
819
+ {endpoint.sessions.map((s) => {
820
+ const active = axomSelected?.session === s.session;
821
+ return (
822
+ <button
823
+ key={s.session}
824
+ onClick={() => selectAxomSession(endpoint.name, s.session)}
825
+ className={cn(
826
+ 'flex items-center gap-1.5 h-6 px-2 rounded font-mono text-2xs whitespace-nowrap transition-colors cursor-pointer',
827
+ active ? 'bg-surface-3 text-text-0' : 'text-text-4 hover:text-text-2 hover:bg-surface-2',
828
+ )}
829
+ title={s.overflow > 0 ? `${s.overflow} events evicted from the buffer` : undefined}
830
+ >
831
+ <StatusDot status={s.live ? 'running' : 'completed'} size="sm" />
832
+ {s.session}
833
+ {s.overflow > 0 && <span className="text-warning">−{s.overflow}</span>}
834
+ </button>
835
+ );
836
+ })}
837
+ {/* §12: session ids are caller-chosen; the first message creates it */}
838
+ <button
839
+ onClick={() => selectAxomSession(endpoint.name, `s-${Math.random().toString(36).slice(2, 10)}`)}
840
+ className="h-6 w-6 flex items-center justify-center rounded text-text-4 hover:text-text-1 hover:bg-surface-2 transition-colors cursor-pointer flex-shrink-0"
841
+ title="New session — created on your first message"
842
+ >
843
+ <Plus size={12} />
844
+ </button>
845
+ </div>
846
+ );
847
+ }
848
+
849
+ // ── Local instance controls (drawer footer) ────────────────────────────────
850
+
851
+ function InstanceControls() {
852
+ const instances = useGrooveStore((s) => s.axomInstances);
853
+ const startAxomInstance = useGrooveStore((s) => s.startAxomInstance);
854
+ const stopAxomInstance = useGrooveStore((s) => s.stopAxomInstance);
855
+ const addToast = useGrooveStore((s) => s.addToast);
856
+ if (instances.length === 0) return null;
857
+ return (
858
+ <div className="flex-shrink-0 border-t border-border-subtle px-3 py-2 flex flex-col gap-1">
859
+ <div className="text-2xs uppercase tracking-[0.12em] text-text-4 font-semibold">Local instances</div>
860
+ {instances.map((inst) => (
861
+ <div key={inst.id} className="flex items-center gap-2 text-2xs text-text-3">
862
+ <StatusDot status={inst.status === 'running' ? 'running' : inst.status === 'error' ? 'crashed' : 'completed'} size="sm" />
863
+ <span className="font-mono truncate" title={inst.error || inst.dataDir}>{inst.id}:{inst.port}</span>
864
+ <button
865
+ onClick={() => (inst.status === 'running'
866
+ ? stopAxomInstance(inst.id)
867
+ : startAxomInstance(inst.id)
868
+ ).catch((err) => addToast('error', 'Instance action failed', err.message))}
869
+ className="ml-auto text-text-4 hover:text-text-1 cursor-pointer transition-colors"
870
+ title={inst.status === 'running' ? 'Stop instance' : 'Start instance'}
871
+ >
872
+ {inst.status === 'running' ? <Square size={11} /> : <Play size={11} />}
873
+ </button>
874
+ </div>
875
+ ))}
876
+ </div>
877
+ );
878
+ }
879
+
880
+ // ── Raw ticker — every envelope, verbatim, nothing dropped ─────────────────
881
+ //
882
+ // Demoted from a permanent pane to a drawer: ground truth on demand. Kinds
883
+ // are scannable by WEIGHT and CASE, not by hue — only genuine state kinds
884
+ // (stop, anomaly-adjacent) carry color.
885
+
886
+ const KIND_EMPHASIS = {
887
+ narration: 'text-text-1 font-medium',
888
+ resolution: 'text-text-0 font-medium',
889
+ text: 'text-text-1',
890
+ thought: 'text-text-2',
891
+ interrupt: 'text-text-0 font-medium', interrupt_ack: 'text-text-2',
892
+ stop_requested: 'text-danger', stop_effected: 'text-danger',
893
+ leaf_swap: 'text-text-1',
894
+ };
895
+
896
+ function payloadPreview(payload) {
897
+ if (payload == null) return '';
898
+ if (typeof payload === 'string') return payload;
899
+ const text = payload.text || payload.thought || payload.content;
900
+ if (typeof text === 'string') return text;
901
+ try { return JSON.stringify(payload); } catch { return String(payload); }
902
+ }
903
+
904
+ function RawTicker({ events, highlight, onClose }) {
905
+ const scrollRef = useRef(null);
906
+ const pinnedRef = useRef(true);
907
+
908
+ useEffect(() => {
909
+ const el = scrollRef.current;
910
+ if (el && pinnedRef.current && !highlight) el.scrollTop = el.scrollHeight;
911
+ }, [events.length, highlight]);
912
+
913
+ // Provenance interaction: when a narration is selected, bring its first
914
+ // cited event into view — trust becomes tactile.
915
+ useEffect(() => {
916
+ if (!highlight?.ids?.length) return;
917
+ const container = scrollRef.current;
918
+ const el = container?.querySelector(`[data-ev="${highlight.ids[0]}"]`);
919
+ if (!el) return;
920
+ // NEVER scrollIntoView here: it scrolls every ancestor scroll container,
921
+ // which drags the whole app sideways on wide rows and strands the user
922
+ // with no way back. Scroll this pane, and only this pane.
923
+ container.scrollTo({
924
+ top: el.offsetTop - (container.clientHeight / 2) + (el.offsetHeight / 2),
925
+ behavior: 'smooth',
926
+ });
927
+ }, [highlight]);
928
+
929
+ function onScroll() {
930
+ const el = scrollRef.current;
931
+ pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
932
+ }
933
+
934
+ const cited = new Set(highlight?.ids || []);
935
+
936
+ return (
937
+ <div className="w-[24rem] xl:w-[28rem] flex-shrink-0 border-l border-border-subtle bg-surface-1 flex flex-col min-h-0">
938
+ <div className="flex-shrink-0 h-8 px-3 flex items-center gap-2 border-b border-border-subtle">
939
+ <Radio size={11} className="text-text-4" />
940
+ <span className="text-2xs uppercase tracking-[0.12em] text-text-4 font-semibold">Ground truth</span>
941
+ <span className="font-mono text-2xs text-text-4">{events.length} buffered</span>
942
+ <button
943
+ onClick={onClose}
944
+ className="ml-auto h-6 w-6 flex items-center justify-center rounded text-text-4 hover:text-text-1 hover:bg-surface-2 transition-colors cursor-pointer"
945
+ title="Close"
946
+ >
947
+ <X size={12} />
948
+ </button>
949
+ </div>
950
+ <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-2 py-1.5 font-mono text-2xs leading-5">
951
+ {events.length === 0 && (
952
+ <p className="px-1 text-text-4 font-sans text-xs">Waiting for events…</p>
953
+ )}
954
+ {events.map((e) => (
955
+ <div
956
+ key={e.id}
957
+ data-ev={e.id}
958
+ className={cn(
959
+ 'flex gap-2 items-baseline rounded px-1 transition-colors',
960
+ cited.has(e.id) ? 'bg-accent/10 text-text-1 ring-1 ring-accent/30' : 'hover:bg-surface-2',
961
+ )}
962
+ >
963
+ <span className="text-text-4/70 flex-shrink-0">{e.id}</span>
964
+ <span className={cn('flex-shrink-0', KIND_EMPHASIS[e.kind] || 'text-text-3')}>{e.kind}</span>
965
+ {e.step != null && <span className="text-text-4/70 flex-shrink-0">s{e.step}</span>}
966
+ <span className="text-text-4 truncate">{payloadPreview(e.payload)}</span>
967
+ </div>
968
+ ))}
969
+ </div>
970
+ <InstanceControls />
971
+ </div>
972
+ );
973
+ }
974
+
975
+ // ── Conversation model — events folded into turns ──────────────────────────
976
+ //
977
+ // The stream is a transcript, not a log dump. Lifecycle kinds
978
+ // (pipeline/firing/step boundaries) are structure: they open and close turns
979
+ // rather than earning rows of their own. Everything rendered still traces to
980
+ // an event id — nothing here is synthesized.
981
+
982
+ // §9: narration.payload = {text, cites: [ev-ids]} — `cites` is the field,
983
+ // no variant exists.
984
+ function narrationCites(payload) {
985
+ const cites = payload?.cites;
986
+ return Array.isArray(cites) ? cites.filter((c) => typeof c === 'string') : [];
987
+ }
988
+
989
+ // §9: leaf_swap.payload = {from: str|null, to: str, firing_id} — the worn
990
+ // leaf is `to` (`from` is null on a firing's first swap).
991
+ function leafOf(payload) {
992
+ return typeof payload?.to === 'string' ? payload.to : null;
993
+ }
994
+
995
+ function promptOf(payload) {
996
+ for (const k of ['text', 'prompt', 'message', 'query', 'input']) {
997
+ if (typeof payload?.[k] === 'string' && payload[k].trim()) return payload[k];
998
+ }
999
+ return null;
1000
+ }
1001
+
1002
+ // tool_start/tool_end payloads name a tool; the argument is whatever string
1003
+ // the runtime put there. No shape is invented — an unnamed tool stays "tool".
1004
+ function toolLabel(payload, kind) {
1005
+ const name = payload?.tool || payload?.name || payload?.tool_name;
1006
+ let arg = payload?.arg ?? payload?.args ?? payload?.input ?? payload?.query;
1007
+ if (arg && typeof arg === 'object') {
1008
+ arg = Object.values(arg).find((v) => typeof v === 'string' && v.length < 120);
1009
+ }
1010
+ const verb = kind === 'tool_end' ? 'finished' : 'running';
1011
+ if (!name) return payloadPreview(payload) || verb;
1012
+ return [`${verb} ${name}`, typeof arg === 'string' ? arg : null].filter(Boolean).join(' · ');
1013
+ }
1014
+
1015
+ const LIFECYCLE = new Set(['firing_start', 'firing_end', 'step_start', 'step_end']);
1016
+
1017
+ // How long a prompt may sit unattached before the transcript stops implying
1018
+ // a turn is on its way for it.
1019
+ const PROMPT_STALE_S = 25;
1020
+
1021
+ function buildTurns(events) {
1022
+ const turns = [];
1023
+ let turn = null;
1024
+
1025
+ function current(seedId) {
1026
+ if (!turn) {
1027
+ turn = { id: seedId, prompt: null, promptId: null, blocks: [], done: false, stopped: false };
1028
+ turns.push(turn);
1029
+ }
1030
+ return turn;
1031
+ }
1032
+ function push(block) { current(block.id).blocks.push(block); }
1033
+ function last(type) {
1034
+ const b = turn?.blocks[turn.blocks.length - 1];
1035
+ return b && b.type === type ? b : null;
1036
+ }
1037
+
1038
+ for (const e of events) {
1039
+ const p = e.payload || {};
1040
+ switch (e.kind) {
1041
+ case 'pipeline_start':
1042
+ turn = { id: e.id, prompt: promptOf(p), promptId: e.id, blocks: [], done: false, stopped: false };
1043
+ turns.push(turn);
1044
+ break;
1045
+
1046
+ case 'thought': {
1047
+ const text = payloadPreview(p);
1048
+ if (!text) break;
1049
+ const prev = last('thought');
1050
+ if (prev) prev.items.push({ id: e.id, text });
1051
+ else push({ type: 'thought', id: e.id, items: [{ id: e.id, text }] });
1052
+ break;
1053
+ }
1054
+
1055
+ case 'tool_start':
1056
+ case 'tool_end': {
1057
+ const entry = { id: e.id, text: toolLabel(p, e.kind), done: e.kind === 'tool_end' };
1058
+ const prev = last('activity');
1059
+ if (prev) prev.items.push(entry);
1060
+ else push({ type: 'activity', id: e.id, items: [entry] });
1061
+ break;
1062
+ }
1063
+
1064
+ case 'narration': {
1065
+ // Law 1: an uncited narration renders nothing.
1066
+ const cites = narrationCites(p);
1067
+ if (!cites.length) break;
1068
+ push({ type: 'narration', id: e.id, text: payloadPreview(p), cites });
1069
+ break;
1070
+ }
1071
+
1072
+ // `text` arrives as line-sized fragments of the answer being written.
1073
+ // They are joined with a space (never glued — "searches"+"the web"
1074
+ // must not become "searchesthe web") and never mixed with resolution.
1075
+ case 'text': {
1076
+ const chunk = payloadPreview(p);
1077
+ if (!chunk) break;
1078
+ const prev = last('answer');
1079
+ if (prev && !prev.final) {
1080
+ const joiner = /\s$/.test(prev.text) || /^\s/.test(chunk) ? '' : ' ';
1081
+ if (!prev.text.endsWith(chunk)) prev.text += joiner + chunk;
1082
+ prev.ids.push(e.id);
1083
+ } else {
1084
+ push({ type: 'answer', id: e.id, text: chunk, final: false, ids: [e.id] });
1085
+ }
1086
+ break;
1087
+ }
1088
+
1089
+ // A resolution is the settled answer in its own right — authoritative
1090
+ // and complete. It gets its own block rather than being appended to the
1091
+ // streamed fragments, so neither text is distorted by the other.
1092
+ case 'resolution': {
1093
+ const chunk = payloadPreview(p);
1094
+ if (!chunk) break;
1095
+ push({ type: 'answer', id: e.id, text: chunk, final: true, ids: [e.id] });
1096
+ break;
1097
+ }
1098
+
1099
+ case 'leaf_swap':
1100
+ push({ type: 'leaf', id: e.id, leaf: leafOf(p) ?? 'UNKNOWN' });
1101
+ break;
1102
+
1103
+ case 'interrupt':
1104
+ // `id` is the envelope; `steerId` is the interrupt's own id, which is
1105
+ // what an ack names (§8). Both are kept so the ack can find its steer.
1106
+ push({
1107
+ type: 'steer', id: e.id, steerId: p.interrupt_id ?? p.id ?? null,
1108
+ text: payloadPreview(p), acked: false,
1109
+ });
1110
+ break;
1111
+
1112
+ case 'interrupt_ack': {
1113
+ // The ack lands on the steer it names; §8 pins interrupt_id present.
1114
+ // With no id, the oldest unacked steer is the only honest guess — and
1115
+ // the slice already logs that shape violation as an anomaly.
1116
+ const target = p.interrupt_id ?? p.id ?? null;
1117
+ for (let i = turns.length - 1; i >= 0; i--) {
1118
+ const hit = turns[i].blocks.find((b) => b.type === 'steer' && !b.acked
1119
+ && (target ? b.steerId === target || b.id === target : true));
1120
+ if (hit) { hit.acked = true; break; }
1121
+ }
1122
+ break;
1123
+ }
1124
+
1125
+ case 'stop_requested':
1126
+ push({ type: 'note', id: e.id, text: 'stop requested', state: 'stop' });
1127
+ break;
1128
+ case 'stop_effected':
1129
+ push({ type: 'note', id: e.id, text: 'stopped', state: 'stop' });
1130
+ break;
1131
+
1132
+ case 'pipeline_done':
1133
+ if (turn) { turn.done = true; turn.stopped = p.stopped_early === true; }
1134
+ turn = null;
1135
+ break;
1136
+
1137
+ default:
1138
+ if (LIFECYCLE.has(e.kind)) break; // structure, not a row
1139
+ break;
1140
+ }
1141
+ }
1142
+ return turns;
1143
+ }
1144
+
1145
+ // ── Conversation rendering — the fleet chat's vocabulary ───────────────────
1146
+
1147
+ function UserTurn({ text, status }) {
1148
+ return (
1149
+ <div className="flex justify-end pl-8">
1150
+ <div className="max-w-[85%] flex flex-col items-end gap-1">
1151
+ <div className="px-3.5 py-2.5 rounded-lg border border-accent/20 bg-accent/[0.07]">
1152
+ <div className="text-[12px] font-sans whitespace-pre-wrap break-words leading-relaxed text-text-0">
1153
+ {text}
1154
+ </div>
1155
+ </div>
1156
+ {status && (
1157
+ <span className={cn('font-mono text-2xs', status.stale ? 'text-warning' : 'text-text-4')}>
1158
+ {status.label}
1159
+ </span>
1160
+ )}
1161
+ </div>
1162
+ </div>
1163
+ );
1164
+ }
1165
+
1166
+ // A steer is a user utterance too, but mid-flight — and its state is only
1167
+ // ever what the stream said (heard on `interrupt`, acked on `interrupt_ack`).
1168
+ function SteerLine({ block }) {
1169
+ return (
1170
+ <div className="flex justify-end pl-8">
1171
+ <div className="max-w-[85%] flex flex-col items-end gap-1">
1172
+ <div className="px-3 py-1.5 rounded-lg border border-border bg-surface-2">
1173
+ <span className="flex items-center gap-1.5 text-[12px] font-sans text-text-1 leading-relaxed">
1174
+ <Zap size={11} className="text-accent flex-shrink-0" />
1175
+ {block.text}
1176
+ </span>
1177
+ </div>
1178
+ <span className="font-mono text-2xs text-text-4">
1179
+ {block.acked ? 'acked' : 'heard'} · {block.id}
1180
+ </span>
1181
+ </div>
1182
+ </div>
1183
+ );
1184
+ }
1185
+
1186
+ function ThoughtBlock({ block }) {
1187
+ const [open, setOpen] = useState(false);
1188
+ const shown = open ? block.items : block.items.slice(-1);
1189
+ return (
1190
+ <div className="flex flex-col gap-1">
1191
+ <button
1192
+ onClick={() => setOpen((v) => !v)}
1193
+ className="flex items-center gap-1.5 text-2xs text-text-4 hover:text-text-2 font-sans transition-colors cursor-pointer w-fit"
1194
+ >
1195
+ <Brain size={11} />
1196
+ {block.items.length === 1 ? 'thought' : `${block.items.length} thoughts`}
1197
+ {block.items.length > 1 && <ChevronDown size={10} className={cn('transition-transform', open && 'rotate-180')} />}
1198
+ </button>
1199
+ <div className="pl-3.5 border-l border-border flex flex-col gap-1">
1200
+ {shown.map((t) => (
1201
+ <p key={t.id} className="text-[11px] font-sans text-text-3 leading-relaxed italic">{t.text}</p>
1202
+ ))}
1203
+ </div>
1204
+ </div>
1205
+ );
1206
+ }
1207
+
1208
+ // Tool activity, fleet-style: a live group cycles its entries behind a
1209
+ // spinner; a settled group collapses to a count you can open.
1210
+ function ActivityBlock({ block, live }) {
1211
+ const [open, setOpen] = useState(false);
1212
+ const [cycle, setCycle] = useState(0);
1213
+ const items = block.items;
1214
+ const isLive = live && !items[items.length - 1]?.done;
1215
+
1216
+ useEffect(() => {
1217
+ if (!isLive || items.length <= 1) return;
1218
+ const t = setInterval(() => setCycle((i) => (i + 1) % items.length), 1500);
1219
+ return () => clearInterval(t);
1220
+ }, [isLive, items.length]);
1221
+
1222
+ if (isLive) {
1223
+ const cur = items[Math.min(cycle, items.length - 1)];
1224
+ return (
1225
+ <div className="flex items-center gap-2 px-3 py-2 rounded-md bg-surface-2 border border-border-subtle">
1226
+ <Loader2 size={11} className="text-accent animate-spin flex-shrink-0" />
1227
+ <span className="text-[11px] text-text-2 font-mono truncate min-w-0 flex-1">{cur.text}</span>
1228
+ {items.length > 1 && <span className="text-2xs text-text-4 font-mono flex-shrink-0">{items.length}</span>}
1229
+ </div>
1230
+ );
1231
+ }
1232
+
1233
+ return (
1234
+ <div className="flex flex-col gap-1">
1235
+ <button
1236
+ onClick={() => setOpen((v) => !v)}
1237
+ className="inline-flex items-center gap-1.5 text-2xs text-text-4 hover:text-text-2 font-mono transition-colors cursor-pointer w-fit"
1238
+ >
1239
+ <Wrench size={10} />
1240
+ {items.length} tool call{items.length === 1 ? '' : 's'}
1241
+ <ChevronDown size={10} className={cn('transition-transform', open && 'rotate-180')} />
1242
+ </button>
1243
+ {open && (
1244
+ <div className="pl-3.5 border-l border-border flex flex-col">
1245
+ {items.map((it) => (
1246
+ <div key={it.id} className="flex items-center gap-2 py-0.5">
1247
+ <Wrench size={9} className="text-text-4 flex-shrink-0" />
1248
+ <span className="text-[11px] text-text-3 font-mono truncate flex-1 min-w-0">{it.text}</span>
1249
+ <span className="text-2xs text-text-4 font-mono flex-shrink-0">{it.id}</span>
1250
+ </div>
1251
+ ))}
1252
+ </div>
1253
+ )}
1254
+ </div>
1255
+ );
1256
+ }
1257
+
1258
+ // Narration keeps its provenance interaction — but as an inline expansion,
1259
+ // not a second column. Clicking reveals the cited envelopes right here and
1260
+ // mirrors the selection into the ground-truth drawer if it's open.
1261
+ function NarrationBlock({ block, eventsById, highlight, onHighlight }) {
1262
+ const active = highlight?.narrationId === block.id;
1263
+ return (
1264
+ <div className="flex flex-col gap-1">
1265
+ <button
1266
+ onClick={() => onHighlight(active ? null : { narrationId: block.id, ids: block.cites })}
1267
+ className={cn(
1268
+ 'group flex items-start gap-2 text-left rounded-md px-2 py-1 -mx-2 transition-colors cursor-pointer',
1269
+ active ? 'bg-surface-2' : 'hover:bg-surface-2/60',
1270
+ )}
1271
+ title="Show the events behind this line"
1272
+ >
1273
+ <MessageSquareQuote size={12} className={cn('mt-0.5 flex-shrink-0', active ? 'text-accent' : 'text-text-4')} />
1274
+ <span className="text-[12px] font-sans text-text-2 leading-relaxed">{block.text}</span>
1275
+ <span className={cn(
1276
+ 'ml-auto flex-shrink-0 font-mono text-2xs transition-colors',
1277
+ active ? 'text-accent' : 'text-text-4 opacity-0 group-hover:opacity-100',
1278
+ )}>
1279
+ {block.cites.length} cited
1280
+ </span>
1281
+ </button>
1282
+ {active && (
1283
+ <div className="ml-5 rounded-md border border-border-subtle bg-surface-0 px-2 py-1.5 font-mono text-2xs leading-5">
1284
+ {block.cites.map((id) => {
1285
+ const ev = eventsById[id];
1286
+ return (
1287
+ <div key={id} className="flex gap-2 items-baseline">
1288
+ <span className="text-text-4/70 flex-shrink-0">{id}</span>
1289
+ <span className={cn('flex-shrink-0', ev ? KIND_EMPHASIS[ev.kind] || 'text-text-3' : 'text-warning')}>
1290
+ {ev ? ev.kind : 'evicted from buffer'}
1291
+ </span>
1292
+ <span className="text-text-4 truncate">{ev ? payloadPreview(ev.payload) : ''}</span>
1293
+ </div>
1294
+ );
1295
+ })}
1296
+ </div>
1297
+ )}
1298
+ </div>
1299
+ );
1300
+ }
1301
+
1302
+ function AnswerBlock({ block, family }) {
1303
+ return (
1304
+ <div>
1305
+ <div className="flex items-center gap-2 mb-1">
1306
+ <span className="text-2xs font-semibold text-text-1 font-sans">Axom</span>
1307
+ {family && <span className="text-2xs text-text-4 font-mono">{family}</span>}
1308
+ <span className="text-2xs text-text-4 font-mono">{block.final ? 'resolution' : 'text'}</span>
1309
+ <span className="ml-auto font-mono text-2xs text-text-4">{block.ids[0]}</span>
1310
+ </div>
1311
+ <div className="pl-3.5 border-l border-accent">
1312
+ <StructuredMessage text={block.text} />
1313
+ </div>
1314
+ </div>
1315
+ );
1316
+ }
1317
+
1318
+ function Divider({ icon: Icon, text, tone }) {
1319
+ return (
1320
+ <div className="flex items-center gap-3 py-1">
1321
+ <div className="flex-1 h-px bg-border-subtle" />
1322
+ <span className={cn('flex items-center gap-1.5 text-2xs font-sans uppercase tracking-[0.1em] flex-shrink-0', tone || 'text-text-4')}>
1323
+ {Icon && <Icon size={10} />} {text}
1324
+ </span>
1325
+ <div className="flex-1 h-px bg-border-subtle" />
1326
+ </div>
1327
+ );
1328
+ }
1329
+
1330
+ function TurnView({ turn, live, family, eventsById, highlight, onHighlight, prompt, ambiguous }) {
1331
+ // A turn with no prompt of ours shows NO bubble — a one-sided transcript
1332
+ // that's honest beats a complete one that's invented. But the two reasons
1333
+ // it can happen are different claims, and the label must not overreach:
1334
+ // · nothing of ours is pending → another client genuinely started it.
1335
+ // · something of ours IS pending → the slice declined an ambiguous
1336
+ // attachment, so we know a prompt exists and can't say which. Saying
1337
+ // "started elsewhere" there would assert something we can't back.
1338
+ const unclaimed = !prompt && !!turn.promptId;
1339
+ return (
1340
+ <div className="flex flex-col gap-3">
1341
+ {prompt && <UserTurn text={prompt} />}
1342
+ {unclaimed && <Divider text={ambiguous ? 'prompt not identified' : 'started elsewhere'} />}
1343
+ {turn.blocks.map((b) => {
1344
+ switch (b.type) {
1345
+ case 'thought': return <ThoughtBlock key={b.id} block={b} />;
1346
+ case 'activity': return <ActivityBlock key={b.id} block={b} live={live} />;
1347
+ case 'narration': return (
1348
+ <NarrationBlock key={b.id} block={b} eventsById={eventsById} highlight={highlight} onHighlight={onHighlight} />
1349
+ );
1350
+ case 'answer': return <AnswerBlock key={b.id} block={b} family={family} />;
1351
+ case 'steer': return <SteerLine key={b.id} block={b} />;
1352
+ case 'leaf': return <Divider key={b.id} icon={Shirt} text={`wearing ${b.leaf}`} />;
1353
+ case 'note': return <Divider key={b.id} icon={OctagonX} text={b.text} tone="text-danger" />;
1354
+ default: return null;
1355
+ }
1356
+ })}
1357
+ {turn.stopped && <Divider icon={OctagonX} text="stopped early" tone="text-danger" />}
1358
+ </div>
1359
+ );
1360
+ }
1361
+
1362
+ function Conversation({ events, prompts, sessionLive, family, highlight, onHighlight }) {
1363
+ const scrollRef = useRef(null);
1364
+ const pinnedRef = useRef(true);
1365
+ const turns = useMemo(() => buildTurns(events), [events]);
1366
+ const eventsById = useMemo(() => Object.fromEntries(events.map((e) => [e.id, e])), [events]);
1367
+
1368
+ useEffect(() => {
1369
+ const el = scrollRef.current;
1370
+ // Scoped to this pane only — never scrollIntoView (see RawTicker).
1371
+ if (el && pinnedRef.current) el.scrollTop = el.scrollHeight;
1372
+ }, [events.length]);
1373
+
1374
+ function onScroll() {
1375
+ const el = scrollRef.current;
1376
+ pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
1377
+ }
1378
+
1379
+ // Pending prompts age on their own clock — the stream may go quiet, and a
1380
+ // "still awaiting" label that can never update is its own small deception.
1381
+ const pending = prompts.filter((p) => !p.attachedTo);
1382
+ const [now, setNow] = useState(() => Date.now() / 1000);
1383
+ useEffect(() => {
1384
+ if (!pending.length) return;
1385
+ const t = setInterval(() => setNow(Date.now() / 1000), 3000);
1386
+ return () => clearInterval(t);
1387
+ }, [pending.length]);
1388
+
1389
+ const lastTurn = turns[turns.length - 1];
1390
+ const lastBlock = lastTurn?.blocks[lastTurn.blocks.length - 1];
1391
+ const awaiting = sessionLive && lastTurn && !lastTurn.done
1392
+ && (!lastBlock || (lastBlock.type !== 'activity' && lastBlock.type !== 'answer'));
1393
+
1394
+ return (
1395
+ <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto min-h-0">
1396
+ <div className="max-w-3xl mx-auto w-full px-5 py-5 flex flex-col gap-6">
1397
+ {turns.length === 0 && (
1398
+ <div className="flex flex-col items-center gap-2 py-16 text-center">
1399
+ <Atom size={28} strokeWidth={1} className="text-text-4" />
1400
+ <p className="text-xs text-text-3 max-w-sm leading-relaxed">
1401
+ Nothing has happened in this session yet. Send a message below — every
1402
+ step Axom takes will appear here, and every line will cite the events
1403
+ behind it.
1404
+ </p>
1405
+ </div>
1406
+ )}
1407
+ {turns.map((t, i) => (
1408
+ <TurnView
1409
+ key={t.id}
1410
+ turn={t}
1411
+ // §prompt ledger: a turn's prompt is the entry the slice attached
1412
+ // to that turn's pipeline_start envelope — a stable key, never a
1413
+ // timestamp guess. Rejected prompts (409/413) never got an entry.
1414
+ prompt={t.prompt || prompts.find((p) => p.attachedTo && p.attachedTo === t.promptId)?.text || null}
1415
+ ambiguous={pending.length > 0}
1416
+ live={sessionLive && i === turns.length - 1 && !t.done}
1417
+ family={family}
1418
+ eventsById={eventsById}
1419
+ highlight={highlight}
1420
+ onHighlight={onHighlight}
1421
+ />
1422
+ ))}
1423
+ {/* Accepted (202) but no pipeline_start yet — the runtime took it and
1424
+ hasn't opened the turn. Shown so a sent message never vanishes,
1425
+ and aged: a bubble that sits here forever quietly implies "still
1426
+ working" when the truth is that no turn ever opened for it. */}
1427
+ {pending.map((p) => (
1428
+ <UserTurn
1429
+ key={p.ts}
1430
+ text={p.text}
1431
+ status={now - p.ts > PROMPT_STALE_S
1432
+ ? { label: 'sent · no turn opened for it', stale: true }
1433
+ : { label: 'sent · awaiting turn' }}
1434
+ />
1435
+ ))}
1436
+ {awaiting && <ThinkingIndicator agent={{ name: 'Axom' }} />}
1437
+ </div>
1438
+ </div>
1439
+ );
1440
+ }
1441
+
1442
+ // ── Wardrobe — the worn leaf, never guessed ────────────────────────────────
1443
+
1444
+ function WardrobeStrip({ about, events }) {
1445
+ const swaps = useMemo(
1446
+ () => events.filter((e) => e.kind === 'leaf_swap').map((e) => ({ id: e.id, leaf: leafOf(e.payload) })),
1447
+ [events],
1448
+ );
1449
+ const roster = about?.leaves || [];
1450
+ // UNKNOWN is the default render — a HUD that guesses is a fail-open in a
1451
+ // costume. Only a leaf_swap event names the worn leaf.
1452
+ const current = swaps.length ? (swaps[swaps.length - 1].leaf ?? 'UNKNOWN') : 'UNKNOWN';
1453
+
1454
+ return (
1455
+ <div className="flex-shrink-0 h-7 border-b border-border-subtle px-3 flex items-center gap-2 overflow-x-auto">
1456
+ <Shirt size={11} className="text-text-4 flex-shrink-0" />
1457
+ <span className="font-mono text-2xs flex-shrink-0">
1458
+ <span className={current === 'UNKNOWN' ? 'text-text-4' : 'text-text-1'}>{current}</span>
1459
+ </span>
1460
+ {swaps.length > 1 && (
1461
+ <span className="font-mono text-2xs text-text-4 whitespace-nowrap" title="Leaf swap history — each entry is a leaf_swap event">
1462
+ ← {swaps.slice(-6, -1).reverse().map((s) => s.leaf ?? '?').join(' ← ')}
1463
+ </span>
1464
+ )}
1465
+ <span className="ml-auto font-mono text-2xs text-text-4 whitespace-nowrap flex-shrink-0">
1466
+ roster {roster.length ? roster.map((l) => l.name).join(' · ') : '—'}
1467
+ </span>
1468
+ </div>
1469
+ );
1470
+ }
1471
+
1472
+ // ── Living Answer (M4) — the tournament, spectated ─────────────────────────
1473
+ //
1474
+ // Payload shapes ratified from runtime source in contract §10 (234537e):
1475
+ // candidates are identified by firing_id; derived events carry `basis`
1476
+ // (ev-id provenance — same click-to-highlight as narration); the verifier
1477
+ // slot is null until runtime verifier integration and renders dimmed-absent;
1478
+ // verifier_verdict is reserved-dark and deliberately NOT parsed. The panel
1479
+ // appears only when a consensus run exists — no tournament, no panel.
1480
+ // Watermark machine (kinds + §8 stopped_early) ratified as GUI derivation.
1481
+
1482
+ function livingAnswer(events) {
1483
+ let state = null;
1484
+ const blank = () => ({
1485
+ watermark: 'PROVISIONAL', candidates: [], byFiring: {},
1486
+ champion: null, confidence: null, evidence: [], banked: 0,
1487
+ });
1488
+ for (const e of events) {
1489
+ const p = e.payload || {};
1490
+ if (e.kind === 'pipeline_start') state = null; // new run, fresh tournament
1491
+ else if (e.kind === 'candidate_arrived') {
1492
+ state = state || blank();
1493
+ const c = {
1494
+ firingId: p.firing_id, leafId: p.leaf_id, text: p.text,
1495
+ banked: !!p.banked, basis: Array.isArray(p.basis) ? p.basis : [],
1496
+ };
1497
+ state.candidates.push(c);
1498
+ if (c.firingId) state.byFiring[c.firingId] = c;
1499
+ } else if (state && e.kind === 'champion_changed') {
1500
+ state.champion = {
1501
+ to: p.to, from: p.from ?? null, rule: p.rule,
1502
+ basis: Array.isArray(p.basis) ? p.basis : [],
1503
+ };
1504
+ } else if (state && e.kind === 'confidence_updated') {
1505
+ state.confidence = p;
1506
+ } else if (state && e.kind === 'evidence_scored') {
1507
+ state.evidence.push({ source: p.source, values: p.values, basis: Array.isArray(p.basis) ? p.basis : [] });
1508
+ } else if (state && e.kind === 'candidate_banked') {
1509
+ state.banked += 1;
1510
+ } else if (state && e.kind === 'pipeline_done') {
1511
+ state.watermark = p.stopped_early === true ? 'STOPPED' : 'SETTLED';
1512
+ }
1513
+ }
1514
+ return state;
1515
+ }
1516
+
1517
+ // Watermark is real state, so it may carry hue — but only the watermark.
1518
+ const WATERMARK_TONE = {
1519
+ PROVISIONAL: 'text-warning',
1520
+ SETTLED: 'text-text-1',
1521
+ STOPPED: 'text-danger',
1522
+ };
1523
+
1524
+ // A provenance-bearing block: clicking highlights its basis events, exactly
1525
+ // like narration cites.
1526
+ function BasisButton({ basis, highlightKey, highlight, onHighlight, className, children, title }) {
1527
+ const active = highlight?.narrationId === highlightKey;
1528
+ return (
1529
+ <button
1530
+ title={title}
1531
+ onClick={() => basis.length && onHighlight(active ? null : { narrationId: highlightKey, ids: basis })}
1532
+ className={cn(
1533
+ 'text-left rounded transition-colors',
1534
+ basis.length ? 'cursor-pointer' : 'cursor-default',
1535
+ active ? 'bg-surface-3' : 'hover:bg-surface-2',
1536
+ className,
1537
+ )}
1538
+ >
1539
+ {children}
1540
+ </button>
1541
+ );
1542
+ }
1543
+
1544
+ function LivingAnswerPanel({ answer, onStop, stopState, highlight, onHighlight }) {
1545
+ const [open, setOpen] = useState(false);
1546
+ const champion = answer.champion ? answer.byFiring[answer.champion.to] : null;
1547
+ const conf = answer.confidence;
1548
+ const live = answer.watermark === 'PROVISIONAL';
1549
+
1550
+ return (
1551
+ <div className="flex-shrink-0 border-t border-border-subtle bg-surface-1">
1552
+ <div className="max-w-3xl mx-auto w-full px-5">
1553
+ <div className="h-8 flex items-center gap-2.5">
1554
+ <Trophy size={11} className="text-text-4 flex-shrink-0" />
1555
+ <span className="text-2xs uppercase tracking-[0.12em] text-text-4 font-semibold flex-shrink-0">Living answer</span>
1556
+ <span className={cn('font-mono text-2xs font-semibold flex-shrink-0', WATERMARK_TONE[answer.watermark])}>
1557
+ {answer.watermark}
1558
+ </span>
1559
+ <span className="font-mono text-2xs text-text-4 truncate">
1560
+ {answer.candidates.length} candidate{answer.candidates.length === 1 ? '' : 's'}
1561
+ {answer.banked > 0 && ` · ${answer.banked} banked`}
1562
+ </span>
1563
+ {live && (
1564
+ <button
1565
+ onClick={onStop}
1566
+ disabled={stopState === 'pending'}
1567
+ className="flex items-center gap-1.5 h-6 px-2 rounded text-2xs font-medium text-text-3 hover:text-danger hover:bg-danger/10 disabled:opacity-40 disabled:pointer-events-none transition-colors cursor-pointer flex-shrink-0"
1568
+ title="Settle the tournament on the current champion"
1569
+ >
1570
+ <OctagonX size={11} /> Good enough
1571
+ </button>
1572
+ )}
1573
+ <button
1574
+ onClick={() => setOpen((v) => !v)}
1575
+ className="ml-auto h-6 w-6 flex items-center justify-center rounded text-text-4 hover:text-text-1 hover:bg-surface-2 transition-colors cursor-pointer flex-shrink-0"
1576
+ title={open ? 'Collapse' : 'Show champion and confidence'}
1577
+ >
1578
+ <ChevronDown size={12} className={cn('transition-transform', open && 'rotate-180')} />
1579
+ </button>
1580
+ </div>
1581
+
1582
+ {open && (
1583
+ <div className="pb-2 flex flex-col gap-1.5 max-h-48 overflow-y-auto">
1584
+ {answer.champion && (
1585
+ <BasisButton
1586
+ basis={answer.champion.basis}
1587
+ highlightKey="champion"
1588
+ highlight={highlight}
1589
+ onHighlight={onHighlight}
1590
+ className="px-2 py-1.5 border border-border-subtle"
1591
+ title={answer.champion.basis.length ? 'Click to highlight the events behind this champion' : undefined}
1592
+ >
1593
+ <span className="flex items-center gap-2 mb-0.5 font-mono text-2xs text-text-4">
1594
+ {champion?.leafId && <span className="text-text-2">{champion.leafId}</span>}
1595
+ {answer.champion.rule && <span>rule {answer.champion.rule}</span>}
1596
+ </span>
1597
+ {typeof champion?.text === 'string' && (
1598
+ <span className="block text-[12px] text-text-2 leading-relaxed whitespace-pre-wrap">
1599
+ {champion.text.length > 400 ? `${champion.text.slice(0, 400)}…` : champion.text}
1600
+ </span>
1601
+ )}
1602
+ </BasisButton>
1603
+ )}
1604
+
1605
+ {conf && (
1606
+ <BasisButton
1607
+ basis={Array.isArray(conf.basis) ? conf.basis : []}
1608
+ highlightKey="confidence"
1609
+ highlight={highlight}
1610
+ onHighlight={onHighlight}
1611
+ className="px-2 py-1 font-mono text-2xs text-text-4"
1612
+ >
1613
+ {[
1614
+ conf.candidates != null && `${conf.candidates} candidates`,
1615
+ conf.n_fused != null && conf.n_agents != null && `${conf.n_fused}/${conf.n_agents} fused`,
1616
+ conf.n_facts != null && `${conf.n_facts} facts`,
1617
+ conf.tools_grounded != null && `tools ${String(conf.tools_grounded)}`,
1618
+ ].filter(Boolean).join(' · ')}
1619
+ {/* §10: the U3 meter waiting for its organ — dimmed, never invented */}
1620
+ <span className="opacity-60"> · verifier: {conf.verifier == null ? 'awaiting integration' : String(conf.verifier)}</span>
1621
+ </BasisButton>
1622
+ )}
1623
+
1624
+ {answer.evidence.length > 0 && (
1625
+ <div className="flex items-center gap-1.5 flex-wrap">
1626
+ {answer.evidence.slice(-3).map((ev, i) => (
1627
+ <BasisButton
1628
+ key={`${ev.source}-${i}`}
1629
+ basis={ev.basis}
1630
+ highlightKey={`evidence-${answer.evidence.length - 3 + i}`}
1631
+ highlight={highlight}
1632
+ onHighlight={onHighlight}
1633
+ className="px-1.5 py-0.5 border border-border-subtle font-mono text-2xs text-text-4"
1634
+ title={ev.values ? JSON.stringify(ev.values) : undefined}
1635
+ >
1636
+ {ev.source ?? 'evidence'}
1637
+ </BasisButton>
1638
+ ))}
1639
+ </div>
1640
+ )}
1641
+ </div>
1642
+ )}
1643
+ </div>
1644
+ </div>
1645
+ );
1646
+ }
1647
+
1648
+ // ── Hot input + interrupt/stop accountability ──────────────────────────────
1649
+
1650
+ const STOP_LABEL = {
1651
+ pending: 'stop pending…',
1652
+ effected: 'stopped',
1653
+ 'resolved-before-stop': 'resolved before stop',
1654
+ };
1655
+
1656
+ // §7: the three-state rollup from pipeline_done — "nothing you type silently
1657
+ // vanishes" deserves pixels. Shape pinned by contract §8 (nested-only):
1658
+ // pipeline_done.payload.interrupts.{acked, unanswered, unconsumed}, three
1659
+ // flat lists of interrupt-id strings. Rendered only when present (fail-open).
1660
+ function interruptRollup(events) {
1661
+ for (let i = events.length - 1; i >= 0; i--) {
1662
+ if (events[i].kind !== 'pipeline_done') continue;
1663
+ const src = events[i].payload?.interrupts;
1664
+ if (!src || typeof src !== 'object') return null;
1665
+ const states = ['acked', 'unanswered', 'unconsumed'].filter((k) => Array.isArray(src[k]));
1666
+ if (states.length === 0) return null;
1667
+ return Object.fromEntries(states.map((k) => [k, src[k].length]));
1668
+ }
1669
+ return null;
1670
+ }
1671
+
1672
+ function HotInput({ sessionLive, rollup }) {
1673
+ const axomSelected = useGrooveStore((s) => s.axomSelected);
1674
+ const interrupts = useGrooveStore((s) => s.axomInterrupts);
1675
+ const stops = useGrooveStore((s) => s.axomStops);
1676
+ const sendAxomInterrupt = useGrooveStore((s) => s.sendAxomInterrupt);
1677
+ const sendAxomMessage = useGrooveStore((s) => s.sendAxomMessage);
1678
+ const sendAxomStop = useGrooveStore((s) => s.sendAxomStop);
1679
+ const addToast = useGrooveStore((s) => s.addToast);
1680
+ const [text, setText] = useState('');
1681
+
1682
+ const key = axomSelected ? axomSessionKey(axomSelected.endpoint, axomSelected.session) : null;
1683
+ const ledger = (key && interrupts[key]) || {};
1684
+ const stopState = (key && stops[key]) || null;
1685
+
1686
+ // §12 two-verb split: message starts a turn (session idle), interrupt
1687
+ // steers one already in flight. The verbs stay distinct — on a stale-`live`
1688
+ // 409 we report, never silently reroute a prompt into a steer.
1689
+ async function send() {
1690
+ const message = text.trim();
1691
+ if (!message) return;
1692
+ setText(''); // input never locks — clear immediately, chips carry the truth
1693
+ try {
1694
+ if (sessionLive) {
1695
+ const result = await sendAxomInterrupt(message);
1696
+ if (result.truncated) addToast('warning', 'Interrupt truncated', 'The runtime capped it at 2000 chars');
1697
+ } else {
1698
+ const result = await sendAxomMessage(message);
1699
+ if (result.busy) {
1700
+ addToast('warning', 'Turn already in flight', 'Not sent — type again to steer the running turn instead');
1701
+ setText(message);
1702
+ } else if (result.tooLong) {
1703
+ addToast('error', 'Message too long', `The runtime's max is ${result.max || 32768} chars — nothing was truncated`);
1704
+ setText(message);
1705
+ }
1706
+ }
1707
+ } catch (err) {
1708
+ // 503 is not 409: the runtime is shutting down, so retrying can never
1709
+ // succeed. It must not read as "busy, try again".
1710
+ if (err.status === 503) {
1711
+ addToast('error', 'This runtime is shutting down', 'Nothing was sent — that endpoint is going away.');
1712
+ } else {
1713
+ addToast('error', sessionLive ? 'Interrupt failed' : 'Message failed', err.message);
1714
+ }
1715
+ setText(message);
1716
+ }
1717
+ }
1718
+
1719
+ async function stop() {
1720
+ try { await sendAxomStop(); } catch (err) { addToast('error', 'Stop failed', err.message); }
1721
+ }
1722
+
1723
+ // Only steers the stream has NOT yet confirmed live here; once the runtime
1724
+ // emits `interrupt`, the steer appears in the transcript above and this
1725
+ // pending row lets go of it. Nothing is claimed that no event backs.
1726
+ const pending = Object.entries(ledger).filter(([, v]) => v.state === 'sent').slice(-3);
1727
+
1728
+ return (
1729
+ <div className="flex-shrink-0 border-t border-border-subtle bg-surface-1 px-5 py-2.5">
1730
+ <div className="max-w-3xl mx-auto w-full flex flex-col gap-1.5">
1731
+
1732
+ {(pending.length > 0 || stopState || rollup) && (
1733
+ <div className="flex items-center gap-2 flex-wrap font-mono text-2xs text-text-4">
1734
+ {pending.map(([id, entry]) => (
1735
+ <span key={id} title={entry.text} className="max-w-56 truncate">
1736
+ sent · {entry.text}
1737
+ </span>
1738
+ ))}
1739
+ {stopState && (
1740
+ <span className={cn(stopState === 'effected' || stopState === 'pending' ? 'text-danger' : 'text-text-3')}>
1741
+ {STOP_LABEL[stopState]}
1742
+ </span>
1743
+ )}
1744
+ {rollup && (
1745
+ <span title="Interrupt accounting from the last pipeline_done rollup — every interrupt ends in exactly one state">
1746
+ rollup{' '}
1747
+ {Object.entries(rollup).map(([state, count], i) => (
1748
+ <span key={state} className={state === 'unconsumed' && count > 0 ? 'text-warning' : undefined}>
1749
+ {i > 0 && ' · '}{count} {state}
1750
+ </span>
1751
+ ))}
1752
+ </span>
1753
+ )}
1754
+ </div>
1755
+ )}
1756
+
1757
+ <div className="flex flex-col rounded-lg border border-border-subtle bg-surface-0 transition-colors focus-within:border-text-4/40">
1758
+ <textarea
1759
+ value={text}
1760
+ onChange={(e) => setText(e.target.value)}
1761
+ onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
1762
+ placeholder={sessionLive ? 'Steer Axom mid-flight…' : 'Message your Axom — starts a turn'}
1763
+ rows={1}
1764
+ className="w-full resize-none field-sizing-content max-h-40 px-3 py-2.5 text-[13px] leading-[20px] bg-transparent font-sans text-text-0 placeholder:text-text-4 focus:outline-none"
1765
+ />
1766
+ <div className="flex items-center gap-1.5 px-1.5 pb-1.5">
1767
+ {sessionLive && (
1768
+ <span className="flex items-center gap-2 pl-1.5">
1769
+ <span className="relative flex items-center justify-center w-3 h-3">
1770
+ <span className="absolute inset-0 rounded-full bg-accent/30 animate-ping [animation-duration:2s]" />
1771
+ <span className="relative w-1.5 h-1.5 rounded-full bg-accent" />
1772
+ </span>
1773
+ <span className="text-2xs text-text-4 font-sans">turn in flight</span>
1774
+ </span>
1775
+ )}
1776
+ <div className="flex-1" />
1777
+ {sessionLive && (
1778
+ <button
1779
+ onClick={stop}
1780
+ disabled={!axomSelected || stopState === 'pending'}
1781
+ className="flex items-center gap-1.5 h-7 px-2 rounded-md text-2xs font-medium text-text-3 hover:text-danger hover:bg-danger/10 disabled:opacity-40 disabled:pointer-events-none transition-colors cursor-pointer"
1782
+ title="Stop this turn"
1783
+ >
1784
+ <OctagonX size={12} /> Stop
1785
+ </button>
1786
+ )}
1787
+ <button
1788
+ onClick={send}
1789
+ disabled={!axomSelected || !text.trim()}
1790
+ className={cn(
1791
+ 'w-7 h-7 flex items-center justify-center rounded-md transition-colors cursor-pointer',
1792
+ 'disabled:opacity-15 disabled:cursor-not-allowed',
1793
+ text.trim() ? 'text-text-0 hover:text-text-1 hover:bg-surface-3' : 'text-text-4',
1794
+ )}
1795
+ title={sessionLive ? 'Steer (Enter)' : 'Send (Enter)'}
1796
+ >
1797
+ {sessionLive ? <Zap size={14} /> : <SendHorizontal size={15} />}
1798
+ </button>
1799
+ </div>
1800
+ </div>
1801
+ </div>
1802
+ </div>
1803
+ );
1804
+ }
1805
+
1806
+ // ── View root ───────────────────────────────────────────────────────────────
1807
+
1808
+ export default function AxomView() {
1809
+ const axomStatus = useGrooveStore((s) => s.axomStatus);
1810
+ const axomSelected = useGrooveStore((s) => s.axomSelected);
1811
+ const axomEvents = useGrooveStore((s) => s.axomEvents);
1812
+ const axomPrompts = useGrooveStore((s) => s.axomPrompts);
1813
+ const axomAnomalies = useGrooveStore((s) => s.axomAnomalies);
1814
+ const axomStops = useGrooveStore((s) => s.axomStops);
1815
+ const selectAxomSession = useGrooveStore((s) => s.selectAxomSession);
1816
+ const sendAxomStop = useGrooveStore((s) => s.sendAxomStop);
1817
+ const [highlight, setHighlight] = useState(null);
1818
+ const [tickerOpen, setTickerOpen] = useState(false);
1819
+ // Escape hatch: a configured-but-wrong endpoint must never trap the user in
1820
+ // a dead workspace with no way back to setup.
1821
+ const [showSetup, setShowSetup] = useState(false);
1822
+
1823
+ const endpoints = axomStatus?.endpoints || [];
1824
+ // v0 renders the first endpoint; the config supports several (mesh later).
1825
+ const endpoint = endpoints[0];
1826
+
1827
+ // Auto-select the only session so the tab is alive without a click.
1828
+ useEffect(() => {
1829
+ if (!endpoint || axomSelected) return;
1830
+ const live = endpoint.sessions.find((s) => s.live) || endpoint.sessions[0];
1831
+ if (live) selectAxomSession(endpoint.name, live.session);
1832
+ }, [endpoint, axomSelected, selectAxomSession]);
1833
+
1834
+ const sessionKeyStr = axomSelected ? axomSessionKey(axomSelected.endpoint, axomSelected.session) : null;
1835
+ const events = useMemo(
1836
+ () => (sessionKeyStr ? axomEvents[sessionKeyStr] || [] : []),
1837
+ [sessionKeyStr, axomEvents],
1838
+ );
1839
+
1840
+ const prompts = useMemo(
1841
+ () => (sessionKeyStr ? axomPrompts[sessionKeyStr] || [] : []),
1842
+ [sessionKeyStr, axomPrompts],
1843
+ );
1844
+
1845
+ // A highlight belongs to one session's stream — drop it on switch.
1846
+ useEffect(() => { setHighlight(null); }, [sessionKeyStr]);
1847
+
1848
+ const answer = useMemo(() => livingAnswer(events), [events]);
1849
+
1850
+ if (!endpoint || showSetup) {
1851
+ return <Onboarding onBack={endpoint ? () => setShowSetup(false) : null} />;
1852
+ }
1853
+
1854
+ const selectedSession = axomSelected
1855
+ && endpoint.sessions.find((s) => s.session === axomSelected.session);
1856
+ const sessionLive = !!selectedSession?.live;
1857
+
1858
+ return (
1859
+ <div className="h-full flex flex-col bg-surface-1">
1860
+ <RuntimeHeader
1861
+ endpoint={endpoint}
1862
+ anomalies={(sessionKeyStr && axomAnomalies[sessionKeyStr]) || []}
1863
+ onSetup={() => setShowSetup(true)}
1864
+ tickerOpen={tickerOpen}
1865
+ onToggleTicker={() => setTickerOpen((v) => !v)}
1866
+ eventCount={events.length}
1867
+ />
1868
+ <SessionTabs endpoint={endpoint} />
1869
+ <div className="flex-1 flex min-h-0">
1870
+ <div className="flex-1 flex flex-col min-w-0 min-h-0">
1871
+ <WardrobeStrip about={endpoint.about} events={events} />
1872
+ <Conversation
1873
+ events={events}
1874
+ prompts={prompts}
1875
+ sessionLive={sessionLive}
1876
+ family={endpoint.about?.family}
1877
+ highlight={highlight}
1878
+ onHighlight={(h) => { setHighlight(h); if (h) setTickerOpen(true); }}
1879
+ />
1880
+ {answer && (
1881
+ <LivingAnswerPanel
1882
+ answer={answer}
1883
+ onStop={() => sendAxomStop().catch(() => {})}
1884
+ stopState={(sessionKeyStr && axomStops[sessionKeyStr]) || null}
1885
+ highlight={highlight}
1886
+ onHighlight={(h) => { setHighlight(h); if (h) setTickerOpen(true); }}
1887
+ />
1888
+ )}
1889
+ <HotInput sessionLive={sessionLive} rollup={interruptRollup(events)} />
1890
+ </div>
1891
+ {tickerOpen && (
1892
+ <RawTicker events={events} highlight={highlight} onClose={() => setTickerOpen(false)} />
1893
+ )}
1894
+ </div>
1895
+ </div>
1896
+ );
1897
+ }