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.
- package/axom-integration/Screenshot_2026-07-25_at_5.25.30_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_5.33.26_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_6.11.53_PM.png +0 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +5 -5
- package/node_modules/@groove-dev/daemon/src/axom-connector.js +340 -0
- package/node_modules/@groove-dev/daemon/src/axom-install.js +140 -0
- package/node_modules/@groove-dev/daemon/src/axom-server.js +229 -0
- package/node_modules/@groove-dev/daemon/src/chatstore.js +104 -0
- package/node_modules/@groove-dev/daemon/src/federation.js +6 -0
- package/node_modules/@groove-dev/daemon/src/index.js +14 -2
- package/node_modules/@groove-dev/daemon/src/innerchat-docs.js +8 -1
- package/node_modules/@groove-dev/daemon/src/innerchat-relay.js +89 -0
- package/node_modules/@groove-dev/daemon/src/innerchat.js +261 -1
- package/node_modules/@groove-dev/daemon/src/introducer.js +1 -1
- package/node_modules/@groove-dev/daemon/src/network-guard.js +56 -0
- package/node_modules/@groove-dev/daemon/src/process.js +2 -2
- package/node_modules/@groove-dev/daemon/src/providers/axom.js +66 -0
- package/node_modules/@groove-dev/daemon/src/providers/index.js +2 -0
- package/node_modules/@groove-dev/daemon/src/routes/agents.js +1 -175
- package/node_modules/@groove-dev/daemon/src/routes/axom.js +196 -0
- package/node_modules/@groove-dev/daemon/src/routes/chat-history.js +34 -0
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +152 -20
- package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +412 -0
- package/node_modules/@groove-dev/daemon/test/axom-server.test.js +187 -0
- package/node_modules/@groove-dev/daemon/test/chatstore.test.js +86 -0
- package/node_modules/@groove-dev/daemon/test/innerchat-relay.test.js +251 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-RbtaI6l7.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-b9dKN6cq.js +1074 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/App.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/app.css +53 -0
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +24 -163
- package/node_modules/@groove-dev/gui/src/stores/slices/agents-slice.js +52 -2
- package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +197 -0
- package/node_modules/@groove-dev/gui/src/views/axom.jsx +1060 -0
- package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +178 -64
- package/package.json +2 -2
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +5 -5
- package/packages/daemon/src/axom-connector.js +340 -0
- package/packages/daemon/src/axom-install.js +140 -0
- package/packages/daemon/src/axom-server.js +229 -0
- package/packages/daemon/src/chatstore.js +104 -0
- package/packages/daemon/src/federation.js +6 -0
- package/packages/daemon/src/index.js +14 -2
- package/packages/daemon/src/innerchat-docs.js +8 -1
- package/packages/daemon/src/innerchat-relay.js +89 -0
- package/packages/daemon/src/innerchat.js +261 -1
- package/packages/daemon/src/introducer.js +1 -1
- package/packages/daemon/src/network-guard.js +56 -0
- package/packages/daemon/src/process.js +2 -2
- package/packages/daemon/src/providers/axom.js +66 -0
- package/packages/daemon/src/providers/index.js +2 -0
- package/packages/daemon/src/routes/agents.js +1 -175
- package/packages/daemon/src/routes/axom.js +196 -0
- package/packages/daemon/src/routes/chat-history.js +34 -0
- package/packages/daemon/src/routes/innerchat.js +152 -20
- package/packages/gui/dist/assets/index-RbtaI6l7.css +1 -0
- package/packages/gui/dist/assets/index-b9dKN6cq.js +1074 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/App.jsx +2 -2
- package/packages/gui/src/app.css +53 -0
- package/packages/gui/src/components/agents/agent-config.jsx +2 -2
- package/packages/gui/src/components/agents/spawn-wizard.jsx +3 -3
- package/packages/gui/src/components/layout/activity-bar.jsx +3 -3
- package/packages/gui/src/components/layout/breadcrumb-bar.jsx +1 -1
- package/packages/gui/src/components/layout/command-palette.jsx +1 -1
- package/packages/gui/src/stores/groove.js +24 -163
- package/packages/gui/src/stores/slices/agents-slice.js +52 -2
- package/packages/gui/src/stores/slices/axom-slice.js +197 -0
- package/packages/gui/src/views/axom.jsx +1060 -0
- package/packages/gui/src/views/marketplace.jsx +1 -1
- package/packages/gui/src/views/settings.jsx +178 -64
- package/node_modules/@groove-dev/daemon/src/conversations.js +0 -576
- package/node_modules/@groove-dev/gui/dist/assets/index-Cat5pJUx.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-L8xSnvHj.js +0 -1070
- package/node_modules/@groove-dev/gui/src/components/chat/chat-header.jsx +0 -198
- package/node_modules/@groove-dev/gui/src/components/chat/chat-input.jsx +0 -367
- package/node_modules/@groove-dev/gui/src/components/chat/chat-messages.jsx +0 -527
- package/node_modules/@groove-dev/gui/src/components/chat/chat-view.jsx +0 -212
- package/node_modules/@groove-dev/gui/src/components/chat/conversation-list.jsx +0 -163
- package/node_modules/@groove-dev/gui/src/components/chat/model-picker.jsx +0 -201
- package/node_modules/@groove-dev/gui/src/stores/slices/chat-slice.js +0 -227
- package/node_modules/@groove-dev/gui/src/views/chat.jsx +0 -6
- package/packages/daemon/src/conversations.js +0 -576
- package/packages/gui/dist/assets/index-Cat5pJUx.css +0 -1
- package/packages/gui/dist/assets/index-L8xSnvHj.js +0 -1070
- package/packages/gui/src/components/chat/chat-header.jsx +0 -198
- package/packages/gui/src/components/chat/chat-input.jsx +0 -367
- package/packages/gui/src/components/chat/chat-messages.jsx +0 -527
- package/packages/gui/src/components/chat/chat-view.jsx +0 -212
- package/packages/gui/src/components/chat/conversation-list.jsx +0 -163
- package/packages/gui/src/components/chat/model-picker.jsx +0 -201
- package/packages/gui/src/stores/slices/chat-slice.js +0 -227
- 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
|
+
}
|