groove-dev 0.27.198 → 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 (44) hide show
  1. package/axom-integration/Screenshot_2026-07-25_at_6.44.56_PM.png +0 -0
  2. package/axom-integration/Screenshot_2026-07-25_at_6.45.35_PM.png +0 -0
  3. package/node_modules/@groove-dev/cli/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/package.json +1 -1
  5. package/node_modules/@groove-dev/daemon/src/axom-connector.js +30 -2
  6. package/node_modules/@groove-dev/daemon/src/axom-install.js +24 -2
  7. package/node_modules/@groove-dev/daemon/src/axom-remote.js +230 -0
  8. package/node_modules/@groove-dev/daemon/src/axom-server.js +22 -2
  9. package/node_modules/@groove-dev/daemon/src/index.js +3 -0
  10. package/node_modules/@groove-dev/daemon/src/routes/axom.js +73 -2
  11. package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +190 -8
  12. package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +44 -1
  13. package/node_modules/@groove-dev/daemon/test/axom-remote.test.js +115 -0
  14. package/node_modules/@groove-dev/daemon/test/axom-server.test.js +50 -9
  15. package/node_modules/@groove-dev/gui/dist/assets/{index-b9dKN6cq.js → index-BbA-X4CE.js} +243 -233
  16. package/node_modules/@groove-dev/gui/dist/assets/index-BbE3qX83.css +1 -0
  17. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  18. package/node_modules/@groove-dev/gui/package.json +1 -1
  19. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +3 -1
  20. package/node_modules/@groove-dev/gui/src/stores/groove.js +4 -1
  21. package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +83 -1
  22. package/node_modules/@groove-dev/gui/src/views/axom.jsx +1147 -310
  23. package/node_modules/@groove-dev/gui/src/views/settings.jsx +123 -70
  24. package/package.json +1 -1
  25. package/packages/cli/package.json +1 -1
  26. package/packages/daemon/package.json +1 -1
  27. package/packages/daemon/src/axom-connector.js +30 -2
  28. package/packages/daemon/src/axom-install.js +24 -2
  29. package/packages/daemon/src/axom-remote.js +230 -0
  30. package/packages/daemon/src/axom-server.js +22 -2
  31. package/packages/daemon/src/index.js +3 -0
  32. package/packages/daemon/src/routes/axom.js +73 -2
  33. package/packages/daemon/src/routes/innerchat.js +190 -8
  34. package/packages/gui/dist/assets/{index-b9dKN6cq.js → index-BbA-X4CE.js} +243 -233
  35. package/packages/gui/dist/assets/index-BbE3qX83.css +1 -0
  36. package/packages/gui/dist/index.html +2 -2
  37. package/packages/gui/package.json +1 -1
  38. package/packages/gui/src/components/agents/agent-feed.jsx +3 -1
  39. package/packages/gui/src/stores/groove.js +4 -1
  40. package/packages/gui/src/stores/slices/axom-slice.js +83 -1
  41. package/packages/gui/src/views/axom.jsx +1147 -310
  42. package/packages/gui/src/views/settings.jsx +123 -70
  43. package/node_modules/@groove-dev/gui/dist/assets/index-RbtaI6l7.css +0 -1
  44. package/packages/gui/dist/assets/index-RbtaI6l7.css +0 -1
@@ -10,7 +10,7 @@
10
10
  // fail-deceptive).
11
11
 
12
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';
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
14
  import { motion } from 'framer-motion';
15
15
  import { useGrooveStore } from '../stores/groove';
16
16
  import { axomSessionKey } from '../stores/slices/axom-slice';
@@ -18,6 +18,10 @@ import { Button } from '../components/ui/button';
18
18
  import { Input } from '../components/ui/input';
19
19
  import { Badge } from '../components/ui/badge';
20
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';
21
25
  import { cn } from '../lib/cn';
22
26
 
23
27
  // ── Onboarding — the front door ─────────────────────────────────────────────
@@ -86,7 +90,84 @@ const FEATURE_CHIPS = [
86
90
  { icon: HardDrive, label: 'Memory that stays yours' },
87
91
  ];
88
92
 
89
- function Onboarding() {
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);
90
171
  const saveAxomEndpoints = useGrooveStore((s) => s.saveAxomEndpoints);
91
172
  const startAxomInstall = useGrooveStore((s) => s.startAxomInstall);
92
173
  const startAxomInstance = useGrooveStore((s) => s.startAxomInstance);
@@ -124,7 +205,10 @@ function Onboarding() {
124
205
  async function connect() {
125
206
  setSaving(true);
126
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.
127
210
  await saveAxomEndpoints([{ name: 'local', url: url.trim() }]);
211
+ onBack?.();
128
212
  } catch (err) {
129
213
  addToast('error', 'Could not save endpoint', err.message);
130
214
  } finally {
@@ -132,12 +216,71 @@ function Onboarding() {
132
216
  }
133
217
  }
134
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
+
135
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;
136
254
  const ramPct = hw && req ? Math.min(100, (hw.totalRamGb / req.recommendedRamGb) * 100) : 0;
137
255
 
138
256
  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">
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
+ )}>
141
284
 
142
285
  {/* ── Identity pane — who Axom is, stated once ────────────────── */}
143
286
  <motion.div
@@ -257,27 +400,36 @@ function Onboarding() {
257
400
  {/* Path 1 — run it here */}
258
401
  <section className={cn(
259
402
  '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',
403
+ connectFirst ? 'bg-surface-1/60 border-border-subtle' : 'axom-panel border-accent/25',
261
404
  )}>
262
- <span aria-hidden className={cn('absolute inset-x-0 top-0 h-px', insufficient ? 'axom-edge-muted' : 'axom-edge-accent')} />
405
+ <span aria-hidden className={cn('absolute inset-x-0 top-0 h-px', connectFirst ? 'axom-edge-muted' : 'axom-edge-accent')} />
263
406
  <div className="flex items-center justify-between gap-2">
264
407
  <div className="flex items-center gap-2.5">
265
408
  <span className={cn(
266
409
  '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',
410
+ connectFirst ? 'bg-surface-2 border-border-subtle text-text-4' : 'bg-accent/10 border-accent/25 text-accent',
268
411
  )}>
269
412
  <Download size={14} strokeWidth={1.75} />
270
413
  </span>
271
- <span className={cn('text-sm font-semibold tracking-tight', insufficient ? 'text-text-3' : 'text-text-0')}>Run it here</span>
414
+ <span className={cn('text-sm font-semibold tracking-tight', connectFirst ? 'text-text-3' : 'text-text-0')}>Run it here</span>
272
415
  </div>
273
- {!insufficient && hw && <Badge variant="accent">recommended here</Badge>}
416
+ {!connectFirst && hw && <Badge variant="accent">recommended here</Badge>}
274
417
  </div>
275
418
  <p className="text-xs text-text-3 leading-relaxed flex-1">
276
419
  One verified download — runtime and models (~{req?.downloadGb ?? 4.4} GB) —
277
420
  then your Axom lives on this machine, fully self-contained.
278
421
  </p>
279
422
 
280
- {insufficient ? (
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 ? (
281
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">
282
434
  <TriangleAlert size={13} className="flex-shrink-0 mt-0.5 text-text-4" />
283
435
  Locked below the {req?.minRamGb} GB memory floor — this machine stays comfortable.
@@ -306,7 +458,11 @@ function Onboarding() {
306
458
  )}
307
459
  </div>
308
460
  ) : (
309
- <Button variant="primary" size="lg" onClick={installLocally} disabled={!canInstall} className="w-full">
461
+ <Button
462
+ variant="primary" size="lg" onClick={installLocally}
463
+ disabled={!canInstall || checkingAvailability}
464
+ className="w-full"
465
+ >
310
466
  <Download size={15} className="mr-1.5" /> Install Axom locally
311
467
  </Button>
312
468
  )}
@@ -320,20 +476,20 @@ function Onboarding() {
320
476
  {/* Path 2 — connect to one */}
321
477
  <section className={cn(
322
478
  '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',
479
+ connectFirst ? 'axom-panel border-accent/25' : 'bg-surface-1/60 border-border-subtle',
324
480
  )}>
325
- <span aria-hidden className={cn('absolute inset-x-0 top-0 h-px', insufficient ? 'axom-edge-accent' : 'axom-edge-muted')} />
481
+ <span aria-hidden className={cn('absolute inset-x-0 top-0 h-px', connectFirst ? 'axom-edge-accent' : 'axom-edge-muted')} />
326
482
  <div className="flex items-center justify-between gap-2">
327
483
  <div className="flex items-center gap-2.5">
328
484
  <span className={cn(
329
485
  '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',
486
+ connectFirst ? 'bg-accent/10 border-accent/25 text-accent' : 'bg-surface-2 border-border-subtle text-text-3',
331
487
  )}>
332
488
  <Globe size={14} strokeWidth={1.75} />
333
489
  </span>
334
490
  <span className="text-sm font-semibold tracking-tight text-text-0">Connect to an Axom</span>
335
491
  </div>
336
- {insufficient && <Badge variant="accent">recommended here</Badge>}
492
+ {connectFirst && <Badge variant="accent">recommended here</Badge>}
337
493
  </div>
338
494
 
339
495
  <div className="flex flex-col gap-2">
@@ -345,11 +501,41 @@ function Onboarding() {
345
501
  placeholder="Paste an Axom endpoint…"
346
502
  className="flex-1 font-mono text-xs"
347
503
  />
348
- <Button variant={insufficient ? 'primary' : 'secondary'} onClick={connect} disabled={saving || !url.trim()}>
504
+ <Button variant={connectFirst ? 'primary' : 'secondary'} onClick={connect} disabled={saving || !url.trim()}>
349
505
  <Plug size={14} className="mr-1.5" />
350
506
  {saving ? 'Connecting…' : 'Connect'}
351
507
  </Button>
352
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
+
353
539
  <p className="text-xs text-text-4 leading-relaxed">
354
540
  Copied from another GROOVE's "My endpoint", over your own secure channel.
355
541
  </p>
@@ -400,53 +586,267 @@ function Onboarding() {
400
586
  }
401
587
 
402
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
+ }
403
639
 
404
- function RuntimeHeader({ endpoint, anomalies }) {
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 }) {
405
710
  const addToast = useGrooveStore((s) => s.addToast);
406
711
  const about = endpoint.about;
407
712
  const dotStatus = endpoint.status === 'connected' ? 'running'
408
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
+
409
720
  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">
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">
412
723
  <StatusDot status={dotStatus} size="sm" />
413
- <span className="text-sm font-semibold text-text-0">{endpoint.name}</span>
724
+ <span className="text-xs font-semibold text-text-0 truncate">{endpoint.name}</span>
414
725
  <button
415
726
  onClick={() => { navigator.clipboard.writeText(endpoint.url); addToast('success', 'Endpoint copied'); }}
416
- className="text-xs text-text-4 font-mono hover:text-accent cursor-pointer"
727
+ className="font-mono text-2xs text-text-4 hover:text-accent truncate max-w-[15rem] cursor-pointer transition-colors"
417
728
  title="Copy endpoint URL"
418
729
  >
419
730
  {endpoint.url}
420
731
  </button>
421
732
  </div>
422
- {about?.family && <Badge variant="accent">{about.family}</Badge>}
423
- {about?.record?.benchmark && (
424
- <Badge>{about.record['pass@1']} {about.record.benchmark}</Badge>
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>
425
744
  )}
426
- {about?.narrator && (
427
- <Badge className="font-mono">narrator: {about.narrator}</Badge>
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>
428
752
  )}
753
+
754
+ {/* State — the only place hue is allowed up here */}
429
755
  {endpoint.status === 'error' && (
430
- <span className="text-xs text-danger">{endpoint.error || 'unreachable'}</span>
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>
431
759
  )}
432
760
  {(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
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}
435
766
  </span>
436
767
  )}
437
768
  {anomalies?.length > 0 && (
438
769
  <span
439
- className="flex items-center gap-1 text-xs text-warning"
770
+ className="flex items-center gap-1 text-2xs text-warning flex-shrink-0"
440
771
  title={anomalies.slice(-3).map((a) => `${a.eventId}: ${a.message}`).join('\n')}
441
772
  >
442
- <TriangleAlert size={12} /> {anomalies.length} contract {anomalies.length === 1 ? 'anomaly' : 'anomalies'}
773
+ <TriangleAlert size={11} /> {anomalies.length} contract {anomalies.length === 1 ? 'anomaly' : 'anomalies'}
443
774
  </span>
444
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>
445
807
  </div>
446
808
  );
447
809
  }
448
810
 
449
- // ── Session rail ────────────────────────────────────────────────────────────
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) ────────────────────────────────
450
850
 
451
851
  function InstanceControls() {
452
852
  const instances = useGrooveStore((s) => s.axomInstances);
@@ -455,10 +855,10 @@ function InstanceControls() {
455
855
  const addToast = useGrooveStore((s) => s.addToast);
456
856
  if (instances.length === 0) return null;
457
857
  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>
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>
460
860
  {instances.map((inst) => (
461
- <div key={inst.id} className="flex items-center gap-2 text-xs text-text-2">
861
+ <div key={inst.id} className="flex items-center gap-2 text-2xs text-text-3">
462
862
  <StatusDot status={inst.status === 'running' ? 'running' : inst.status === 'error' ? 'crashed' : 'completed'} size="sm" />
463
863
  <span className="font-mono truncate" title={inst.error || inst.dataDir}>{inst.id}:{inst.port}</span>
464
864
  <button
@@ -466,10 +866,10 @@ function InstanceControls() {
466
866
  ? stopAxomInstance(inst.id)
467
867
  : startAxomInstance(inst.id)
468
868
  ).catch((err) => addToast('error', 'Instance action failed', err.message))}
469
- className="ml-auto text-text-3 hover:text-accent cursor-pointer"
869
+ className="ml-auto text-text-4 hover:text-text-1 cursor-pointer transition-colors"
470
870
  title={inst.status === 'running' ? 'Stop instance' : 'Start instance'}
471
871
  >
472
- {inst.status === 'running' ? <Square size={12} /> : <Play size={12} />}
872
+ {inst.status === 'running' ? <Square size={11} /> : <Play size={11} />}
473
873
  </button>
474
874
  </div>
475
875
  ))}
@@ -477,58 +877,20 @@ function InstanceControls() {
477
877
  );
478
878
  }
479
879
 
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
880
  // ── Raw ticker — every envelope, verbatim, nothing dropped ─────────────────
525
-
526
- const KIND_TONE = {
527
- narration: 'text-accent',
528
- interrupt: 'text-warning', interrupt_ack: 'text-warning',
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',
529
892
  stop_requested: 'text-danger', stop_effected: 'text-danger',
530
- pipeline_done: 'text-success', resolution: 'text-success',
531
- leaf_swap: 'text-accent',
893
+ leaf_swap: 'text-text-1',
532
894
  };
533
895
 
534
896
  function payloadPreview(payload) {
@@ -539,7 +901,7 @@ function payloadPreview(payload) {
539
901
  try { return JSON.stringify(payload); } catch { return String(payload); }
540
902
  }
541
903
 
542
- function RawTicker({ events, highlight }) {
904
+ function RawTicker({ events, highlight, onClose }) {
543
905
  const scrollRef = useRef(null);
544
906
  const pinnedRef = useRef(true);
545
907
 
@@ -552,8 +914,16 @@ function RawTicker({ events, highlight }) {
552
914
  // cited event into view — trust becomes tactile.
553
915
  useEffect(() => {
554
916
  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' });
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
+ });
557
927
  }, [highlight]);
558
928
 
559
929
  function onScroll() {
@@ -564,40 +934,50 @@ function RawTicker({ events, highlight }) {
564
934
  const cited = new Set(highlight?.ids || []);
565
935
 
566
936
  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>
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>
571
949
  </div>
572
- <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-3 pb-2 font-mono text-xs leading-5">
950
+ <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto px-2 py-1.5 font-mono text-2xs leading-5">
573
951
  {events.length === 0 && (
574
- <p className="text-text-4 font-sans">Waiting for events…</p>
952
+ <p className="px-1 text-text-4 font-sans text-xs">Waiting for events…</p>
575
953
  )}
576
954
  {events.map((e) => (
577
955
  <div
578
956
  key={e.id}
579
957
  data-ev={e.id}
580
958
  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',
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',
583
961
  )}
584
962
  >
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>
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>
589
967
  </div>
590
968
  ))}
591
969
  </div>
970
+ <InstanceControls />
592
971
  </div>
593
972
  );
594
973
  }
595
974
 
596
- // ── Narrated streamtelemetry-grounded or absent ─────────────────────────
975
+ // ── Conversation modelevents folded into turns ──────────────────────────
597
976
  //
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).
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.
601
981
 
602
982
  // §9: narration.payload = {text, cites: [ev-ids]} — `cites` is the field,
603
983
  // no variant exists.
@@ -606,69 +986,460 @@ function narrationCites(payload) {
606
986
  return Array.isArray(cites) ? cites.filter((c) => typeof c === 'string') : [];
607
987
  }
608
988
 
609
- function NarratedStream({ events, highlight, onHighlight }) {
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 }) {
610
1363
  const scrollRef = useRef(null);
611
1364
  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
- );
1365
+ const turns = useMemo(() => buildTurns(events), [events]);
1366
+ const eventsById = useMemo(() => Object.fromEntries(events.map((e) => [e.id, e])), [events]);
619
1367
 
620
1368
  useEffect(() => {
621
1369
  const el = scrollRef.current;
1370
+ // Scoped to this pane only — never scrollIntoView (see RawTicker).
622
1371
  if (el && pinnedRef.current) el.scrollTop = el.scrollHeight;
623
- }, [narrations.length]);
1372
+ }, [events.length]);
624
1373
 
625
1374
  function onScroll() {
626
1375
  const el = scrollRef.current;
627
- pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
1376
+ pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
628
1377
  }
629
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
+
630
1394
  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>
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>
642
1406
  )}
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>
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
+ />
659
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' }} />}
660
1437
  </div>
661
1438
  </div>
662
1439
  );
663
1440
  }
664
1441
 
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
- }
1442
+ // ── Wardrobe — the worn leaf, never guessed ────────────────────────────────
672
1443
 
673
1444
  function WardrobeStrip({ about, events }) {
674
1445
  const swaps = useMemo(
@@ -681,22 +1452,18 @@ function WardrobeStrip({ about, events }) {
681
1452
  const current = swaps.length ? (swaps[swaps.length - 1].leaf ?? 'UNKNOWN') : 'UNKNOWN';
682
1453
 
683
1454
  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>
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>
688
1460
  {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
- ))}
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(' ← ')}
696
1463
  </span>
697
1464
  )}
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(' · ') : '—'}
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(' · ') : '—'}
700
1467
  </span>
701
1468
  </div>
702
1469
  );
@@ -747,14 +1514,15 @@ function livingAnswer(events) {
747
1514
  return state;
748
1515
  }
749
1516
 
750
- const WATERMARK_BADGE = {
751
- PROVISIONAL: { variant: 'warning', dot: 'pulse' },
752
- SETTLED: { variant: 'success', dot: true },
753
- STOPPED: { variant: 'danger', dot: true },
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',
754
1522
  };
755
1523
 
756
- // A provenance-bearing block: clicking highlights its basis events in the
757
- // ticker, exactly like narration cites.
1524
+ // A provenance-bearing block: clicking highlights its basis events, exactly
1525
+ // like narration cites.
758
1526
  function BasisButton({ basis, highlightKey, highlight, onHighlight, className, children, title }) {
759
1527
  const active = highlight?.narrationId === highlightKey;
760
1528
  return (
@@ -762,9 +1530,9 @@ function BasisButton({ basis, highlightKey, highlight, onHighlight, className, c
762
1530
  title={title}
763
1531
  onClick={() => basis.length && onHighlight(active ? null : { narrationId: highlightKey, ids: basis })}
764
1532
  className={cn(
765
- 'text-left rounded-md border transition-colors',
1533
+ 'text-left rounded transition-colors',
766
1534
  basis.length ? 'cursor-pointer' : 'cursor-default',
767
- active ? 'bg-accent/10 border-accent/30' : 'border-transparent hover:border-border',
1535
+ active ? 'bg-surface-3' : 'hover:bg-surface-2',
768
1536
  className,
769
1537
  )}
770
1538
  >
@@ -774,97 +1542,111 @@ function BasisButton({ basis, highlightKey, highlight, onHighlight, className, c
774
1542
  }
775
1543
 
776
1544
  function LivingAnswerPanel({ answer, onStop, stopState, highlight, onHighlight }) {
777
- const badge = WATERMARK_BADGE[answer.watermark];
1545
+ const [open, setOpen] = useState(false);
778
1546
  const champion = answer.champion ? answer.byFiring[answer.champion.to] : null;
779
1547
  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>
1548
+ const live = answer.watermark === 'PROVISIONAL';
796
1549
 
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
- )}
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}
811
1558
  </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>
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>
816
1572
  )}
817
- </BasisButton>
818
- )}
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>
819
1581
 
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
- )}
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
+ )}
838
1604
 
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
- )}
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>
856
1644
  </div>
857
1645
  );
858
1646
  }
859
1647
 
860
1648
  // ── Hot input + interrupt/stop accountability ──────────────────────────────
861
1649
 
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
1650
  const STOP_LABEL = {
869
1651
  pending: 'stop pending…',
870
1652
  effected: 'stopped',
@@ -887,8 +1669,6 @@ function interruptRollup(events) {
887
1669
  return null;
888
1670
  }
889
1671
 
890
- const ROLLUP_TONE = { acked: 'text-success', unanswered: 'text-warning', unconsumed: 'text-danger' };
891
-
892
1672
  function HotInput({ sessionLive, rollup }) {
893
1673
  const axomSelected = useGrooveStore((s) => s.axomSelected);
894
1674
  const interrupts = useGrooveStore((s) => s.axomInterrupts);
@@ -925,7 +1705,13 @@ function HotInput({ sessionLive, rollup }) {
925
1705
  }
926
1706
  }
927
1707
  } catch (err) {
928
- addToast('error', sessionLive ? 'Interrupt failed' : 'Message failed', err.message);
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
+ }
929
1715
  setText(message);
930
1716
  }
931
1717
  }
@@ -934,60 +1720,84 @@ function HotInput({ sessionLive, rollup }) {
934
1720
  try { await sendAxomStop(); } catch (err) { addToast('error', 'Stop failed', err.message); }
935
1721
  }
936
1722
 
937
- const chips = Object.entries(ledger).slice(-6);
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);
938
1727
 
939
1728
  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}
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}
952
1737
  </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>
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" />
967
1772
  </span>
968
- ))}
969
- </span>
970
- )}
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>
971
1800
  </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
1801
  </div>
992
1802
  </div>
993
1803
  );
@@ -999,11 +1809,16 @@ export default function AxomView() {
999
1809
  const axomStatus = useGrooveStore((s) => s.axomStatus);
1000
1810
  const axomSelected = useGrooveStore((s) => s.axomSelected);
1001
1811
  const axomEvents = useGrooveStore((s) => s.axomEvents);
1812
+ const axomPrompts = useGrooveStore((s) => s.axomPrompts);
1002
1813
  const axomAnomalies = useGrooveStore((s) => s.axomAnomalies);
1003
1814
  const axomStops = useGrooveStore((s) => s.axomStops);
1004
1815
  const selectAxomSession = useGrooveStore((s) => s.selectAxomSession);
1005
1816
  const sendAxomStop = useGrooveStore((s) => s.sendAxomStop);
1006
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);
1007
1822
 
1008
1823
  const endpoints = axomStatus?.endpoints || [];
1009
1824
  // v0 renders the first endpoint; the config supports several (mesh later).
@@ -1022,38 +1837,60 @@ export default function AxomView() {
1022
1837
  [sessionKeyStr, axomEvents],
1023
1838
  );
1024
1839
 
1840
+ const prompts = useMemo(
1841
+ () => (sessionKeyStr ? axomPrompts[sessionKeyStr] || [] : []),
1842
+ [sessionKeyStr, axomPrompts],
1843
+ );
1844
+
1025
1845
  // A highlight belongs to one session's stream — drop it on switch.
1026
1846
  useEffect(() => { setHighlight(null); }, [sessionKeyStr]);
1027
1847
 
1028
1848
  const answer = useMemo(() => livingAnswer(events), [events]);
1029
1849
 
1030
- if (!endpoint) return <Onboarding />;
1850
+ if (!endpoint || showSetup) {
1851
+ return <Onboarding onBack={endpoint ? () => setShowSetup(false) : null} />;
1852
+ }
1031
1853
 
1032
1854
  const selectedSession = axomSelected
1033
1855
  && endpoint.sessions.find((s) => s.session === axomSelected.session);
1856
+ const sessionLive = !!selectedSession?.live;
1034
1857
 
1035
1858
  return (
1036
1859
  <div className="h-full flex flex-col bg-surface-1">
1037
- <RuntimeHeader endpoint={endpoint} anomalies={(sessionKeyStr && axomAnomalies[sessionKeyStr]) || []} />
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} />
1038
1869
  <div className="flex-1 flex min-h-0">
1039
- <SessionRail endpoint={endpoint} />
1040
1870
  <div className="flex-1 flex flex-col min-w-0 min-h-0">
1041
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
+ />
1042
1880
  {answer && (
1043
1881
  <LivingAnswerPanel
1044
1882
  answer={answer}
1045
1883
  onStop={() => sendAxomStop().catch(() => {})}
1046
1884
  stopState={(sessionKeyStr && axomStops[sessionKeyStr]) || null}
1047
1885
  highlight={highlight}
1048
- onHighlight={setHighlight}
1886
+ onHighlight={(h) => { setHighlight(h); if (h) setTickerOpen(true); }}
1049
1887
  />
1050
1888
  )}
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)} />
1889
+ <HotInput sessionLive={sessionLive} rollup={interruptRollup(events)} />
1056
1890
  </div>
1891
+ {tickerOpen && (
1892
+ <RawTicker events={events} highlight={highlight} onClose={() => setTickerOpen(false)} />
1893
+ )}
1057
1894
  </div>
1058
1895
  </div>
1059
1896
  );