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.
- package/axom-integration/Screenshot_2026-07-25_at_6.44.56_PM.png +0 -0
- package/axom-integration/Screenshot_2026-07-25_at_6.45.35_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/axom-connector.js +30 -2
- package/node_modules/@groove-dev/daemon/src/axom-install.js +24 -2
- package/node_modules/@groove-dev/daemon/src/axom-remote.js +230 -0
- package/node_modules/@groove-dev/daemon/src/axom-server.js +22 -2
- package/node_modules/@groove-dev/daemon/src/index.js +3 -0
- package/node_modules/@groove-dev/daemon/src/routes/axom.js +73 -2
- package/node_modules/@groove-dev/daemon/src/routes/innerchat.js +190 -8
- package/node_modules/@groove-dev/daemon/test/axom-connector.test.js +44 -1
- package/node_modules/@groove-dev/daemon/test/axom-remote.test.js +115 -0
- package/node_modules/@groove-dev/daemon/test/axom-server.test.js +50 -9
- package/node_modules/@groove-dev/gui/dist/assets/{index-b9dKN6cq.js → index-BbA-X4CE.js} +243 -233
- package/node_modules/@groove-dev/gui/dist/assets/index-BbE3qX83.css +1 -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/components/agents/agent-feed.jsx +3 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +4 -1
- package/node_modules/@groove-dev/gui/src/stores/slices/axom-slice.js +83 -1
- package/node_modules/@groove-dev/gui/src/views/axom.jsx +1147 -310
- package/node_modules/@groove-dev/gui/src/views/settings.jsx +123 -70
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/axom-connector.js +30 -2
- package/packages/daemon/src/axom-install.js +24 -2
- package/packages/daemon/src/axom-remote.js +230 -0
- package/packages/daemon/src/axom-server.js +22 -2
- package/packages/daemon/src/index.js +3 -0
- package/packages/daemon/src/routes/axom.js +73 -2
- package/packages/daemon/src/routes/innerchat.js +190 -8
- package/packages/gui/dist/assets/{index-b9dKN6cq.js → index-BbA-X4CE.js} +243 -233
- package/packages/gui/dist/assets/index-BbE3qX83.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/agents/agent-feed.jsx +3 -1
- package/packages/gui/src/stores/groove.js +4 -1
- package/packages/gui/src/stores/slices/axom-slice.js +83 -1
- package/packages/gui/src/views/axom.jsx +1147 -310
- package/packages/gui/src/views/settings.jsx +123 -70
- package/node_modules/@groove-dev/gui/dist/assets/index-RbtaI6l7.css +0 -1
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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',
|
|
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
|
-
{!
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
-
{
|
|
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={
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
423
|
-
{
|
|
424
|
-
<
|
|
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
|
-
|
|
427
|
-
|
|
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-
|
|
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
|
|
434
|
-
|
|
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-
|
|
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={
|
|
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
|
-
// ──
|
|
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-
|
|
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-
|
|
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-
|
|
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={
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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
|
|
556
|
-
|
|
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
|
|
569
|
-
<Radio size={
|
|
570
|
-
<span className="
|
|
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-
|
|
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
|
|
582
|
-
cited.has(e.id) ? 'bg-accent/
|
|
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',
|
|
587
|
-
{e.step != null && <span className="text-text-4 flex-shrink-0">s{e.step}</span>}
|
|
588
|
-
<span className="text-text-
|
|
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
|
-
// ──
|
|
975
|
+
// ── Conversation model — events folded into turns ──────────────────────────
|
|
597
976
|
//
|
|
598
|
-
//
|
|
599
|
-
//
|
|
600
|
-
//
|
|
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
|
-
|
|
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
|
|
613
|
-
|
|
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
|
-
}, [
|
|
1372
|
+
}, [events.length]);
|
|
624
1373
|
|
|
625
1374
|
function onScroll() {
|
|
626
1375
|
const el = scrollRef.current;
|
|
627
|
-
pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <
|
|
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
|
|
632
|
-
<div className="
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
</
|
|
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
|
-
{
|
|
644
|
-
<
|
|
645
|
-
key={
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
{
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
|
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-
|
|
685
|
-
<Shirt size={
|
|
686
|
-
<span className="
|
|
687
|
-
|
|
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-
|
|
690
|
-
{swaps.slice(-
|
|
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-
|
|
699
|
-
roster
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
|
757
|
-
//
|
|
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
|
|
1533
|
+
'text-left rounded transition-colors',
|
|
766
1534
|
basis.length ? 'cursor-pointer' : 'cursor-default',
|
|
767
|
-
active ? 'bg-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
className=
|
|
804
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
<span
|
|
947
|
-
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
<
|
|
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
|
);
|