@animalabs/connectome-host 0.3.9 → 0.4.0
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/.env.example +7 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +26 -0
- package/.github/workflows/changelog.yml +32 -0
- package/.github/workflows/publish.yml +46 -0
- package/CHANGELOG.md +140 -0
- package/CONTRIBUTING.md +126 -0
- package/HEADLESS-FLEET-PLAN.md +1 -1
- package/README.md +52 -5
- package/UNIFIED-TREE-PLAN.md +2 -0
- package/docs/AGENT-ONBOARDING.md +9 -8
- package/docs/DEPLOYMENTS.md +2 -2
- package/docs/DEV-ENVIRONMENT.md +28 -26
- package/docs/LOCUS-ROUTING-DESIGN.md +8 -2
- package/package.json +5 -4
- package/scripts/release-changelog.ts +32 -0
- package/src/codex-subscription-adapter.ts +938 -0
- package/src/commands.ts +95 -4
- package/src/extensions.ts +201 -0
- package/src/framework-agent-config.ts +20 -9
- package/src/framework-strategy.ts +24 -0
- package/src/index.ts +111 -19
- package/src/logging-bedrock-adapter.ts +145 -0
- package/src/modules/channel-mode-module.ts +9 -6
- package/src/modules/subscription-gc-module.ts +163 -34
- package/src/modules/tts-relay-module.ts +481 -0
- package/src/modules/web-ui-module.ts +45 -1
- package/src/recipe.ts +196 -10
- package/src/tui.ts +527 -137
- package/src/web/protocol.ts +35 -0
- package/test/codex-subscription-adapter.test.ts +226 -0
- package/test/commands-checkpoint-restore.test.ts +118 -0
- package/test/fast-command.test.ts +39 -0
- package/test/recipe-extensions.test.ts +295 -0
- package/test/recipe-provider.test.ts +14 -1
- package/test/subscription-gc-module.test.ts +156 -1
- package/test/tui-quit-confirm.test.ts +42 -0
- package/test/tui-short-agent-name.test.ts +36 -0
- package/test/web-ui-observers.test.ts +14 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/web/src/App.tsx +235 -20
- package/web/src/Branches.tsx +150 -0
- package/web/src/Context.tsx +21 -13
- package/web/src/Health.tsx +274 -0
- package/web/src/Lessons.tsx +2 -1
package/web/src/App.tsx
CHANGED
|
@@ -13,6 +13,8 @@ import { FilesPanel, FileViewerModal, type Mount, type FlatEntry, type FileViewe
|
|
|
13
13
|
import { ContextPanel } from './Context';
|
|
14
14
|
import { ContextDocument } from './ContextDocument';
|
|
15
15
|
import { ObserverGateScreen } from './ObserverGate';
|
|
16
|
+
import { OpsAlertStrip, HealthPanel, type OpsAlert, type HealthSnapshot } from './Health';
|
|
17
|
+
import { BranchPanel } from './Branches';
|
|
16
18
|
import {
|
|
17
19
|
WEB_PROTOCOL_VERSION,
|
|
18
20
|
type WebUiServerMessage,
|
|
@@ -23,6 +25,7 @@ import {
|
|
|
23
25
|
type TokenUsage,
|
|
24
26
|
type PerAgentCost,
|
|
25
27
|
type CallLedgerSnapshot,
|
|
28
|
+
type BranchRow,
|
|
26
29
|
} from '@conhost/web/protocol';
|
|
27
30
|
|
|
28
31
|
/** Client-side block: the wire MessageBlock plus live-stream bookkeeping. */
|
|
@@ -123,7 +126,7 @@ export function App() {
|
|
|
123
126
|
* `mode` decides whether the side panel shows live stream events or a
|
|
124
127
|
* static usage breakdown; clicking the row opens 'stream', clicking the
|
|
125
128
|
* token badge opens 'usage'. Both share the same panel slot. */
|
|
126
|
-
type PanelMode = 'stream' | 'usage';
|
|
129
|
+
type PanelMode = 'stream' | 'usage' | 'branches';
|
|
127
130
|
const [focusedId, setFocusedId] = createSignal<string | null>(null);
|
|
128
131
|
const [focusedNode, setFocusedNode] = createSignal<UiNode | null>(null);
|
|
129
132
|
const [panelMode, setPanelMode] = createSignal<PanelMode | null>(null);
|
|
@@ -136,9 +139,140 @@ export function App() {
|
|
|
136
139
|
* was invoked. Non-null = the quit-confirm modal is open. */
|
|
137
140
|
const [quitConfirm, setQuitConfirm] = createSignal<string[] | null>(null);
|
|
138
141
|
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Ops alerts + health — the operator-facing half of the ops:alert pipeline
|
|
144
|
+
// (failures.log / webhook already exist for machines; this is for the human
|
|
145
|
+
// actually looking at the page).
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/** Active alerts keyed `${agent}:${kind}`. Live `ops:alert` traces upsert;
|
|
149
|
+
* `<kind>-clear` traces and /healthz reconciliation remove. */
|
|
150
|
+
const [opsAlerts, setOpsAlerts] = createSignal<Map<string, OpsAlert>>(new Map());
|
|
151
|
+
const alertList = createMemo(() =>
|
|
152
|
+
[...opsAlerts().values()].sort((a, b) => b.at - a.at));
|
|
153
|
+
|
|
154
|
+
const upsertOpsAlert = (kind: string, agent: string, message: string, bump = true): void => {
|
|
155
|
+
const key = `${agent}:${kind}`;
|
|
156
|
+
setOpsAlerts((prev) => {
|
|
157
|
+
const next = new Map(prev);
|
|
158
|
+
const existing = next.get(key);
|
|
159
|
+
next.set(key, {
|
|
160
|
+
key,
|
|
161
|
+
kind,
|
|
162
|
+
agent,
|
|
163
|
+
message,
|
|
164
|
+
at: Date.now(),
|
|
165
|
+
count: existing ? existing.count + (bump ? 1 : 0) : 1,
|
|
166
|
+
});
|
|
167
|
+
return next;
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
const removeOpsAlert = (key: string): void => {
|
|
171
|
+
setOpsAlerts((prev) => {
|
|
172
|
+
if (!prev.has(key)) return prev;
|
|
173
|
+
const next = new Map(prev);
|
|
174
|
+
next.delete(key);
|
|
175
|
+
return next;
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** Fold an `ops:alert` trace into the strip. All-clear travels as a
|
|
180
|
+
* distinct `<kind>-clear` alert (see the host's quarantine wiring), so a
|
|
181
|
+
* `-clear` suffix removes its base alert instead of adding a row. */
|
|
182
|
+
const applyOpsAlertTrace = (e: { [k: string]: unknown }): void => {
|
|
183
|
+
const kind = typeof e.kind === 'string' ? e.kind : 'unknown';
|
|
184
|
+
const agent = typeof e.agentName === 'string' ? e.agentName : '?';
|
|
185
|
+
const message = typeof e.message === 'string' ? e.message : '';
|
|
186
|
+
if (kind.endsWith('-clear')) {
|
|
187
|
+
removeOpsAlert(`${agent}:${kind.slice(0, -'-clear'.length)}`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
upsertOpsAlert(kind, agent, message);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** /healthz snapshot — feeds the Health tab and reconciles durable-state
|
|
194
|
+
* alerts (quarantine, hard-down) so a page opened mid-incident alarms
|
|
195
|
+
* without waiting for the next klaxon re-fire. */
|
|
196
|
+
const [health, setHealth] = createSignal<HealthSnapshot | null>(null);
|
|
197
|
+
const [healthErr, setHealthErr] = createSignal<string | null>(null);
|
|
198
|
+
let healthDenied = false;
|
|
199
|
+
|
|
200
|
+
const reconcileHealthAlerts = (h: HealthSnapshot): void => {
|
|
201
|
+
const quarantine = h.compressionQuarantine ?? {};
|
|
202
|
+
for (const [agent, q] of Object.entries(quarantine)) {
|
|
203
|
+
const key = `${agent}:compression-quarantine`;
|
|
204
|
+
if ((q.count ?? 0) > 0) {
|
|
205
|
+
if (!opsAlerts().has(key)) {
|
|
206
|
+
upsertOpsAlert('compression-quarantine', agent,
|
|
207
|
+
`${q.count} chunk(s) in compression quarantine — spans stay raw and will eventually exhaust the context budget.`,
|
|
208
|
+
false);
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
removeOpsAlert(key);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const a of h.agents ?? []) {
|
|
215
|
+
const key = `${a.name}:inference-exhausted`;
|
|
216
|
+
const streak = a.consecutiveInferenceFailures ?? 0;
|
|
217
|
+
if (streak >= 3) {
|
|
218
|
+
const message = `${streak} consecutive inference failures — agent is down.`
|
|
219
|
+
+ (a.lastInference?.lastError ? ` Last: ${a.lastInference.lastError}` : '');
|
|
220
|
+
// Only touch the entry when the condition actually changed —
|
|
221
|
+
// rewriting `at` every poll would show a perpetual "5s ago" and
|
|
222
|
+
// re-sort the strip on every cycle.
|
|
223
|
+
if (opsAlerts().get(key)?.message !== message) {
|
|
224
|
+
upsertOpsAlert('inference-exhausted', a.name, message, false);
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
removeOpsAlert(key);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** `force` retries past a remembered 401/403 — the operator's grant may
|
|
233
|
+
* have gained the 'health' scope since the poll gave up. */
|
|
234
|
+
const loadHealth = async (force = false): Promise<void> => {
|
|
235
|
+
if (healthDenied && !force) return;
|
|
236
|
+
if (force) healthDenied = false;
|
|
237
|
+
try {
|
|
238
|
+
const res = await fetch('/healthz', { credentials: 'same-origin' });
|
|
239
|
+
if (!res.ok) {
|
|
240
|
+
if (res.status === 403 || res.status === 401) {
|
|
241
|
+
healthDenied = true;
|
|
242
|
+
setHealthErr('403');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
throw new Error(`HTTP ${res.status}`);
|
|
246
|
+
}
|
|
247
|
+
const h = (await res.json()) as HealthSnapshot;
|
|
248
|
+
setHealth(h);
|
|
249
|
+
setHealthErr(null);
|
|
250
|
+
reconcileHealthAlerts(h);
|
|
251
|
+
} catch (e) {
|
|
252
|
+
setHealthErr(e instanceof Error ? e.message : String(e));
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Branch panel — Chronicle lineage view, opened from the header branch chip.
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
const [branches, setBranches] = createSignal<BranchRow[]>([]);
|
|
261
|
+
const [branchesCurrentId, setBranchesCurrentId] = createSignal<string | null>(null);
|
|
262
|
+
const [branchesLoading, setBranchesLoading] = createSignal(false);
|
|
263
|
+
|
|
264
|
+
const refreshBranches = (): void => {
|
|
265
|
+
setBranchesLoading(true);
|
|
266
|
+
wire.send({ type: 'request-branches' });
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const checkoutBranch = (name: string): void => {
|
|
270
|
+
wire.send({ type: 'command', command: `/checkout ${name}` });
|
|
271
|
+
};
|
|
272
|
+
|
|
139
273
|
/** Right-sidebar tab selection. The Tree is the most-used surface so it's
|
|
140
274
|
* the default; lessons / mcp / files are operator-driven panels. */
|
|
141
|
-
type SidebarTab = 'tree' | 'lessons' | 'mcp' | 'files' | 'context';
|
|
275
|
+
type SidebarTab = 'tree' | 'lessons' | 'mcp' | 'files' | 'context' | 'health';
|
|
142
276
|
const [sidebarTab, setSidebarTab] = createSignal<SidebarTab>('tree');
|
|
143
277
|
const [mainView, setMainView] = createSignal<'chat' | 'context'>('chat');
|
|
144
278
|
|
|
@@ -623,6 +757,23 @@ export function App() {
|
|
|
623
757
|
streamTokenBuffer = '';
|
|
624
758
|
};
|
|
625
759
|
|
|
760
|
+
/** Toggle the branch panel in the shared middle-panel slot. Requests a
|
|
761
|
+
* fresh listing on every open — branch mutations happen through commands
|
|
762
|
+
* and MCPL, so a cached list goes stale invisibly. */
|
|
763
|
+
const openBranches = (): void => {
|
|
764
|
+
if (panelMode() === 'branches') {
|
|
765
|
+
closePanel();
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
teardownPeek();
|
|
769
|
+
setFocusedId(null);
|
|
770
|
+
setFocusedNode(null);
|
|
771
|
+
setPanelMode('branches');
|
|
772
|
+
setStreamLines([]);
|
|
773
|
+
streamTokenBuffer = '';
|
|
774
|
+
refreshBranches();
|
|
775
|
+
};
|
|
776
|
+
|
|
626
777
|
const toggleExpand = (id: string): void => {
|
|
627
778
|
setExpanded((prev) => {
|
|
628
779
|
const next = new Set(prev);
|
|
@@ -741,12 +892,32 @@ export function App() {
|
|
|
741
892
|
setOpenFile(file);
|
|
742
893
|
setFileLoading(false);
|
|
743
894
|
},
|
|
895
|
+
onOpsAlert: applyOpsAlertTrace,
|
|
896
|
+
setBranchesList: (list, currentId) => {
|
|
897
|
+
setBranches(list);
|
|
898
|
+
setBranchesCurrentId(currentId);
|
|
899
|
+
setBranchesLoading(false);
|
|
900
|
+
},
|
|
901
|
+
onBranchChanged: (branch) => {
|
|
902
|
+
// Update the header chip immediately; the server's re-welcome
|
|
903
|
+
// follows with the new branch's messages. Refresh the lineage
|
|
904
|
+
// panel if the operator is looking at it.
|
|
905
|
+
setWelcome((w) => (w ? { ...w, branch } : w));
|
|
906
|
+
if (panelMode() === 'branches') refreshBranches();
|
|
907
|
+
},
|
|
744
908
|
});
|
|
745
909
|
});
|
|
746
910
|
onCleanup(() => {
|
|
747
911
|
detach();
|
|
748
912
|
wire.close();
|
|
749
913
|
});
|
|
914
|
+
|
|
915
|
+
// Health poll: durable-state alerts (quarantine, hard-down) must not
|
|
916
|
+
// depend on being connected when the klaxon fired. 15s keeps the strip
|
|
917
|
+
// honest without meaningful load (counts-only JSON).
|
|
918
|
+
void loadHealth();
|
|
919
|
+
const healthTimer = window.setInterval(() => void loadHealth(), 15_000);
|
|
920
|
+
onCleanup(() => window.clearInterval(healthTimer));
|
|
750
921
|
});
|
|
751
922
|
|
|
752
923
|
/** Route a server message into the stream pane *if* it matches the focused
|
|
@@ -838,8 +1009,15 @@ export function App() {
|
|
|
838
1009
|
|
|
839
1010
|
return (
|
|
840
1011
|
<div class="flex flex-col h-screen">
|
|
841
|
-
<Header
|
|
1012
|
+
<Header
|
|
1013
|
+
welcome={welcome()}
|
|
1014
|
+
usage={usage()}
|
|
1015
|
+
status={wire.status()}
|
|
1016
|
+
branchPanelOpen={panelMode() === 'branches'}
|
|
1017
|
+
onBranchClick={openBranches}
|
|
1018
|
+
/>
|
|
842
1019
|
<ReconnectBanner status={wire.status()} />
|
|
1020
|
+
<OpsAlertStrip alerts={alertList()} onDismiss={removeOpsAlert} />
|
|
843
1021
|
<Show when={wire.observerState() === 'observer' && wire.observer()}>
|
|
844
1022
|
{(info) => (
|
|
845
1023
|
<div class="bg-violet-950/60 border-b border-violet-900 px-4 py-1.5 text-xs text-violet-200 flex items-center gap-2">
|
|
@@ -975,6 +1153,17 @@ export function App() {
|
|
|
975
1153
|
/>
|
|
976
1154
|
)}
|
|
977
1155
|
</Show>
|
|
1156
|
+
<Show when={panelMode() === 'branches'}>
|
|
1157
|
+
<BranchPanel
|
|
1158
|
+
branches={branches()}
|
|
1159
|
+
currentId={branchesCurrentId() ?? welcome()?.branch.id ?? null}
|
|
1160
|
+
loading={branchesLoading()}
|
|
1161
|
+
readOnly={wire.observerState() === 'observer'}
|
|
1162
|
+
onCheckout={checkoutBranch}
|
|
1163
|
+
onRefresh={refreshBranches}
|
|
1164
|
+
onClose={closePanel}
|
|
1165
|
+
/>
|
|
1166
|
+
</Show>
|
|
978
1167
|
<aside class="w-72 border-l border-neutral-800 bg-neutral-950 shrink-0 flex flex-col">
|
|
979
1168
|
<SidebarTabs
|
|
980
1169
|
current={sidebarTab()}
|
|
@@ -1042,6 +1231,13 @@ export function App() {
|
|
|
1042
1231
|
<Show when={sidebarTab() === 'context'}>
|
|
1043
1232
|
<ContextPanel agent={panelScope() === 'local' ? undefined : panelScope()} />
|
|
1044
1233
|
</Show>
|
|
1234
|
+
<Show when={sidebarTab() === 'health'}>
|
|
1235
|
+
<HealthPanel
|
|
1236
|
+
health={health()}
|
|
1237
|
+
error={healthErr()}
|
|
1238
|
+
onRefresh={() => void loadHealth(true)}
|
|
1239
|
+
/>
|
|
1240
|
+
</Show>
|
|
1045
1241
|
</div>
|
|
1046
1242
|
<RecipePane welcome={welcome()} scope={panelScope()} />
|
|
1047
1243
|
</aside>
|
|
@@ -1092,6 +1288,12 @@ interface HandlerHooks {
|
|
|
1092
1288
|
setMountTree: (mount: string, entries: FlatEntry[]) => void;
|
|
1093
1289
|
/** Apply a workspace-file response. */
|
|
1094
1290
|
setOpenFile: (file: FileViewer) => void;
|
|
1291
|
+
/** Fold an ops:alert trace into the alert strip. */
|
|
1292
|
+
onOpsAlert: (event: { [k: string]: unknown }) => void;
|
|
1293
|
+
/** Apply a branches-list response. */
|
|
1294
|
+
setBranchesList: (branches: BranchRow[], currentId: string) => void;
|
|
1295
|
+
/** React to a server-side branch switch (undo/redo/checkout). */
|
|
1296
|
+
onBranchChanged: (branch: { id: string; name: string }) => void;
|
|
1095
1297
|
}
|
|
1096
1298
|
|
|
1097
1299
|
function handleServerMessage(
|
|
@@ -1158,6 +1360,9 @@ function handleServerMessage(
|
|
|
1158
1360
|
}
|
|
1159
1361
|
return;
|
|
1160
1362
|
}
|
|
1363
|
+
case 'ops:alert':
|
|
1364
|
+
hooks.onOpsAlert(e);
|
|
1365
|
+
return;
|
|
1161
1366
|
default:
|
|
1162
1367
|
return;
|
|
1163
1368
|
}
|
|
@@ -1173,7 +1378,13 @@ function handleServerMessage(
|
|
|
1173
1378
|
return;
|
|
1174
1379
|
}
|
|
1175
1380
|
case 'branch-changed':
|
|
1381
|
+
hooks.onBranchChanged(msg.branch);
|
|
1176
1382
|
return;
|
|
1383
|
+
case 'branches-list':
|
|
1384
|
+
hooks.setBranchesList(msg.branches, msg.currentId);
|
|
1385
|
+
return;
|
|
1386
|
+
// The server follows a session switch with a full re-welcome (setApp
|
|
1387
|
+
// re-flushes every client), which resets all session-scoped state.
|
|
1177
1388
|
case 'session-changed':
|
|
1178
1389
|
return;
|
|
1179
1390
|
case 'inbound-trigger': {
|
|
@@ -1228,6 +1439,8 @@ function Header(props: {
|
|
|
1228
1439
|
welcome: WelcomeMessage | null;
|
|
1229
1440
|
usage: TokenUsage;
|
|
1230
1441
|
status: string;
|
|
1442
|
+
branchPanelOpen: boolean;
|
|
1443
|
+
onBranchClick(): void;
|
|
1231
1444
|
}) {
|
|
1232
1445
|
const fmt = (n: number): string => {
|
|
1233
1446
|
if (n < 1000) return String(n);
|
|
@@ -1255,10 +1468,19 @@ function Header(props: {
|
|
|
1255
1468
|
· {props.welcome!.session.name}
|
|
1256
1469
|
</div>
|
|
1257
1470
|
</Show>
|
|
1258
|
-
<Show when={props.welcome?.branch.name
|
|
1259
|
-
<
|
|
1260
|
-
|
|
1261
|
-
|
|
1471
|
+
<Show when={props.welcome?.branch.name}>
|
|
1472
|
+
<button
|
|
1473
|
+
type="button"
|
|
1474
|
+
class={`text-xs font-mono px-1.5 py-0.5 rounded ${
|
|
1475
|
+
props.welcome!.branch.name !== 'main'
|
|
1476
|
+
? 'text-amber-400 bg-amber-950/30 hover:bg-amber-950/60'
|
|
1477
|
+
: 'text-neutral-400 bg-neutral-900 hover:bg-neutral-800'
|
|
1478
|
+
} ${props.branchPanelOpen ? 'ring-1 ring-cyan-800' : ''}`}
|
|
1479
|
+
title="Chronicle branches — click to browse lineage"
|
|
1480
|
+
onClick={() => props.onBranchClick()}
|
|
1481
|
+
>
|
|
1482
|
+
⑂ {props.welcome!.branch.name}
|
|
1483
|
+
</button>
|
|
1262
1484
|
</Show>
|
|
1263
1485
|
<div class="ml-auto text-xs font-mono text-neutral-500">
|
|
1264
1486
|
{fmt(props.usage.input)} in
|
|
@@ -1720,16 +1942,19 @@ function CommandSuggestions(props: { draft: string; onPick: (cmd: string) => voi
|
|
|
1720
1942
|
);
|
|
1721
1943
|
}
|
|
1722
1944
|
|
|
1945
|
+
type SidebarTabId = 'tree' | 'lessons' | 'mcp' | 'files' | 'context' | 'health';
|
|
1946
|
+
|
|
1723
1947
|
function SidebarTabs(props: {
|
|
1724
|
-
current:
|
|
1725
|
-
onSelect: (tab:
|
|
1948
|
+
current: SidebarTabId;
|
|
1949
|
+
onSelect: (tab: SidebarTabId) => void;
|
|
1726
1950
|
}) {
|
|
1727
|
-
const tabs: Array<{ id:
|
|
1951
|
+
const tabs: Array<{ id: SidebarTabId; label: string; title: string }> = [
|
|
1728
1952
|
{ id: 'tree', label: 'Tree', title: 'Agent + fleet tree' },
|
|
1729
1953
|
{ id: 'lessons', label: 'Lessons', title: 'Lesson library' },
|
|
1730
1954
|
{ id: 'mcp', label: 'MCP', title: 'MCPL servers' },
|
|
1731
1955
|
{ id: 'files', label: 'Files', title: 'Workspace mounts + files' },
|
|
1732
1956
|
{ id: 'context', label: 'Context', title: 'Compiled context makeup' },
|
|
1957
|
+
{ id: 'health', label: 'Health', title: 'Runtime settings, failures, quarantine' },
|
|
1733
1958
|
];
|
|
1734
1959
|
return (
|
|
1735
1960
|
<div class="flex border-b border-neutral-800 bg-neutral-900/40 text-[11px]">
|
|
@@ -1751,16 +1976,6 @@ function SidebarTabs(props: {
|
|
|
1751
1976
|
);
|
|
1752
1977
|
}
|
|
1753
1978
|
|
|
1754
|
-
/** Stub used while a tab's full content isn't shipped yet. Replaced as each
|
|
1755
|
-
* panel lands. */
|
|
1756
|
-
function PlaceholderPanel(props: { label: string }) {
|
|
1757
|
-
return (
|
|
1758
|
-
<div class="px-3 py-3 text-xs text-neutral-600 italic">
|
|
1759
|
-
{props.label} — coming soon.
|
|
1760
|
-
</div>
|
|
1761
|
-
);
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
1979
|
function QuitConfirmModal(props: {
|
|
1765
1980
|
childNames: string[];
|
|
1766
1981
|
onAction: (action: 'kill-children' | 'detach' | 'cancel') => void;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Branch panel — Chronicle branch lineage as a navigable tree.
|
|
3
|
+
*
|
|
4
|
+
* Opens from the header's branch chip into the same middle panel slot the
|
|
5
|
+
* stream/usage views use. Rows are indented by fork ancestry (parentId +
|
|
6
|
+
* branchPoint from Chronicle's branch records); the current branch is
|
|
7
|
+
* highlighted, and checkout routes through the existing `/checkout` command
|
|
8
|
+
* so undo/redo bookkeeping and workspace re-materialization stay on the one
|
|
9
|
+
* audited path. Read-only for key-authenticated observers.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createMemo, For, Show } from 'solid-js';
|
|
13
|
+
import type { BranchRow } from '@conhost/web/protocol';
|
|
14
|
+
|
|
15
|
+
interface TreeRow {
|
|
16
|
+
branch: BranchRow;
|
|
17
|
+
depth: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Fold the flat branch list into depth-first display order. Children sort
|
|
21
|
+
* by fork point (earliest first), then creation time. Orphans (parentId
|
|
22
|
+
* pointing at a pruned branch) surface as roots rather than vanishing. */
|
|
23
|
+
function toTreeRows(branches: BranchRow[]): TreeRow[] {
|
|
24
|
+
const byId = new Map(branches.map((b) => [b.id, b]));
|
|
25
|
+
const children = new Map<string, BranchRow[]>();
|
|
26
|
+
const roots: BranchRow[] = [];
|
|
27
|
+
for (const b of branches) {
|
|
28
|
+
if (b.parentId !== undefined && byId.has(b.parentId)) {
|
|
29
|
+
const list = children.get(b.parentId) ?? [];
|
|
30
|
+
list.push(b);
|
|
31
|
+
children.set(b.parentId, list);
|
|
32
|
+
} else {
|
|
33
|
+
roots.push(b);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const byFork = (a: BranchRow, b: BranchRow): number =>
|
|
37
|
+
(a.branchPoint ?? 0) - (b.branchPoint ?? 0) || a.created - b.created;
|
|
38
|
+
const out: TreeRow[] = [];
|
|
39
|
+
// Healthy Chronicle lineage is a DAG, but this view must not hang the
|
|
40
|
+
// page on a corrupt store — guard against parentId cycles.
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
const visit = (b: BranchRow, depth: number): void => {
|
|
43
|
+
if (seen.has(b.id)) return;
|
|
44
|
+
seen.add(b.id);
|
|
45
|
+
out.push({ branch: b, depth });
|
|
46
|
+
for (const c of (children.get(b.id) ?? []).sort(byFork)) visit(c, depth + 1);
|
|
47
|
+
};
|
|
48
|
+
for (const r of roots.sort(byFork)) visit(r, 0);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const fmtDate = (ts: number): string => {
|
|
53
|
+
const d = new Date(ts);
|
|
54
|
+
const now = new Date();
|
|
55
|
+
return d.toDateString() === now.toDateString()
|
|
56
|
+
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
|
|
57
|
+
: d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function BranchPanel(props: {
|
|
61
|
+
branches: BranchRow[];
|
|
62
|
+
currentId: string | null;
|
|
63
|
+
loading: boolean;
|
|
64
|
+
/** True for key-authenticated observers — checkout affordances hidden. */
|
|
65
|
+
readOnly: boolean;
|
|
66
|
+
onCheckout(name: string): void;
|
|
67
|
+
onRefresh(): void;
|
|
68
|
+
onClose(): void;
|
|
69
|
+
}) {
|
|
70
|
+
const rows = createMemo(() => toTreeRows(props.branches));
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div class="border-l border-neutral-800 w-96 shrink-0 bg-neutral-950 flex flex-col h-full">
|
|
74
|
+
<div class="border-b border-neutral-800 px-3 py-2 flex items-center gap-2">
|
|
75
|
+
<span class="text-[10px] uppercase tracking-wider text-neutral-500 font-semibold">branches</span>
|
|
76
|
+
<span class="text-neutral-500 text-xs">{props.branches.length || ''}</span>
|
|
77
|
+
<div class="ml-auto flex gap-1">
|
|
78
|
+
{/* Not disabled while loading: a server-side listing error leaves
|
|
79
|
+
`loading` latched, and re-clicking is the recovery path. */}
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
class="px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded text-xs"
|
|
83
|
+
onClick={() => props.onRefresh()}
|
|
84
|
+
>
|
|
85
|
+
{props.loading ? '…' : 'refresh'}
|
|
86
|
+
</button>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
class="px-2 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded text-xs"
|
|
90
|
+
onClick={() => props.onClose()}
|
|
91
|
+
>
|
|
92
|
+
close
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div class="flex-1 overflow-y-auto px-2 py-2 font-mono text-[11px]">
|
|
98
|
+
<Show when={rows().length > 0} fallback={
|
|
99
|
+
<div class="text-neutral-600 italic px-1">
|
|
100
|
+
{props.loading ? 'loading…' : 'No branches.'}
|
|
101
|
+
</div>
|
|
102
|
+
}>
|
|
103
|
+
<For each={rows()}>{(row) => {
|
|
104
|
+
const b = row.branch;
|
|
105
|
+
const current = (): boolean => b.id === props.currentId;
|
|
106
|
+
return (
|
|
107
|
+
<div
|
|
108
|
+
class={`group flex items-center gap-2 rounded px-1.5 py-1 ${
|
|
109
|
+
current() ? 'bg-cyan-950/40 border border-cyan-900/50' : 'hover:bg-neutral-900'
|
|
110
|
+
}`}
|
|
111
|
+
style={{ 'margin-left': `${row.depth * 14}px` }}
|
|
112
|
+
>
|
|
113
|
+
<span class={current() ? 'text-cyan-300' : 'text-neutral-600'}>
|
|
114
|
+
{row.depth > 0 ? '⑂' : '●'}
|
|
115
|
+
</span>
|
|
116
|
+
<span
|
|
117
|
+
class={`truncate ${current() ? 'text-cyan-100' : 'text-neutral-200'}`}
|
|
118
|
+
title={`${b.name} · head @${b.head}${b.branchPoint !== undefined ? ` · forked @${b.branchPoint}` : ''}`}
|
|
119
|
+
>
|
|
120
|
+
{b.name}
|
|
121
|
+
</span>
|
|
122
|
+
<Show when={b.branchPoint !== undefined}>
|
|
123
|
+
<span class="text-neutral-600 shrink-0">@{b.branchPoint}</span>
|
|
124
|
+
</Show>
|
|
125
|
+
<span class="ml-auto shrink-0 text-neutral-600">{fmtDate(b.created)}</span>
|
|
126
|
+
<Show when={current()}>
|
|
127
|
+
<span class="shrink-0 text-[10px] uppercase tracking-wider text-cyan-400">current</span>
|
|
128
|
+
</Show>
|
|
129
|
+
<Show when={!current() && !props.readOnly}>
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
class="shrink-0 opacity-0 group-hover:opacity-100 px-1.5 py-0.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 rounded text-[10px]"
|
|
133
|
+
title={`/checkout ${b.name}`}
|
|
134
|
+
onClick={() => props.onCheckout(b.name)}
|
|
135
|
+
>
|
|
136
|
+
checkout
|
|
137
|
+
</button>
|
|
138
|
+
</Show>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}}</For>
|
|
142
|
+
</Show>
|
|
143
|
+
<div class="mt-3 px-1 text-neutral-600 leading-relaxed">
|
|
144
|
+
Branches fork from <span class="text-neutral-500">@sequence</span> in their parent.
|
|
145
|
+
/undo, /checkpoint and /branchto create them; checkout switches the live context.
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
package/web/src/Context.tsx
CHANGED
|
@@ -150,15 +150,26 @@ export function ContextPanel(props: { agent?: string }) {
|
|
|
150
150
|
<div class="p-2 text-[11px] font-mono text-neutral-300 space-y-3">
|
|
151
151
|
<div class="flex items-center justify-between">
|
|
152
152
|
<span class="text-neutral-400">Context makeup{data()?.agent ? ` · ${data()!.agent}` : ''}</span>
|
|
153
|
-
<
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
153
|
+
<div class="flex items-center gap-2">
|
|
154
|
+
<a
|
|
155
|
+
class="text-cyan-500 hover:text-cyan-300"
|
|
156
|
+
href="/curve"
|
|
157
|
+
target="_blank"
|
|
158
|
+
rel="noreferrer"
|
|
159
|
+
title="Compression curve — raw vs rendered tokens per compiled entry"
|
|
160
|
+
>
|
|
161
|
+
curve ↗
|
|
162
|
+
</a>
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
class="px-2 py-0.5 text-neutral-400 hover:text-neutral-100 border border-neutral-700 rounded"
|
|
166
|
+
onClick={refreshAll}
|
|
167
|
+
disabled={loading() || coverageLoading()}
|
|
168
|
+
title="Refresh context diagnostics"
|
|
169
|
+
>
|
|
170
|
+
{loading() || coverageLoading() ? '…' : 'refresh'}
|
|
171
|
+
</button>
|
|
172
|
+
</div>
|
|
162
173
|
</div>
|
|
163
174
|
|
|
164
175
|
<Show when={err()}>
|
|
@@ -258,10 +269,7 @@ export function ContextPanel(props: { agent?: string }) {
|
|
|
258
269
|
</Show>
|
|
259
270
|
</div>
|
|
260
271
|
|
|
261
|
-
<div class="
|
|
262
|
-
<span class="min-w-0 truncate" title={coverage()!.branch}>{coverage()!.branch}</span>
|
|
263
|
-
<a class="shrink-0 text-cyan-500 hover:text-cyan-300" href="/curve" target="_blank" rel="noreferrer">curve ↗</a>
|
|
264
|
-
</div>
|
|
272
|
+
<div class="text-neutral-600 min-w-0 truncate" title={coverage()!.branch}>{coverage()!.branch}</div>
|
|
265
273
|
</section>
|
|
266
274
|
</Show>
|
|
267
275
|
|