@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.
Files changed (44) hide show
  1. package/.env.example +7 -0
  2. package/.github/PULL_REQUEST_TEMPLATE.md +26 -0
  3. package/.github/workflows/changelog.yml +32 -0
  4. package/.github/workflows/publish.yml +46 -0
  5. package/CHANGELOG.md +140 -0
  6. package/CONTRIBUTING.md +126 -0
  7. package/HEADLESS-FLEET-PLAN.md +1 -1
  8. package/README.md +52 -5
  9. package/UNIFIED-TREE-PLAN.md +2 -0
  10. package/docs/AGENT-ONBOARDING.md +9 -8
  11. package/docs/DEPLOYMENTS.md +2 -2
  12. package/docs/DEV-ENVIRONMENT.md +28 -26
  13. package/docs/LOCUS-ROUTING-DESIGN.md +8 -2
  14. package/package.json +5 -4
  15. package/scripts/release-changelog.ts +32 -0
  16. package/src/codex-subscription-adapter.ts +938 -0
  17. package/src/commands.ts +95 -4
  18. package/src/extensions.ts +201 -0
  19. package/src/framework-agent-config.ts +20 -9
  20. package/src/framework-strategy.ts +24 -0
  21. package/src/index.ts +111 -19
  22. package/src/logging-bedrock-adapter.ts +145 -0
  23. package/src/modules/channel-mode-module.ts +9 -6
  24. package/src/modules/subscription-gc-module.ts +163 -34
  25. package/src/modules/tts-relay-module.ts +481 -0
  26. package/src/modules/web-ui-module.ts +45 -1
  27. package/src/recipe.ts +196 -10
  28. package/src/tui.ts +527 -137
  29. package/src/web/protocol.ts +35 -0
  30. package/test/codex-subscription-adapter.test.ts +226 -0
  31. package/test/commands-checkpoint-restore.test.ts +118 -0
  32. package/test/fast-command.test.ts +39 -0
  33. package/test/recipe-extensions.test.ts +295 -0
  34. package/test/recipe-provider.test.ts +14 -1
  35. package/test/subscription-gc-module.test.ts +156 -1
  36. package/test/tui-quit-confirm.test.ts +42 -0
  37. package/test/tui-short-agent-name.test.ts +36 -0
  38. package/test/web-ui-observers.test.ts +14 -0
  39. package/test/web-ui-protocol.test.ts +0 -0
  40. package/web/src/App.tsx +235 -20
  41. package/web/src/Branches.tsx +150 -0
  42. package/web/src/Context.tsx +21 -13
  43. package/web/src/Health.tsx +274 -0
  44. 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 welcome={welcome()} usage={usage()} status={wire.status()} />
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 && props.welcome.branch.name !== 'main'}>
1259
- <div class="text-amber-400 text-xs font-mono px-1.5 py-0.5 bg-amber-950/30 rounded">
1260
- {props.welcome!.branch.name}
1261
- </div>
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: 'tree' | 'lessons' | 'mcp' | 'files' | 'context';
1725
- onSelect: (tab: 'tree' | 'lessons' | 'mcp' | 'files' | 'context') => void;
1948
+ current: SidebarTabId;
1949
+ onSelect: (tab: SidebarTabId) => void;
1726
1950
  }) {
1727
- const tabs: Array<{ id: 'tree' | 'lessons' | 'mcp' | 'files' | 'context'; label: string; title: string }> = [
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
+ }
@@ -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
- <button
154
- type="button"
155
- class="px-2 py-0.5 text-neutral-400 hover:text-neutral-100 border border-neutral-700 rounded"
156
- onClick={refreshAll}
157
- disabled={loading() || coverageLoading()}
158
- title="Refresh context diagnostics"
159
- >
160
- {loading() || coverageLoading() ? '…' : 'refresh'}
161
- </button>
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="flex items-center justify-between gap-2 text-neutral-600">
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