groove-dev 0.27.196 → 0.27.198

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 (106) 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/node_modules/@groove-dev/cli/package.json +1 -1
  5. package/node_modules/@groove-dev/daemon/package.json +1 -1
  6. package/node_modules/@groove-dev/daemon/src/api.js +5 -5
  7. package/node_modules/@groove-dev/daemon/src/axom-connector.js +340 -0
  8. package/node_modules/@groove-dev/daemon/src/axom-install.js +140 -0
  9. package/node_modules/@groove-dev/daemon/src/axom-server.js +229 -0
  10. package/node_modules/@groove-dev/daemon/src/chatstore.js +104 -0
  11. package/node_modules/@groove-dev/daemon/src/federation.js +6 -0
  12. package/node_modules/@groove-dev/daemon/src/index.js +14 -2
  13. package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +8 -1
  14. package/node_modules/@groove-dev/daemon/src/innerchat-relay.js +89 -0
  15. package/node_modules/@groove-dev/daemon/src/innerchat.js +261 -1
  16. package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
  17. package/node_modules/@groove-dev/daemon/src/network-guard.js +56 -0
  18. package/node_modules/@groove-dev/daemon/src/process.js +2 -2
  19. package/node_modules/@groove-dev/daemon/src/providers/axom.js +66 -0
  20. package/node_modules/@groove-dev/daemon/src/providers/index.js +2 -0
  21. package/node_modules/@groove-dev/daemon/src/routes/agents.js +1 -175
  22. package/node_modules/@groove-dev/daemon/src/routes/axom.js +196 -0
  23. package/node_modules/@groove-dev/daemon/src/routes/chat-history.js +34 -0
  24. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +152 -20
  25. package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +412 -0
  26. package/node_modules/@groove-dev/daemon/test/axom-server.test.js +187 -0
  27. package/node_modules/@groove-dev/daemon/test/chatstore.test.js +86 -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-RbtaI6l7.css +1 -0
  30. package/node_modules/@groove-dev/gui/dist/assets/index-b9dKN6cq.js +1074 -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 -2
  34. package/node_modules/@groove-dev/gui/src/app.css +53 -0
  35. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +2 -2
  36. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +3 -3
  37. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +3 -3
  38. package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
  39. package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +1 -1
  40. package/node_modules/@groove-dev/gui/src/stores/groove.js +24 -163
  41. package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +52 -2
  42. package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +197 -0
  43. package/node_modules/@groove-dev/gui/src/views/axom.jsx +1060 -0
  44. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +1 -1
  45. package/node_modules/@groove-dev/gui/src/views/settings.jsx +178 -64
  46. package/package.json +2 -2
  47. package/packages/cli/package.json +1 -1
  48. package/packages/daemon/package.json +1 -1
  49. package/packages/daemon/src/api.js +5 -5
  50. package/packages/daemon/src/axom-connector.js +340 -0
  51. package/packages/daemon/src/axom-install.js +140 -0
  52. package/packages/daemon/src/axom-server.js +229 -0
  53. package/packages/daemon/src/chatstore.js +104 -0
  54. package/packages/daemon/src/federation.js +6 -0
  55. package/packages/daemon/src/index.js +14 -2
  56. package/packages/daemon/src/innerchat-docs.js +8 -1
  57. package/packages/daemon/src/innerchat-relay.js +89 -0
  58. package/packages/daemon/src/innerchat.js +261 -1
  59. package/packages/daemon/src/introducer.js +1 -1
  60. package/packages/daemon/src/network-guard.js +56 -0
  61. package/packages/daemon/src/process.js +2 -2
  62. package/packages/daemon/src/providers/axom.js +66 -0
  63. package/packages/daemon/src/providers/index.js +2 -0
  64. package/packages/daemon/src/routes/agents.js +1 -175
  65. package/packages/daemon/src/routes/axom.js +196 -0
  66. package/packages/daemon/src/routes/chat-history.js +34 -0
  67. package/packages/daemon/src/routes/innerchat.js +152 -20
  68. package/packages/gui/dist/assets/index-RbtaI6l7.css +1 -0
  69. package/packages/gui/dist/assets/index-b9dKN6cq.js +1074 -0
  70. package/packages/gui/dist/index.html +2 -2
  71. package/packages/gui/package.json +1 -1
  72. package/packages/gui/src/App.jsx +2 -2
  73. package/packages/gui/src/app.css +53 -0
  74. package/packages/gui/src/components/agents/agent-config.jsx +2 -2
  75. package/packages/gui/src/components/agents/spawn-wizard.jsx +3 -3
  76. package/packages/gui/src/components/layout/activity-bar.jsx +3 -3
  77. package/packages/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
  78. package/packages/gui/src/components/layout/command-palette.jsx +1 -1
  79. package/packages/gui/src/stores/groove.js +24 -163
  80. package/packages/gui/src/stores/slices/agents-slice.js +52 -2
  81. package/packages/gui/src/stores/slices/axom-slice.js +197 -0
  82. package/packages/gui/src/views/axom.jsx +1060 -0
  83. package/packages/gui/src/views/marketplace.jsx +1 -1
  84. package/packages/gui/src/views/settings.jsx +178 -64
  85. package/node_modules/@groove-dev/daemon/src/conversations.js +0 -576
  86. package/node_modules/@groove-dev/gui/dist/assets/index-Cat5pJUx.css +0 -1
  87. package/node_modules/@groove-dev/gui/dist/assets/index-L8xSnvHj.js +0 -1070
  88. package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +0 -198
  89. package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +0 -367
  90. package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +0 -527
  91. package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +0 -212
  92. package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +0 -163
  93. package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +0 -201
  94. package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +0 -227
  95. package/node_modules/@groove-dev/gui/src/views/chat.jsx +0 -6
  96. package/packages/daemon/src/conversations.js +0 -576
  97. package/packages/gui/dist/assets/index-Cat5pJUx.css +0 -1
  98. package/packages/gui/dist/assets/index-L8xSnvHj.js +0 -1070
  99. package/packages/gui/src/components/chat/chat-header.jsx +0 -198
  100. package/packages/gui/src/components/chat/chat-input.jsx +0 -367
  101. package/packages/gui/src/components/chat/chat-messages.jsx +0 -527
  102. package/packages/gui/src/components/chat/chat-view.jsx +0 -212
  103. package/packages/gui/src/components/chat/conversation-list.jsx +0 -163
  104. package/packages/gui/src/components/chat/model-picker.jsx +0 -201
  105. package/packages/gui/src/stores/slices/chat-slice.js +0 -227
  106. package/packages/gui/src/views/chat.jsx +0 -6
@@ -0,0 +1,1060 @@
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, Send, Plus, MemoryStick, HardDrive, Cpu, Gauge, CheckCircle2, Globe, Copy } 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 { cn } from '../lib/cn';
22
+
23
+ // ── Onboarding — the front door ─────────────────────────────────────────────
24
+
25
+ function formatBytes(n) {
26
+ if (!n) return '0 MB';
27
+ return n > 1e9 ? `${(n / 1e9).toFixed(2)} GB` : `${Math.round(n / 1e6)} MB`;
28
+ }
29
+
30
+ // Stat tile per the house dataviz spec: muted label, mono numeral, optional
31
+ // sub-line and severity meter (fill carries state; track is a step of the
32
+ // same surface ramp). Value color follows measurement, never optimism — and
33
+ // state never rides color alone (the verdict pill carries icon + words).
34
+ // `unit` is split off the numeral so the number reads first at a glance.
35
+ function HardwareStat({ icon: Icon, label, value, unit, sub, tone = 'text-text-0', meter }) {
36
+ return (
37
+ <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">
38
+ <span className="flex items-center gap-1.5 text-2xs uppercase tracking-[0.12em] text-text-4 font-semibold">
39
+ <Icon size={11} strokeWidth={1.75} /> {label}
40
+ </span>
41
+ <span className="flex items-baseline gap-1 min-w-0">
42
+ <span className={cn('font-mono text-xl font-semibold leading-none tabular-nums truncate', tone)}>{value}</span>
43
+ {unit && <span className="text-xs text-text-3 font-medium flex-shrink-0">{unit}</span>}
44
+ </span>
45
+ {sub && <span className="text-2xs text-text-4 truncate">{sub}</span>}
46
+ {meter && (
47
+ <div className="h-[3px] mt-0.5 rounded-full bg-surface-4 overflow-hidden">
48
+ <div
49
+ className={cn('h-full rounded-full transition-[width] duration-700 ease-out', meter.tone)}
50
+ style={{ width: `${meter.pct}%` }}
51
+ />
52
+ </div>
53
+ )}
54
+ </div>
55
+ );
56
+ }
57
+
58
+ const VERDICT_COPY = {
59
+ ready: {
60
+ icon: CheckCircle2,
61
+ pill: 'Ready for local Axom',
62
+ tone: 'text-success border-success/30 bg-success/10',
63
+ meterTone: 'bg-success',
64
+ detail: 'Meets the recommended spec — expect a comfortable ride.',
65
+ },
66
+ marginal: {
67
+ icon: Gauge,
68
+ pill: 'Meets the minimum',
69
+ tone: 'text-warning border-warning/30 bg-warning/10',
70
+ meterTone: 'bg-warning',
71
+ detail: 'Local Axom will run, but expect slower turns and tight memory. Close heavy apps first.',
72
+ },
73
+ insufficient: {
74
+ icon: TriangleAlert,
75
+ pill: 'Not enough memory',
76
+ tone: 'text-danger border-danger/30 bg-danger/10',
77
+ meterTone: 'bg-danger',
78
+ 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.',
79
+ },
80
+ };
81
+
82
+ const FEATURE_CHIPS = [
83
+ { icon: Shirt, label: 'Hot-swap skill leaves' },
84
+ { icon: Zap, label: 'Steer it mid-thought' },
85
+ { icon: Trophy, label: 'Answers that crystallize' },
86
+ { icon: HardDrive, label: 'Memory that stays yours' },
87
+ ];
88
+
89
+ function Onboarding() {
90
+ const saveAxomEndpoints = useGrooveStore((s) => s.saveAxomEndpoints);
91
+ const startAxomInstall = useGrooveStore((s) => s.startAxomInstall);
92
+ const startAxomInstance = useGrooveStore((s) => s.startAxomInstance);
93
+ const fetchAxomHardware = useGrooveStore((s) => s.fetchAxomHardware);
94
+ const hw = useGrooveStore((s) => s.axomHardware);
95
+ const myEndpoint = useGrooveStore((s) => s.axomMyEndpoint);
96
+ const install = useGrooveStore((s) => s.axomInstall);
97
+ const addToast = useGrooveStore((s) => s.addToast);
98
+ const [url, setUrl] = useState('');
99
+ const [saving, setSaving] = useState(false);
100
+
101
+ useEffect(() => { fetchAxomHardware(); }, [fetchAxomHardware]);
102
+
103
+ const installing = install.phase === 'manifest' || install.phase === 'runtime' || install.phase === 'models';
104
+ const req = hw?.requirements;
105
+ const verdict = hw ? VERDICT_COPY[hw.verdict] : null;
106
+ const canInstall = hw ? hw.verdict !== 'insufficient' && hw.diskOk !== false : true;
107
+
108
+ async function installLocally() {
109
+ try {
110
+ await startAxomInstall();
111
+ } catch (err) {
112
+ addToast('error', 'Install could not start', err.message);
113
+ }
114
+ }
115
+
116
+ async function startInstance() {
117
+ try {
118
+ await startAxomInstance('default');
119
+ } catch (err) {
120
+ addToast('error', 'Instance failed to start', err.message);
121
+ }
122
+ }
123
+
124
+ async function connect() {
125
+ setSaving(true);
126
+ try {
127
+ await saveAxomEndpoints([{ name: 'local', url: url.trim() }]);
128
+ } catch (err) {
129
+ addToast('error', 'Could not save endpoint', err.message);
130
+ } finally {
131
+ setSaving(false);
132
+ }
133
+ }
134
+
135
+ const insufficient = hw?.verdict === 'insufficient';
136
+ const ramPct = hw && req ? Math.min(100, (hw.totalRamGb / req.recommendedRamGb) * 100) : 0;
137
+
138
+ return (
139
+ <div className="h-full axom-hero-bg overflow-y-auto lg:overflow-hidden">
140
+ <div className="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">
141
+
142
+ {/* ── Identity pane — who Axom is, stated once ────────────────── */}
143
+ <motion.div
144
+ initial={{ opacity: 0, x: -14 }}
145
+ animate={{ opacity: 1, x: 0 }}
146
+ transition={{ duration: 0.5, ease: 'easeOut' }}
147
+ className="flex flex-col gap-6 min-w-0"
148
+ >
149
+ <div className="flex items-center gap-5 min-w-0">
150
+ {/* Orbiting mark */}
151
+ <div className="relative h-24 w-24 flex-shrink-0 flex items-center justify-center">
152
+ <span aria-hidden className="absolute h-20 w-20 rounded-full bg-accent/20 blur-2xl animate-axom-breathe" />
153
+ <span aria-hidden className="absolute inset-0 animate-axom-orbit">
154
+ <span className="axom-ring absolute inset-0 scale-y-[0.42]" />
155
+ </span>
156
+ <span aria-hidden className="absolute inset-1 animate-axom-orbit-slow">
157
+ <span className="axom-ring absolute inset-0 scale-x-[0.42]" />
158
+ </span>
159
+ <span aria-hidden className="absolute inset-2 rounded-full border border-accent/10" />
160
+ <Atom size={46} strokeWidth={0.9} className="relative text-accent" />
161
+ </div>
162
+ <div className="flex flex-col gap-2 min-w-0">
163
+ <span className="inline-flex items-center gap-2 text-2xs uppercase tracking-[0.22em] text-text-4 font-semibold">
164
+ Sovereign runtime
165
+ <span aria-hidden className="h-px w-8 axom-edge-muted" />
166
+ </span>
167
+ <h2 className="axom-display text-[2.75rem] xl:text-5xl font-semibold tracking-[-0.025em] leading-[1.05]">
168
+ Your own Axom
169
+ </h2>
170
+ </div>
171
+ </div>
172
+
173
+ <p className="text-[0.9375rem] text-text-2 leading-relaxed max-w-xl">
174
+ A sovereign AI runtime that lives on your hardware, remembers per
175
+ project, and shows its work — every claim on screen traces to a
176
+ real event underneath.
177
+ </p>
178
+
179
+ {/* Capability ledger — one law per row, hairline separated */}
180
+ <div className="max-w-xl rounded-lg border border-border-subtle bg-surface-1/50 divide-y divide-border-subtle overflow-hidden">
181
+ {FEATURE_CHIPS.map(({ icon: Icon, label }) => (
182
+ <span key={label} className="flex items-center gap-3 px-4 py-2.5">
183
+ <span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded border border-accent/20 bg-accent/10 text-accent">
184
+ <Icon size={12} strokeWidth={1.75} />
185
+ </span>
186
+ <span className="text-xs text-text-2 leading-snug">{label}</span>
187
+ </span>
188
+ ))}
189
+ </div>
190
+
191
+ <p className="text-xs text-text-4 leading-relaxed max-w-md">
192
+ Axom speaks an open provider protocol — local today, your rack tomorrow, the mesh after that. One socket, every tier.
193
+ </p>
194
+ </motion.div>
195
+
196
+ {/* ── Operations pane — what this machine can do about it ──────── */}
197
+ <div className="flex flex-col gap-4 min-w-0">
198
+
199
+ {/* ── This machine — readiness, verdict integrated ─────────────── */}
200
+ {hw && (
201
+ <motion.section
202
+ initial={{ opacity: 0, y: 10 }}
203
+ animate={{ opacity: 1, y: 0 }}
204
+ transition={{ duration: 0.4, delay: 0.1, ease: 'easeOut' }}
205
+ className="relative rounded-xl axom-panel border border-border overflow-hidden"
206
+ >
207
+ <span aria-hidden className="absolute inset-x-0 top-0 h-px axom-edge-muted" />
208
+ <div className="px-5 py-4 flex flex-col gap-4">
209
+ <div className="flex items-center justify-between gap-3 flex-wrap">
210
+ <span className="flex items-center gap-2 text-2xs uppercase tracking-[0.16em] text-text-3 font-semibold">
211
+ <Gauge size={12} strokeWidth={1.75} className="text-text-4" />
212
+ This machine
213
+ </span>
214
+ {verdict && (
215
+ <span className={cn('inline-flex items-center gap-1.5 px-3 py-1 rounded-full border text-xs font-semibold', verdict.tone)}>
216
+ <verdict.icon size={13} strokeWidth={2} /> {verdict.pill}
217
+ </span>
218
+ )}
219
+ </div>
220
+
221
+ <div className="grid grid-cols-2 xl:grid-cols-4 gap-2.5">
222
+ <HardwareStat
223
+ icon={MemoryStick} label="Memory" value={hw.totalRamGb} unit="GB"
224
+ sub={req && `of ${req.recommendedRamGb} GB recommended`}
225
+ tone={insufficient ? 'text-danger' : hw.verdict === 'marginal' ? 'text-warning' : 'text-success'}
226
+ meter={verdict && { pct: ramPct, tone: verdict.meterTone }}
227
+ />
228
+ <HardwareStat
229
+ icon={HardDrive} label="Free disk"
230
+ value={hw.freeDiskGb == null ? '—' : Math.round(hw.freeDiskGb)}
231
+ unit={hw.freeDiskGb == null ? null : 'GB'}
232
+ sub={req && `${req.minDiskGb} GB needed`}
233
+ tone={hw.diskOk === false ? 'text-danger' : 'text-text-0'}
234
+ />
235
+ <HardwareStat icon={Cpu} label="Processor" value={hw.cpuCores} unit="cores" sub={hw.appleSilicon ? 'Apple Silicon' : hw.arch} />
236
+ <HardwareStat icon={Zap} label="Accelerator" value={hw.gpu ? 'Metal' : '—'} sub={hw.gpu ? 'unified memory' : 'none detected'} />
237
+ </div>
238
+
239
+ {verdict && (
240
+ <p className="text-xs text-text-3 leading-relaxed max-w-3xl">
241
+ {verdict.detail}
242
+ {hw.diskOk === false && ` Also low on disk: ${Math.round(hw.freeDiskGb)} GB free, ${req?.minDiskGb} GB needed.`}
243
+ </p>
244
+ )}
245
+ </div>
246
+ </motion.section>
247
+ )}
248
+
249
+ {/* ── Two paths to an Axom — the machine's verdict picks the primary ── */}
250
+ <motion.div
251
+ initial={{ opacity: 0, y: 10 }}
252
+ animate={{ opacity: 1, y: 0 }}
253
+ transition={{ duration: 0.4, delay: 0.18, ease: 'easeOut' }}
254
+ className="grid md:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2 gap-4 items-stretch"
255
+ >
256
+
257
+ {/* Path 1 — run it here */}
258
+ <section className={cn(
259
+ 'relative rounded-xl border p-5 flex flex-col gap-3.5 overflow-hidden transition-colors',
260
+ insufficient ? 'bg-surface-1/60 border-border-subtle' : 'axom-panel border-accent/25',
261
+ )}>
262
+ <span aria-hidden className={cn('absolute inset-x-0 top-0 h-px', insufficient ? 'axom-edge-muted' : 'axom-edge-accent')} />
263
+ <div className="flex items-center justify-between gap-2">
264
+ <div className="flex items-center gap-2.5">
265
+ <span className={cn(
266
+ 'flex h-7 w-7 items-center justify-center rounded-md border',
267
+ insufficient ? 'bg-surface-2 border-border-subtle text-text-4' : 'bg-accent/10 border-accent/25 text-accent',
268
+ )}>
269
+ <Download size={14} strokeWidth={1.75} />
270
+ </span>
271
+ <span className={cn('text-sm font-semibold tracking-tight', insufficient ? 'text-text-3' : 'text-text-0')}>Run it here</span>
272
+ </div>
273
+ {!insufficient && hw && <Badge variant="accent">recommended here</Badge>}
274
+ </div>
275
+ <p className="text-xs text-text-3 leading-relaxed flex-1">
276
+ One verified download — runtime and models (~{req?.downloadGb ?? 4.4} GB) —
277
+ then your Axom lives on this machine, fully self-contained.
278
+ </p>
279
+
280
+ {insufficient ? (
281
+ <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">
282
+ <TriangleAlert size={13} className="flex-shrink-0 mt-0.5 text-text-4" />
283
+ Locked below the {req?.minRamGb} GB memory floor — this machine stays comfortable.
284
+ </div>
285
+ ) : install.phase === 'done' ? (
286
+ <Button variant="primary" size="lg" onClick={startInstance} className="w-full">
287
+ <Atom size={15} className="mr-1.5" /> Start your Axom
288
+ </Button>
289
+ ) : installing ? (
290
+ <div className="rounded-md bg-surface-1 border border-border-subtle px-3 py-2.5 flex flex-col gap-2">
291
+ <div className="flex items-center justify-between gap-2 text-xs text-text-2">
292
+ <span className="truncate">{install.phase === 'models' ? `Downloading ${install.file}` : install.phase === 'runtime' ? 'Installing runtime…' : 'Fetching manifest…'}</span>
293
+ {install.totalBytes > 0 && (
294
+ <span className="font-mono text-2xs text-text-4 tabular-nums flex-shrink-0">
295
+ {formatBytes(install.receivedBytes)} / {formatBytes(install.totalBytes)}
296
+ </span>
297
+ )}
298
+ </div>
299
+ {install.totalBytes > 0 && (
300
+ <div className="h-[3px] rounded-full bg-surface-4 overflow-hidden">
301
+ <div
302
+ className="h-full bg-accent rounded-full transition-[width] duration-500 ease-out"
303
+ style={{ width: `${Math.min(100, (install.receivedBytes / install.totalBytes) * 100)}%` }}
304
+ />
305
+ </div>
306
+ )}
307
+ </div>
308
+ ) : (
309
+ <Button variant="primary" size="lg" onClick={installLocally} disabled={!canInstall} className="w-full">
310
+ <Download size={15} className="mr-1.5" /> Install Axom locally
311
+ </Button>
312
+ )}
313
+ {install.phase === 'error' && (
314
+ <p className="flex items-start gap-1.5 text-xs text-danger leading-relaxed">
315
+ <TriangleAlert size={13} className="flex-shrink-0 mt-0.5" /> {install.error}
316
+ </p>
317
+ )}
318
+ </section>
319
+
320
+ {/* Path 2 — connect to one */}
321
+ <section className={cn(
322
+ 'relative rounded-xl border p-5 flex flex-col gap-3.5 overflow-hidden transition-colors',
323
+ insufficient ? 'axom-panel border-accent/25' : 'bg-surface-1/60 border-border-subtle',
324
+ )}>
325
+ <span aria-hidden className={cn('absolute inset-x-0 top-0 h-px', insufficient ? 'axom-edge-accent' : 'axom-edge-muted')} />
326
+ <div className="flex items-center justify-between gap-2">
327
+ <div className="flex items-center gap-2.5">
328
+ <span className={cn(
329
+ 'flex h-7 w-7 items-center justify-center rounded-md border',
330
+ insufficient ? 'bg-accent/10 border-accent/25 text-accent' : 'bg-surface-2 border-border-subtle text-text-3',
331
+ )}>
332
+ <Globe size={14} strokeWidth={1.75} />
333
+ </span>
334
+ <span className="text-sm font-semibold tracking-tight text-text-0">Connect to an Axom</span>
335
+ </div>
336
+ {insufficient && <Badge variant="accent">recommended here</Badge>}
337
+ </div>
338
+
339
+ <div className="flex flex-col gap-2">
340
+ <p className="text-2xs uppercase tracking-[0.12em] text-text-4 font-semibold">Enter endpoint</p>
341
+ <div className="flex gap-2">
342
+ <Input
343
+ value={url}
344
+ onChange={(e) => setUrl(e.target.value)}
345
+ placeholder="Paste an Axom endpoint…"
346
+ className="flex-1 font-mono text-xs"
347
+ />
348
+ <Button variant={insufficient ? 'primary' : 'secondary'} onClick={connect} disabled={saving || !url.trim()}>
349
+ <Plug size={14} className="mr-1.5" />
350
+ {saving ? 'Connecting…' : 'Connect'}
351
+ </Button>
352
+ </div>
353
+ <p className="text-xs text-text-4 leading-relaxed">
354
+ Copied from another GROOVE's "My endpoint", over your own secure channel.
355
+ </p>
356
+ </div>
357
+
358
+ {myEndpoint && (
359
+ <div className="flex flex-col gap-2 mt-auto pt-3.5 border-t border-border-subtle">
360
+ <div className="flex items-center gap-1.5">
361
+ <p className="text-2xs uppercase tracking-[0.12em] text-text-4 font-semibold">My endpoint</p>
362
+ <StatusDot status={myEndpoint.running ? 'running' : 'completed'} size="sm" />
363
+ <span className="text-2xs text-text-4">{myEndpoint.running ? 'live' : 'idle'}</span>
364
+ </div>
365
+ <div className="flex items-center gap-1.5">
366
+ <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">
367
+ {myEndpoint.url}
368
+ </code>
369
+ <Button
370
+ size="sm"
371
+ onClick={() => { navigator.clipboard.writeText(myEndpoint.url); addToast('success', 'Endpoint copied'); }}
372
+ >
373
+ <Copy size={12} className="mr-1" /> Copy
374
+ </Button>
375
+ </div>
376
+ <p className="text-xs text-text-4 leading-relaxed">
377
+ {myEndpoint.running ? 'Your Axom is live here.' : 'Where your Axom listens once started.'}{' '}
378
+ Binds to this machine only — from another machine, tunnel it:
379
+ </p>
380
+ <div className="flex items-center gap-1.5">
381
+ <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">
382
+ {myEndpoint.tunnelCommand}
383
+ </code>
384
+ <button
385
+ onClick={() => { navigator.clipboard.writeText(myEndpoint.tunnelCommand); addToast('success', 'Tunnel command copied'); }}
386
+ className="p-1 rounded text-text-4 hover:text-accent hover:bg-surface-3 transition-colors cursor-pointer"
387
+ title="Copy tunnel command"
388
+ >
389
+ <Copy size={11} />
390
+ </button>
391
+ </div>
392
+ </div>
393
+ )}
394
+ </section>
395
+ </motion.div>
396
+ </div>
397
+ </div>
398
+ </div>
399
+ );
400
+ }
401
+
402
+ // ── Header — the runtime wears its receipts ────────────────────────────────
403
+
404
+ function RuntimeHeader({ endpoint, anomalies }) {
405
+ const addToast = useGrooveStore((s) => s.addToast);
406
+ const about = endpoint.about;
407
+ const dotStatus = endpoint.status === 'connected' ? 'running'
408
+ : endpoint.status === 'connecting' ? 'starting' : 'crashed';
409
+ return (
410
+ <div className="flex-shrink-0 border-b border-border bg-surface-2 px-4 py-2.5 flex items-center gap-3 flex-wrap">
411
+ <div className="flex items-center gap-2">
412
+ <StatusDot status={dotStatus} size="sm" />
413
+ <span className="text-sm font-semibold text-text-0">{endpoint.name}</span>
414
+ <button
415
+ onClick={() => { navigator.clipboard.writeText(endpoint.url); addToast('success', 'Endpoint copied'); }}
416
+ className="text-xs text-text-4 font-mono hover:text-accent cursor-pointer"
417
+ title="Copy endpoint URL"
418
+ >
419
+ {endpoint.url}
420
+ </button>
421
+ </div>
422
+ {about?.family && <Badge variant="accent">{about.family}</Badge>}
423
+ {about?.record?.benchmark && (
424
+ <Badge>{about.record['pass@1']} {about.record.benchmark}</Badge>
425
+ )}
426
+ {about?.narrator && (
427
+ <Badge className="font-mono">narrator: {about.narrator}</Badge>
428
+ )}
429
+ {endpoint.status === 'error' && (
430
+ <span className="text-xs text-danger">{endpoint.error || 'unreachable'}</span>
431
+ )}
432
+ {(endpoint.drift?.novel?.length > 0 || endpoint.drift?.missing?.length > 0) && (
433
+ <span className="text-xs text-warning" title={`novel: ${endpoint.drift.novel.join(', ') || '—'} · missing: ${endpoint.drift.missing.join(', ') || '—'}`}>
434
+ schema drift: +{endpoint.drift.novel.length} / −{endpoint.drift.missing.length} kinds
435
+ </span>
436
+ )}
437
+ {anomalies?.length > 0 && (
438
+ <span
439
+ className="flex items-center gap-1 text-xs text-warning"
440
+ title={anomalies.slice(-3).map((a) => `${a.eventId}: ${a.message}`).join('\n')}
441
+ >
442
+ <TriangleAlert size={12} /> {anomalies.length} contract {anomalies.length === 1 ? 'anomaly' : 'anomalies'}
443
+ </span>
444
+ )}
445
+ </div>
446
+ );
447
+ }
448
+
449
+ // ── Session rail ────────────────────────────────────────────────────────────
450
+
451
+ function InstanceControls() {
452
+ const instances = useGrooveStore((s) => s.axomInstances);
453
+ const startAxomInstance = useGrooveStore((s) => s.startAxomInstance);
454
+ const stopAxomInstance = useGrooveStore((s) => s.stopAxomInstance);
455
+ const addToast = useGrooveStore((s) => s.addToast);
456
+ if (instances.length === 0) return null;
457
+ return (
458
+ <div className="flex-shrink-0 border-t border-border px-3 py-2 flex flex-col gap-1">
459
+ <div className="text-[11px] uppercase tracking-wide text-text-4 font-medium">Local instances</div>
460
+ {instances.map((inst) => (
461
+ <div key={inst.id} className="flex items-center gap-2 text-xs text-text-2">
462
+ <StatusDot status={inst.status === 'running' ? 'running' : inst.status === 'error' ? 'crashed' : 'completed'} size="sm" />
463
+ <span className="font-mono truncate" title={inst.error || inst.dataDir}>{inst.id}:{inst.port}</span>
464
+ <button
465
+ onClick={() => (inst.status === 'running'
466
+ ? stopAxomInstance(inst.id)
467
+ : startAxomInstance(inst.id)
468
+ ).catch((err) => addToast('error', 'Instance action failed', err.message))}
469
+ className="ml-auto text-text-3 hover:text-accent cursor-pointer"
470
+ title={inst.status === 'running' ? 'Stop instance' : 'Start instance'}
471
+ >
472
+ {inst.status === 'running' ? <Square size={12} /> : <Play size={12} />}
473
+ </button>
474
+ </div>
475
+ ))}
476
+ </div>
477
+ );
478
+ }
479
+
480
+ function SessionRail({ endpoint }) {
481
+ const axomSelected = useGrooveStore((s) => s.axomSelected);
482
+ const selectAxomSession = useGrooveStore((s) => s.selectAxomSession);
483
+ return (
484
+ <div className="w-52 flex-shrink-0 border-r border-border flex flex-col">
485
+ <div className="px-3 py-2 flex items-center justify-between">
486
+ <span className="text-[11px] uppercase tracking-wide text-text-4 font-medium">Sessions</span>
487
+ {/* §12: session ids are caller-chosen; the first message creates it */}
488
+ <button
489
+ onClick={() => selectAxomSession(endpoint.name, `s-${Math.random().toString(36).slice(2, 10)}`)}
490
+ className="text-text-3 hover:text-accent cursor-pointer"
491
+ title="New session — created on your first message"
492
+ >
493
+ <Plus size={13} />
494
+ </button>
495
+ </div>
496
+ <div className="flex-1 overflow-y-auto">
497
+ {endpoint.sessions.length === 0 && (
498
+ <p className="px-3 text-xs text-text-4">No sessions yet — start one from the Axom REPL or wait for the runtime to open one.</p>
499
+ )}
500
+ {endpoint.sessions.map((s) => (
501
+ <button
502
+ key={s.session}
503
+ onClick={() => selectAxomSession(endpoint.name, s.session)}
504
+ className={cn(
505
+ 'w-full text-left px-3 py-2 flex items-center gap-2 transition-colors cursor-pointer',
506
+ axomSelected?.session === s.session ? 'bg-accent/10 text-text-0' : 'text-text-2 hover:bg-surface-2',
507
+ )}
508
+ >
509
+ <StatusDot status={s.live ? 'running' : 'completed'} size="sm" />
510
+ <span className="font-mono text-xs truncate">{s.session}</span>
511
+ {s.overflow > 0 && (
512
+ <span className="ml-auto text-[10px] text-warning" title={`${s.overflow} events evicted from the buffer`}>
513
+ −{s.overflow}
514
+ </span>
515
+ )}
516
+ </button>
517
+ ))}
518
+ </div>
519
+ <InstanceControls />
520
+ </div>
521
+ );
522
+ }
523
+
524
+ // ── Raw ticker — every envelope, verbatim, nothing dropped ─────────────────
525
+
526
+ const KIND_TONE = {
527
+ narration: 'text-accent',
528
+ interrupt: 'text-warning', interrupt_ack: 'text-warning',
529
+ stop_requested: 'text-danger', stop_effected: 'text-danger',
530
+ pipeline_done: 'text-success', resolution: 'text-success',
531
+ leaf_swap: 'text-accent',
532
+ };
533
+
534
+ function payloadPreview(payload) {
535
+ if (payload == null) return '';
536
+ if (typeof payload === 'string') return payload;
537
+ const text = payload.text || payload.thought || payload.content;
538
+ if (typeof text === 'string') return text;
539
+ try { return JSON.stringify(payload); } catch { return String(payload); }
540
+ }
541
+
542
+ function RawTicker({ events, highlight }) {
543
+ const scrollRef = useRef(null);
544
+ const pinnedRef = useRef(true);
545
+
546
+ useEffect(() => {
547
+ const el = scrollRef.current;
548
+ if (el && pinnedRef.current && !highlight) el.scrollTop = el.scrollHeight;
549
+ }, [events.length, highlight]);
550
+
551
+ // Provenance interaction: when a narration is selected, bring its first
552
+ // cited event into view — trust becomes tactile.
553
+ useEffect(() => {
554
+ if (!highlight?.ids?.length) return;
555
+ const el = scrollRef.current?.querySelector(`[data-ev="${highlight.ids[0]}"]`);
556
+ if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
557
+ }, [highlight]);
558
+
559
+ function onScroll() {
560
+ const el = scrollRef.current;
561
+ pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
562
+ }
563
+
564
+ const cited = new Set(highlight?.ids || []);
565
+
566
+ return (
567
+ <div className="flex-1 flex flex-col min-h-0">
568
+ <div className="px-3 py-2 flex items-center gap-2 text-[11px] uppercase tracking-wide text-text-4 font-medium">
569
+ <Radio size={12} /> Event ticker
570
+ <span className="normal-case font-normal tracking-normal">— {events.length} buffered</span>
571
+ </div>
572
+ <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-3 pb-2 font-mono text-xs leading-5">
573
+ {events.length === 0 && (
574
+ <p className="text-text-4 font-sans">Waiting for events…</p>
575
+ )}
576
+ {events.map((e) => (
577
+ <div
578
+ key={e.id}
579
+ data-ev={e.id}
580
+ className={cn(
581
+ 'flex gap-2 items-baseline rounded px-1 -mx-1 transition-colors',
582
+ cited.has(e.id) ? 'bg-accent/15 outline outline-1 outline-accent/40' : 'hover:bg-surface-2',
583
+ )}
584
+ >
585
+ <span className="text-text-4 flex-shrink-0">{e.id}</span>
586
+ <span className={cn('flex-shrink-0', KIND_TONE[e.kind] || 'text-text-2')}>{e.kind}</span>
587
+ {e.step != null && <span className="text-text-4 flex-shrink-0">s{e.step}</span>}
588
+ <span className="text-text-3 truncate">{payloadPreview(e.payload)}</span>
589
+ </div>
590
+ ))}
591
+ </div>
592
+ </div>
593
+ );
594
+ }
595
+
596
+ // ── Narrated stream — telemetry-grounded or absent ─────────────────────────
597
+ //
598
+ // Renders `narration` events ONLY when they carry citations; an uncited
599
+ // narration renders nothing (law 1). Clicking a narration highlights its
600
+ // cited events in the ticker (law 4 — provenance as interaction).
601
+
602
+ // §9: narration.payload = {text, cites: [ev-ids]} — `cites` is the field,
603
+ // no variant exists.
604
+ function narrationCites(payload) {
605
+ const cites = payload?.cites;
606
+ return Array.isArray(cites) ? cites.filter((c) => typeof c === 'string') : [];
607
+ }
608
+
609
+ function NarratedStream({ events, highlight, onHighlight }) {
610
+ const scrollRef = useRef(null);
611
+ const pinnedRef = useRef(true);
612
+ const narrations = useMemo(
613
+ () => events
614
+ .filter((e) => e.kind === 'narration')
615
+ .map((e) => ({ ...e, cites: narrationCites(e.payload) }))
616
+ .filter((e) => e.cites.length > 0),
617
+ [events],
618
+ );
619
+
620
+ useEffect(() => {
621
+ const el = scrollRef.current;
622
+ if (el && pinnedRef.current) el.scrollTop = el.scrollHeight;
623
+ }, [narrations.length]);
624
+
625
+ function onScroll() {
626
+ const el = scrollRef.current;
627
+ pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
628
+ }
629
+
630
+ return (
631
+ <div className="flex-1 flex flex-col min-h-0 border-r border-border">
632
+ <div className="px-3 py-2 flex items-center gap-2 text-[11px] uppercase tracking-wide text-text-4 font-medium">
633
+ <MessageSquareQuote size={12} /> Narrated
634
+ <span className="normal-case font-normal tracking-normal">— every line cited</span>
635
+ </div>
636
+ <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-3 pb-2 flex flex-col gap-1.5">
637
+ {narrations.length === 0 && (
638
+ <p className="text-xs text-text-4">
639
+ No narration yet. Lines appear here only when the narrator cites the
640
+ events behind them — silence means nothing narratable has happened.
641
+ </p>
642
+ )}
643
+ {narrations.map((n) => (
644
+ <button
645
+ key={n.id}
646
+ onClick={() => onHighlight(highlight?.narrationId === n.id ? null : { narrationId: n.id, ids: n.cites })}
647
+ className={cn(
648
+ 'text-left text-sm leading-relaxed rounded-md px-2.5 py-1.5 border transition-colors cursor-pointer',
649
+ highlight?.narrationId === n.id
650
+ ? 'bg-accent/10 border-accent/30 text-text-0'
651
+ : 'bg-surface-2 border-transparent text-text-1 hover:border-border',
652
+ )}
653
+ >
654
+ {payloadPreview(n.payload)}
655
+ <span className="block mt-0.5 font-mono text-[10px] text-text-4">
656
+ cites {n.cites.join(' ')}
657
+ </span>
658
+ </button>
659
+ ))}
660
+ </div>
661
+ </div>
662
+ );
663
+ }
664
+
665
+ // ── Wardrobe history strip (§7) — the leaf timeline, never guessed ─────────
666
+
667
+ // §9: leaf_swap.payload = {from: str|null, to: str, firing_id} — the worn
668
+ // leaf is `to` (`from` is null on a firing's first swap).
669
+ function leafOf(payload) {
670
+ return typeof payload?.to === 'string' ? payload.to : null;
671
+ }
672
+
673
+ function WardrobeStrip({ about, events }) {
674
+ const swaps = useMemo(
675
+ () => events.filter((e) => e.kind === 'leaf_swap').map((e) => ({ id: e.id, leaf: leafOf(e.payload) })),
676
+ [events],
677
+ );
678
+ const roster = about?.leaves || [];
679
+ // UNKNOWN is the default render — a HUD that guesses is a fail-open in a
680
+ // costume. Only a leaf_swap event names the worn leaf.
681
+ const current = swaps.length ? (swaps[swaps.length - 1].leaf ?? 'UNKNOWN') : 'UNKNOWN';
682
+
683
+ return (
684
+ <div className="flex-shrink-0 border-b border-border px-4 py-1.5 flex items-center gap-2 overflow-x-auto">
685
+ <Shirt size={12} className="text-text-4 flex-shrink-0" />
686
+ <span className="text-[10px] uppercase tracking-wide text-text-4 font-medium flex-shrink-0">Wardrobe</span>
687
+ <Badge variant={current === 'UNKNOWN' ? 'default' : 'accent'}>{current}</Badge>
688
+ {swaps.length > 1 && (
689
+ <span className="font-mono text-[10px] text-text-4 whitespace-nowrap" title="Leaf swap history — each entry is a leaf_swap event">
690
+ {swaps.slice(-8).map((s, i) => (
691
+ <span key={s.id} title={s.id}>
692
+ {i > 0 && ' → '}
693
+ {s.leaf ?? '?'}
694
+ </span>
695
+ ))}
696
+ </span>
697
+ )}
698
+ <span className="ml-auto text-[10px] text-text-4 whitespace-nowrap flex-shrink-0">
699
+ roster: {roster.length ? roster.map((l) => l.name).join(' · ') : '—'}
700
+ </span>
701
+ </div>
702
+ );
703
+ }
704
+
705
+ // ── Living Answer (M4) — the tournament, spectated ─────────────────────────
706
+ //
707
+ // Payload shapes ratified from runtime source in contract §10 (234537e):
708
+ // candidates are identified by firing_id; derived events carry `basis`
709
+ // (ev-id provenance — same click-to-highlight as narration); the verifier
710
+ // slot is null until runtime verifier integration and renders dimmed-absent;
711
+ // verifier_verdict is reserved-dark and deliberately NOT parsed. The panel
712
+ // appears only when a consensus run exists — no tournament, no panel.
713
+ // Watermark machine (kinds + §8 stopped_early) ratified as GUI derivation.
714
+
715
+ function livingAnswer(events) {
716
+ let state = null;
717
+ const blank = () => ({
718
+ watermark: 'PROVISIONAL', candidates: [], byFiring: {},
719
+ champion: null, confidence: null, evidence: [], banked: 0,
720
+ });
721
+ for (const e of events) {
722
+ const p = e.payload || {};
723
+ if (e.kind === 'pipeline_start') state = null; // new run, fresh tournament
724
+ else if (e.kind === 'candidate_arrived') {
725
+ state = state || blank();
726
+ const c = {
727
+ firingId: p.firing_id, leafId: p.leaf_id, text: p.text,
728
+ banked: !!p.banked, basis: Array.isArray(p.basis) ? p.basis : [],
729
+ };
730
+ state.candidates.push(c);
731
+ if (c.firingId) state.byFiring[c.firingId] = c;
732
+ } else if (state && e.kind === 'champion_changed') {
733
+ state.champion = {
734
+ to: p.to, from: p.from ?? null, rule: p.rule,
735
+ basis: Array.isArray(p.basis) ? p.basis : [],
736
+ };
737
+ } else if (state && e.kind === 'confidence_updated') {
738
+ state.confidence = p;
739
+ } else if (state && e.kind === 'evidence_scored') {
740
+ state.evidence.push({ source: p.source, values: p.values, basis: Array.isArray(p.basis) ? p.basis : [] });
741
+ } else if (state && e.kind === 'candidate_banked') {
742
+ state.banked += 1;
743
+ } else if (state && e.kind === 'pipeline_done') {
744
+ state.watermark = p.stopped_early === true ? 'STOPPED' : 'SETTLED';
745
+ }
746
+ }
747
+ return state;
748
+ }
749
+
750
+ const WATERMARK_BADGE = {
751
+ PROVISIONAL: { variant: 'warning', dot: 'pulse' },
752
+ SETTLED: { variant: 'success', dot: true },
753
+ STOPPED: { variant: 'danger', dot: true },
754
+ };
755
+
756
+ // A provenance-bearing block: clicking highlights its basis events in the
757
+ // ticker, exactly like narration cites.
758
+ function BasisButton({ basis, highlightKey, highlight, onHighlight, className, children, title }) {
759
+ const active = highlight?.narrationId === highlightKey;
760
+ return (
761
+ <button
762
+ title={title}
763
+ onClick={() => basis.length && onHighlight(active ? null : { narrationId: highlightKey, ids: basis })}
764
+ className={cn(
765
+ 'text-left rounded-md border transition-colors',
766
+ basis.length ? 'cursor-pointer' : 'cursor-default',
767
+ active ? 'bg-accent/10 border-accent/30' : 'border-transparent hover:border-border',
768
+ className,
769
+ )}
770
+ >
771
+ {children}
772
+ </button>
773
+ );
774
+ }
775
+
776
+ function LivingAnswerPanel({ answer, onStop, stopState, highlight, onHighlight }) {
777
+ const badge = WATERMARK_BADGE[answer.watermark];
778
+ const champion = answer.champion ? answer.byFiring[answer.champion.to] : null;
779
+ const conf = answer.confidence;
780
+ return (
781
+ <div className="flex-shrink-0 border-b border-border bg-surface-2 px-4 py-2 flex flex-col gap-1.5 max-h-56 overflow-y-auto">
782
+ <div className="flex items-center gap-3 flex-wrap">
783
+ <Trophy size={13} className="text-text-3 flex-shrink-0" />
784
+ <span className="text-xs font-semibold text-text-1">Living Answer</span>
785
+ <Badge variant={badge.variant} dot={badge.dot}>{answer.watermark}</Badge>
786
+ <span className="text-xs text-text-3">
787
+ {answer.candidates.length} candidate{answer.candidates.length === 1 ? '' : 's'}
788
+ {answer.banked > 0 && <> · {answer.banked} banked</>}
789
+ </span>
790
+ {answer.watermark === 'PROVISIONAL' && (
791
+ <Button size="sm" variant="danger" onClick={onStop} disabled={stopState === 'pending'} className="ml-auto">
792
+ <OctagonX size={12} className="mr-1" /> Good enough — stop
793
+ </Button>
794
+ )}
795
+ </div>
796
+
797
+ {answer.champion && (
798
+ <BasisButton
799
+ basis={answer.champion.basis}
800
+ highlightKey="champion"
801
+ highlight={highlight}
802
+ onHighlight={onHighlight}
803
+ className="px-2.5 py-1.5 bg-surface-1"
804
+ title={answer.champion.basis.length ? 'Click to highlight the events behind this champion' : undefined}
805
+ >
806
+ <span className="flex items-center gap-2 mb-0.5">
807
+ {champion?.leafId && <Badge variant="accent">{champion.leafId}</Badge>}
808
+ {answer.champion.rule && (
809
+ <span className="font-mono text-[10px] text-text-4">rule: {answer.champion.rule}</span>
810
+ )}
811
+ </span>
812
+ {typeof champion?.text === 'string' && (
813
+ <span className="block text-sm text-text-1 leading-relaxed whitespace-pre-wrap">
814
+ {champion.text.length > 400 ? `${champion.text.slice(0, 400)}…` : champion.text}
815
+ </span>
816
+ )}
817
+ </BasisButton>
818
+ )}
819
+
820
+ {conf && (
821
+ <BasisButton
822
+ basis={Array.isArray(conf.basis) ? conf.basis : []}
823
+ highlightKey="confidence"
824
+ highlight={highlight}
825
+ onHighlight={onHighlight}
826
+ className="px-2.5 py-1 text-xs text-text-3"
827
+ >
828
+ {[
829
+ conf.candidates != null && `${conf.candidates} candidates`,
830
+ conf.n_fused != null && conf.n_agents != null && `${conf.n_fused}/${conf.n_agents} fused`,
831
+ conf.n_facts != null && `${conf.n_facts} facts`,
832
+ conf.tools_grounded != null && `tools ${String(conf.tools_grounded)}`,
833
+ ].filter(Boolean).join(' · ')}
834
+ {/* §10: the U3 meter waiting for its organ — dimmed, never invented */}
835
+ <span className="text-text-4 opacity-60"> · verifier: {conf.verifier == null ? 'awaiting integration' : String(conf.verifier)}</span>
836
+ </BasisButton>
837
+ )}
838
+
839
+ {answer.evidence.length > 0 && (
840
+ <div className="flex items-center gap-1.5 flex-wrap">
841
+ {answer.evidence.slice(-3).map((ev, i) => (
842
+ <BasisButton
843
+ key={`${ev.source}-${i}`}
844
+ basis={ev.basis}
845
+ highlightKey={`evidence-${answer.evidence.length - 3 + i}`}
846
+ highlight={highlight}
847
+ onHighlight={onHighlight}
848
+ className="px-1.5 py-0.5 bg-surface-1 font-mono text-[10px] text-text-3"
849
+ title={ev.values ? JSON.stringify(ev.values) : undefined}
850
+ >
851
+ {ev.source ?? 'evidence'}
852
+ </BasisButton>
853
+ ))}
854
+ </div>
855
+ )}
856
+ </div>
857
+ );
858
+ }
859
+
860
+ // ── Hot input + interrupt/stop accountability ──────────────────────────────
861
+
862
+ const INTERRUPT_CHIP = {
863
+ sent: { label: 'sent', className: 'bg-surface-2 text-text-3 border-border' },
864
+ heard: { label: '⚡ heard', className: 'bg-warning/10 text-warning border-warning/30' },
865
+ acked: { label: 'acked', className: 'bg-success/10 text-success border-success/30' },
866
+ };
867
+
868
+ const STOP_LABEL = {
869
+ pending: 'stop pending…',
870
+ effected: 'stopped',
871
+ 'resolved-before-stop': 'resolved before stop',
872
+ };
873
+
874
+ // §7: the three-state rollup from pipeline_done — "nothing you type silently
875
+ // vanishes" deserves pixels. Shape pinned by contract §8 (nested-only):
876
+ // pipeline_done.payload.interrupts.{acked, unanswered, unconsumed}, three
877
+ // flat lists of interrupt-id strings. Rendered only when present (fail-open).
878
+ function interruptRollup(events) {
879
+ for (let i = events.length - 1; i >= 0; i--) {
880
+ if (events[i].kind !== 'pipeline_done') continue;
881
+ const src = events[i].payload?.interrupts;
882
+ if (!src || typeof src !== 'object') return null;
883
+ const states = ['acked', 'unanswered', 'unconsumed'].filter((k) => Array.isArray(src[k]));
884
+ if (states.length === 0) return null;
885
+ return Object.fromEntries(states.map((k) => [k, src[k].length]));
886
+ }
887
+ return null;
888
+ }
889
+
890
+ const ROLLUP_TONE = { acked: 'text-success', unanswered: 'text-warning', unconsumed: 'text-danger' };
891
+
892
+ function HotInput({ sessionLive, rollup }) {
893
+ const axomSelected = useGrooveStore((s) => s.axomSelected);
894
+ const interrupts = useGrooveStore((s) => s.axomInterrupts);
895
+ const stops = useGrooveStore((s) => s.axomStops);
896
+ const sendAxomInterrupt = useGrooveStore((s) => s.sendAxomInterrupt);
897
+ const sendAxomMessage = useGrooveStore((s) => s.sendAxomMessage);
898
+ const sendAxomStop = useGrooveStore((s) => s.sendAxomStop);
899
+ const addToast = useGrooveStore((s) => s.addToast);
900
+ const [text, setText] = useState('');
901
+
902
+ const key = axomSelected ? axomSessionKey(axomSelected.endpoint, axomSelected.session) : null;
903
+ const ledger = (key && interrupts[key]) || {};
904
+ const stopState = (key && stops[key]) || null;
905
+
906
+ // §12 two-verb split: message starts a turn (session idle), interrupt
907
+ // steers one already in flight. The verbs stay distinct — on a stale-`live`
908
+ // 409 we report, never silently reroute a prompt into a steer.
909
+ async function send() {
910
+ const message = text.trim();
911
+ if (!message) return;
912
+ setText(''); // input never locks — clear immediately, chips carry the truth
913
+ try {
914
+ if (sessionLive) {
915
+ const result = await sendAxomInterrupt(message);
916
+ if (result.truncated) addToast('warning', 'Interrupt truncated', 'The runtime capped it at 2000 chars');
917
+ } else {
918
+ const result = await sendAxomMessage(message);
919
+ if (result.busy) {
920
+ addToast('warning', 'Turn already in flight', 'Not sent — type again to steer the running turn instead');
921
+ setText(message);
922
+ } else if (result.tooLong) {
923
+ addToast('error', 'Message too long', `The runtime's max is ${result.max || 32768} chars — nothing was truncated`);
924
+ setText(message);
925
+ }
926
+ }
927
+ } catch (err) {
928
+ addToast('error', sessionLive ? 'Interrupt failed' : 'Message failed', err.message);
929
+ setText(message);
930
+ }
931
+ }
932
+
933
+ async function stop() {
934
+ try { await sendAxomStop(); } catch (err) { addToast('error', 'Stop failed', err.message); }
935
+ }
936
+
937
+ const chips = Object.entries(ledger).slice(-6);
938
+
939
+ return (
940
+ <div className="flex-shrink-0 border-t border-border bg-surface-2 px-3 py-2 flex flex-col gap-2">
941
+ {(chips.length > 0 || stopState || rollup) && (
942
+ <div className="flex items-center gap-1.5 flex-wrap">
943
+ {chips.map(([id, entry]) => {
944
+ const chip = INTERRUPT_CHIP[entry.state] || INTERRUPT_CHIP.sent;
945
+ return (
946
+ <span
947
+ key={id}
948
+ title={entry.text}
949
+ className={cn('px-1.5 py-0.5 rounded border text-[10px] font-medium max-w-48 truncate', chip.className)}
950
+ >
951
+ {chip.label} · {entry.text}
952
+ </span>
953
+ );
954
+ })}
955
+ {stopState && (
956
+ <span className="px-1.5 py-0.5 rounded border border-danger/30 bg-danger/10 text-danger text-[10px] font-medium">
957
+ {STOP_LABEL[stopState]}
958
+ </span>
959
+ )}
960
+ {rollup && (
961
+ <span className="ml-auto text-[10px] text-text-4" title="Interrupt accounting from the last pipeline_done rollup — every interrupt ends in exactly one state">
962
+ run rollup:{' '}
963
+ {Object.entries(rollup).map(([state, count], i) => (
964
+ <span key={state}>
965
+ {i > 0 && ' · '}
966
+ <span className={ROLLUP_TONE[state]}>{count} {state}</span>
967
+ </span>
968
+ ))}
969
+ </span>
970
+ )}
971
+ </div>
972
+ )}
973
+ <div className="flex gap-2">
974
+ <Input
975
+ value={text}
976
+ onChange={(e) => setText(e.target.value)}
977
+ onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }}
978
+ placeholder={sessionLive ? 'Type to steer Axom mid-flight…' : 'Message your Axom — starts a turn'}
979
+ className="flex-1 text-sm"
980
+ />
981
+ <Button variant="secondary" onClick={send} disabled={!axomSelected || !text.trim()}>
982
+ {sessionLive ? <><Zap size={14} className="mr-1" /> Steer</> : <><Send size={14} className="mr-1" /> Send</>}
983
+ </Button>
984
+ <Button
985
+ variant="danger"
986
+ onClick={stop}
987
+ disabled={!axomSelected || stopState === 'pending'}
988
+ >
989
+ <OctagonX size={14} className="mr-1" /> Stop
990
+ </Button>
991
+ </div>
992
+ </div>
993
+ );
994
+ }
995
+
996
+ // ── View root ───────────────────────────────────────────────────────────────
997
+
998
+ export default function AxomView() {
999
+ const axomStatus = useGrooveStore((s) => s.axomStatus);
1000
+ const axomSelected = useGrooveStore((s) => s.axomSelected);
1001
+ const axomEvents = useGrooveStore((s) => s.axomEvents);
1002
+ const axomAnomalies = useGrooveStore((s) => s.axomAnomalies);
1003
+ const axomStops = useGrooveStore((s) => s.axomStops);
1004
+ const selectAxomSession = useGrooveStore((s) => s.selectAxomSession);
1005
+ const sendAxomStop = useGrooveStore((s) => s.sendAxomStop);
1006
+ const [highlight, setHighlight] = useState(null);
1007
+
1008
+ const endpoints = axomStatus?.endpoints || [];
1009
+ // v0 renders the first endpoint; the config supports several (mesh later).
1010
+ const endpoint = endpoints[0];
1011
+
1012
+ // Auto-select the only session so the tab is alive without a click.
1013
+ useEffect(() => {
1014
+ if (!endpoint || axomSelected) return;
1015
+ const live = endpoint.sessions.find((s) => s.live) || endpoint.sessions[0];
1016
+ if (live) selectAxomSession(endpoint.name, live.session);
1017
+ }, [endpoint, axomSelected, selectAxomSession]);
1018
+
1019
+ const sessionKeyStr = axomSelected ? axomSessionKey(axomSelected.endpoint, axomSelected.session) : null;
1020
+ const events = useMemo(
1021
+ () => (sessionKeyStr ? axomEvents[sessionKeyStr] || [] : []),
1022
+ [sessionKeyStr, axomEvents],
1023
+ );
1024
+
1025
+ // A highlight belongs to one session's stream — drop it on switch.
1026
+ useEffect(() => { setHighlight(null); }, [sessionKeyStr]);
1027
+
1028
+ const answer = useMemo(() => livingAnswer(events), [events]);
1029
+
1030
+ if (!endpoint) return <Onboarding />;
1031
+
1032
+ const selectedSession = axomSelected
1033
+ && endpoint.sessions.find((s) => s.session === axomSelected.session);
1034
+
1035
+ return (
1036
+ <div className="h-full flex flex-col bg-surface-1">
1037
+ <RuntimeHeader endpoint={endpoint} anomalies={(sessionKeyStr && axomAnomalies[sessionKeyStr]) || []} />
1038
+ <div className="flex-1 flex min-h-0">
1039
+ <SessionRail endpoint={endpoint} />
1040
+ <div className="flex-1 flex flex-col min-w-0 min-h-0">
1041
+ <WardrobeStrip about={endpoint.about} events={events} />
1042
+ {answer && (
1043
+ <LivingAnswerPanel
1044
+ answer={answer}
1045
+ onStop={() => sendAxomStop().catch(() => {})}
1046
+ stopState={(sessionKeyStr && axomStops[sessionKeyStr]) || null}
1047
+ highlight={highlight}
1048
+ onHighlight={setHighlight}
1049
+ />
1050
+ )}
1051
+ <div className="flex-1 flex min-h-0">
1052
+ <NarratedStream events={events} highlight={highlight} onHighlight={setHighlight} />
1053
+ <RawTicker events={events} highlight={highlight} />
1054
+ </div>
1055
+ <HotInput sessionLive={!!selectedSession?.live} rollup={interruptRollup(events)} />
1056
+ </div>
1057
+ </div>
1058
+ </div>
1059
+ );
1060
+ }