@animalabs/connectome-host 0.4.0 → 0.5.1
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/CHANGELOG.md +63 -0
- package/bun.lock +6 -6
- package/package.json +1 -1
- package/src/index.ts +17 -5
- package/src/logging-provider-wrapper.ts +165 -0
- package/src/modules/web-ui-module.ts +280 -1
- package/src/web/protocol.ts +130 -0
- package/test/settings-protocol.test.ts +64 -0
- package/web/src/App.tsx +35 -2
- package/web/src/Settings.tsx +434 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context settings panel — live control of the agent's compile window.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the stop → edit the `framework/state` Chronicle slot → start dance.
|
|
5
|
+
* Three things this panel is deliberately honest about, because getting any of
|
|
6
|
+
* them wrong would mislead an operator into an outage:
|
|
7
|
+
*
|
|
8
|
+
* 1. RAISING the budget applies at once; LOWERING starts a PACED convergence
|
|
9
|
+
* that can sit in `converging` for many turns, and can be cancelled. Apply
|
|
10
|
+
* is not "done" the moment it returns.
|
|
11
|
+
* 2. Only a few keys are hot. Chunk size, head window, merge threshold and
|
|
12
|
+
* friends are recipe-and-restart-only — shown, but not offered as controls.
|
|
13
|
+
* 3. Preview requires a context-manager build with dry-run support. When the
|
|
14
|
+
* resolved build lacks it we say so, rather than rendering an empty result
|
|
15
|
+
* that looks like "nothing would change".
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createSignal, Show, For, onCleanup } from 'solid-js';
|
|
19
|
+
|
|
20
|
+
export interface SettingsSnapshot {
|
|
21
|
+
contextBudgetTokens: number;
|
|
22
|
+
tailTokens?: number;
|
|
23
|
+
transitionPaceTokens?: number;
|
|
24
|
+
sameRoundThinkTextPolicy?: string;
|
|
25
|
+
sameRoundThinkTextPolicySource?: string;
|
|
26
|
+
transition: 'stable' | 'converging' | 'blocked';
|
|
27
|
+
transitionReason?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SettingsState {
|
|
31
|
+
agent: string;
|
|
32
|
+
settings: SettingsSnapshot;
|
|
33
|
+
overrides: string[];
|
|
34
|
+
hotKeys: string[];
|
|
35
|
+
hotConfigurable: boolean;
|
|
36
|
+
previewAvailable: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PreviewResult {
|
|
40
|
+
finalTokens: number;
|
|
41
|
+
budgetTokens: number;
|
|
42
|
+
fits: boolean;
|
|
43
|
+
exhausted: boolean;
|
|
44
|
+
headTokens: number;
|
|
45
|
+
tailTokens: number;
|
|
46
|
+
middleTokens: number;
|
|
47
|
+
middleChunkCount: number;
|
|
48
|
+
deepestLevel: number;
|
|
49
|
+
resolutions: Record<string, number>;
|
|
50
|
+
appliedCount: number;
|
|
51
|
+
producedCount: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Settings the strategy reads only at construction — surfaced so an operator
|
|
55
|
+
* can see them without being invited to "change" them from here. */
|
|
56
|
+
const RESTART_ONLY = [
|
|
57
|
+
'targetChunkTokens',
|
|
58
|
+
'headWindowTokens',
|
|
59
|
+
'mergeThreshold',
|
|
60
|
+
'foldingStrategy',
|
|
61
|
+
'maxSpeculativeL1s',
|
|
62
|
+
'adaptiveResolution',
|
|
63
|
+
'compressionSlackRatio',
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const TRANSITION_HELP: Record<string, string> = {
|
|
67
|
+
transition_pace_too_small:
|
|
68
|
+
'the per-turn pace is below the floor — raise transitionPaceTokens or the descent cannot progress',
|
|
69
|
+
protected_context_exceeds_target:
|
|
70
|
+
'head + tail alone exceed the target — the verbatim window must shrink before this budget is reachable',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function SettingsPanel(props: {
|
|
74
|
+
loaded: boolean;
|
|
75
|
+
state: SettingsState | null;
|
|
76
|
+
onRefresh(): void;
|
|
77
|
+
onApply(patch: {
|
|
78
|
+
contextBudgetTokens?: number;
|
|
79
|
+
tailTokens?: number;
|
|
80
|
+
transitionPaceTokens?: number;
|
|
81
|
+
persist: boolean;
|
|
82
|
+
notify: boolean;
|
|
83
|
+
}): void;
|
|
84
|
+
onReset(keys: string[] | undefined, persist: boolean): void;
|
|
85
|
+
onCancelTransition(): void;
|
|
86
|
+
}) {
|
|
87
|
+
const [budget, setBudget] = createSignal<string>('');
|
|
88
|
+
const [tail, setTail] = createSignal<string>('');
|
|
89
|
+
const [pace, setPace] = createSignal<string>('');
|
|
90
|
+
const [persist, setPersist] = createSignal(true);
|
|
91
|
+
const [notify, setNotify] = createSignal(false);
|
|
92
|
+
|
|
93
|
+
const [preview, setPreview] = createSignal<PreviewResult | null>(null);
|
|
94
|
+
const [previewErr, setPreviewErr] = createSignal<string | null>(null);
|
|
95
|
+
const [previewing, setPreviewing] = createSignal(false);
|
|
96
|
+
|
|
97
|
+
let debounce: number | undefined;
|
|
98
|
+
onCleanup(() => { if (debounce) window.clearTimeout(debounce); });
|
|
99
|
+
|
|
100
|
+
const s = () => props.state?.settings;
|
|
101
|
+
const isHot = (k: string) => props.state?.hotKeys.includes(k) ?? false;
|
|
102
|
+
|
|
103
|
+
/** Prefill the inputs from live values so the operator edits from reality. */
|
|
104
|
+
const syncFromLive = () => {
|
|
105
|
+
const cur = s();
|
|
106
|
+
if (!cur) return;
|
|
107
|
+
setBudget(String(cur.contextBudgetTokens));
|
|
108
|
+
setTail(cur.tailTokens !== undefined ? String(cur.tailTokens) : '');
|
|
109
|
+
setPace(cur.transitionPaceTokens !== undefined ? String(cur.transitionPaceTokens) : '');
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const num = (v: string): number | undefined => {
|
|
113
|
+
if (v.trim() === '') return undefined;
|
|
114
|
+
const n = Number(v);
|
|
115
|
+
return Number.isSafeInteger(n) && n > 0 ? n : undefined;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Debounced: an operator dragging a value should not fire a compile per keystroke.
|
|
119
|
+
* The request itself is non-committing, but it is still real CPU on a live agent. */
|
|
120
|
+
const runPreview = () => {
|
|
121
|
+
if (!props.state?.previewAvailable) return;
|
|
122
|
+
const b = num(budget());
|
|
123
|
+
if (b === undefined) { setPreview(null); setPreviewErr(null); return; }
|
|
124
|
+
if (debounce) window.clearTimeout(debounce);
|
|
125
|
+
debounce = window.setTimeout(async () => {
|
|
126
|
+
setPreviewing(true);
|
|
127
|
+
setPreviewErr(null);
|
|
128
|
+
try {
|
|
129
|
+
const qs = new URLSearchParams({ budget: String(b), agent: props.state!.agent });
|
|
130
|
+
const t = num(tail());
|
|
131
|
+
if (t !== undefined) qs.set('tail', String(t));
|
|
132
|
+
const res = await fetch(`/debug/context/preview?${qs}`, { credentials: 'same-origin' });
|
|
133
|
+
const body = await res.json();
|
|
134
|
+
if (!res.ok) {
|
|
135
|
+
setPreview(null);
|
|
136
|
+
setPreviewErr(body?.error ?? `HTTP ${res.status}`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
setPreview(body.preview as PreviewResult);
|
|
140
|
+
} catch (e) {
|
|
141
|
+
setPreview(null);
|
|
142
|
+
setPreviewErr(e instanceof Error ? e.message : String(e));
|
|
143
|
+
} finally {
|
|
144
|
+
setPreviewing(false);
|
|
145
|
+
}
|
|
146
|
+
}, 350) as unknown as number;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const apply = () => {
|
|
150
|
+
const patch: Record<string, number> = {};
|
|
151
|
+
const b = num(budget());
|
|
152
|
+
const t = num(tail());
|
|
153
|
+
const p = num(pace());
|
|
154
|
+
const cur = s();
|
|
155
|
+
if (b !== undefined && b !== cur?.contextBudgetTokens) patch.contextBudgetTokens = b;
|
|
156
|
+
if (t !== undefined && t !== cur?.tailTokens) patch.tailTokens = t;
|
|
157
|
+
if (p !== undefined && p !== cur?.transitionPaceTokens) patch.transitionPaceTokens = p;
|
|
158
|
+
if (Object.keys(patch).length === 0) return;
|
|
159
|
+
props.onApply({ ...patch, persist: persist(), notify: notify() });
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const lowering = () => {
|
|
163
|
+
const b = num(budget());
|
|
164
|
+
const cur = s();
|
|
165
|
+
return b !== undefined && cur !== undefined && b < cur.contextBudgetTokens;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const dirty = () => {
|
|
169
|
+
const cur = s();
|
|
170
|
+
if (!cur) return false;
|
|
171
|
+
const b = num(budget()), t = num(tail()), p = num(pace());
|
|
172
|
+
return (b !== undefined && b !== cur.contextBudgetTokens)
|
|
173
|
+
|| (t !== undefined && t !== cur.tailTokens)
|
|
174
|
+
|| (p !== undefined && p !== cur.transitionPaceTokens);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div class="h-full overflow-y-auto px-3 py-2 text-xs">
|
|
179
|
+
<div class="flex items-center gap-2 mb-2">
|
|
180
|
+
<span class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold">
|
|
181
|
+
context settings
|
|
182
|
+
</span>
|
|
183
|
+
<Show when={props.state}>
|
|
184
|
+
<span class="text-neutral-600 text-[10px] font-mono">{props.state!.agent}</span>
|
|
185
|
+
</Show>
|
|
186
|
+
<button
|
|
187
|
+
type="button"
|
|
188
|
+
class="ml-auto px-2 py-0.5 text-[10px] bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded font-mono"
|
|
189
|
+
onClick={() => { props.onRefresh(); setTimeout(syncFromLive, 150); }}
|
|
190
|
+
>
|
|
191
|
+
refresh
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<Show when={!props.loaded}>
|
|
196
|
+
<div class="text-neutral-600 italic">Loading…</div>
|
|
197
|
+
</Show>
|
|
198
|
+
|
|
199
|
+
<Show when={props.loaded && !props.state}>
|
|
200
|
+
<div class="text-amber-400/80">Runtime settings unavailable on this build.</div>
|
|
201
|
+
</Show>
|
|
202
|
+
|
|
203
|
+
<Show when={props.state}>
|
|
204
|
+
{/* ---- transition status: the thing an operator most needs to see ---- */}
|
|
205
|
+
<Show when={s()!.transition !== 'stable'}>
|
|
206
|
+
<div
|
|
207
|
+
class={`mb-2 px-2 py-1.5 rounded border text-[11px] ${
|
|
208
|
+
s()!.transition === 'blocked'
|
|
209
|
+
? 'bg-red-950/40 border-red-800 text-red-300'
|
|
210
|
+
: 'bg-amber-950/40 border-amber-800 text-amber-200'
|
|
211
|
+
}`}
|
|
212
|
+
>
|
|
213
|
+
<div class="font-semibold">
|
|
214
|
+
{s()!.transition === 'blocked' ? 'descent BLOCKED' : 'descent converging'}
|
|
215
|
+
</div>
|
|
216
|
+
<Show when={s()!.transitionReason}>
|
|
217
|
+
<div class="mt-0.5 text-[10px] opacity-90">
|
|
218
|
+
{TRANSITION_HELP[s()!.transitionReason!] ?? s()!.transitionReason}
|
|
219
|
+
</div>
|
|
220
|
+
</Show>
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
class="mt-1 px-2 py-0.5 text-[10px] bg-neutral-800 hover:bg-neutral-700 text-neutral-200 rounded font-mono"
|
|
224
|
+
onClick={() => props.onCancelTransition()}
|
|
225
|
+
>
|
|
226
|
+
cancel transition (hold current frontier)
|
|
227
|
+
</button>
|
|
228
|
+
</div>
|
|
229
|
+
</Show>
|
|
230
|
+
|
|
231
|
+
<Show when={!props.state!.hotConfigurable}>
|
|
232
|
+
<div class="mb-2 px-2 py-1 rounded bg-neutral-900 border border-neutral-800 text-[10px] text-neutral-400">
|
|
233
|
+
This strategy is not hot-configurable — only the budget can be changed live.
|
|
234
|
+
</div>
|
|
235
|
+
</Show>
|
|
236
|
+
|
|
237
|
+
{/* ---- live values ---- */}
|
|
238
|
+
<table class="w-full mb-3 font-mono text-[11px]">
|
|
239
|
+
<tbody>
|
|
240
|
+
<For each={[
|
|
241
|
+
['contextBudgetTokens', s()!.contextBudgetTokens],
|
|
242
|
+
['tailTokens', s()!.tailTokens],
|
|
243
|
+
['transitionPaceTokens', s()!.transitionPaceTokens],
|
|
244
|
+
['sameRoundThinkTextPolicy', s()!.sameRoundThinkTextPolicy],
|
|
245
|
+
] as Array<[string, unknown]>}>
|
|
246
|
+
{([k, v]) => (
|
|
247
|
+
<tr>
|
|
248
|
+
<td class="text-neutral-500 pr-2">{k}</td>
|
|
249
|
+
<td class="text-neutral-200 text-right tabular-nums">
|
|
250
|
+
{v === undefined ? '–' : String(v)}
|
|
251
|
+
</td>
|
|
252
|
+
<td class="pl-2 w-16">
|
|
253
|
+
<Show when={props.state!.overrides.includes(k)}>
|
|
254
|
+
<span class="text-[9px] px-1 rounded bg-cyan-900/50 text-cyan-300">override</span>
|
|
255
|
+
</Show>
|
|
256
|
+
</td>
|
|
257
|
+
</tr>
|
|
258
|
+
)}
|
|
259
|
+
</For>
|
|
260
|
+
</tbody>
|
|
261
|
+
</table>
|
|
262
|
+
|
|
263
|
+
{/* ---- editors ---- */}
|
|
264
|
+
<div class="space-y-1.5 mb-2">
|
|
265
|
+
<For each={[
|
|
266
|
+
['budget', 'contextBudgetTokens', budget, setBudget] as const,
|
|
267
|
+
['tail', 'tailTokens', tail, setTail] as const,
|
|
268
|
+
['pace', 'transitionPaceTokens', pace, setPace] as const,
|
|
269
|
+
]}>
|
|
270
|
+
{([label, key, get, set]) => (
|
|
271
|
+
<label class="flex items-center gap-2">
|
|
272
|
+
<span class="text-neutral-500 w-12">{label}</span>
|
|
273
|
+
<input
|
|
274
|
+
type="number"
|
|
275
|
+
disabled={!isHot(key)}
|
|
276
|
+
value={get()}
|
|
277
|
+
placeholder={isHot(key) ? 'tokens' : 'restart only'}
|
|
278
|
+
onInput={(e) => { set(e.currentTarget.value); runPreview(); }}
|
|
279
|
+
class="flex-1 bg-neutral-900 border border-neutral-700 rounded px-1.5 py-0.5
|
|
280
|
+
font-mono text-[11px] text-neutral-100 disabled:opacity-40"
|
|
281
|
+
/>
|
|
282
|
+
</label>
|
|
283
|
+
)}
|
|
284
|
+
</For>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<div class="flex items-center gap-3 mb-2 text-[10px] text-neutral-400">
|
|
288
|
+
<label class="flex items-center gap-1">
|
|
289
|
+
<input type="checkbox" checked={persist()} onChange={(e) => setPersist(e.currentTarget.checked)} />
|
|
290
|
+
persist
|
|
291
|
+
</label>
|
|
292
|
+
<label class="flex items-center gap-1" title="Injects a notice into the agent's context. This perturbs the very context being tuned — it invalidates the KV prefix and is classifier-visible. The agent can always read settings itself via agent_settings.">
|
|
293
|
+
<input type="checkbox" checked={notify()} onChange={(e) => setNotify(e.currentTarget.checked)} />
|
|
294
|
+
notify agent
|
|
295
|
+
</label>
|
|
296
|
+
</div>
|
|
297
|
+
<div class="text-[10px] text-neutral-600 mb-2 leading-relaxed">
|
|
298
|
+
<Show when={!persist()}>
|
|
299
|
+
<div>ephemeral — live now, reverts on restart.</div>
|
|
300
|
+
</Show>
|
|
301
|
+
<Show when={lowering()}>
|
|
302
|
+
<div class="text-amber-500/90">
|
|
303
|
+
lowering the budget converges gradually, not instantly — it will report
|
|
304
|
+
<span class="font-mono"> converging</span> until it settles.
|
|
305
|
+
</div>
|
|
306
|
+
</Show>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<div class="flex items-center gap-2 mb-3">
|
|
310
|
+
<button
|
|
311
|
+
type="button"
|
|
312
|
+
disabled={!dirty()}
|
|
313
|
+
class="px-2 py-0.5 text-[10px] rounded font-mono bg-cyan-900/50 hover:bg-cyan-900/70
|
|
314
|
+
text-cyan-200 disabled:opacity-30 disabled:hover:bg-cyan-900/50"
|
|
315
|
+
onClick={apply}
|
|
316
|
+
>
|
|
317
|
+
apply
|
|
318
|
+
</button>
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
class="px-2 py-0.5 text-[10px] bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded font-mono"
|
|
322
|
+
onClick={() => { props.onReset(undefined, persist()); setTimeout(syncFromLive, 200); }}
|
|
323
|
+
>
|
|
324
|
+
reset to recipe
|
|
325
|
+
</button>
|
|
326
|
+
<button
|
|
327
|
+
type="button"
|
|
328
|
+
class="px-2 py-0.5 text-[10px] bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded font-mono"
|
|
329
|
+
onClick={syncFromLive}
|
|
330
|
+
>
|
|
331
|
+
revert edits
|
|
332
|
+
</button>
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
{/* ---- preview ---- */}
|
|
336
|
+
<div class="border-t border-neutral-800 pt-2">
|
|
337
|
+
<div class="flex items-center gap-2 mb-1">
|
|
338
|
+
<span class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold">
|
|
339
|
+
preview
|
|
340
|
+
</span>
|
|
341
|
+
<Show when={previewing()}>
|
|
342
|
+
<span class="text-neutral-600 text-[10px]">computing…</span>
|
|
343
|
+
</Show>
|
|
344
|
+
</div>
|
|
345
|
+
|
|
346
|
+
<Show when={!props.state!.previewAvailable}>
|
|
347
|
+
<div class="text-[10px] text-neutral-500 leading-relaxed">
|
|
348
|
+
Preview unavailable on this build — the resolved context-manager has no
|
|
349
|
+
dry-run support. Apply still works; you just won't see the plan first.
|
|
350
|
+
</div>
|
|
351
|
+
</Show>
|
|
352
|
+
|
|
353
|
+
<Show when={props.state!.previewAvailable && previewErr()}>
|
|
354
|
+
<div class="text-[10px] text-amber-400/90">{previewErr()}</div>
|
|
355
|
+
</Show>
|
|
356
|
+
|
|
357
|
+
<Show when={preview()}>
|
|
358
|
+
{(p) => (
|
|
359
|
+
<div>
|
|
360
|
+
<div
|
|
361
|
+
class={`px-2 py-1 rounded mb-1.5 text-[11px] border ${
|
|
362
|
+
p().fits
|
|
363
|
+
? 'bg-emerald-950/40 border-emerald-800 text-emerald-300'
|
|
364
|
+
: 'bg-red-950/40 border-red-800 text-red-300'
|
|
365
|
+
}`}
|
|
366
|
+
>
|
|
367
|
+
{p().fits
|
|
368
|
+
? `fits — ${p().finalTokens.toLocaleString()} of ${p().budgetTokens.toLocaleString()} tok`
|
|
369
|
+
: `DOES NOT FIT — ${p().finalTokens.toLocaleString()} tok vs ${p().budgetTokens.toLocaleString()} budget`}
|
|
370
|
+
<Show when={!p().fits && p().exhausted}>
|
|
371
|
+
<div class="text-[10px] opacity-90 mt-0.5">
|
|
372
|
+
picker exhausted: nothing further can be folded. Applying this would
|
|
373
|
+
hard-fail the compile.
|
|
374
|
+
</div>
|
|
375
|
+
</Show>
|
|
376
|
+
</div>
|
|
377
|
+
|
|
378
|
+
<table class="w-full font-mono text-[10px]">
|
|
379
|
+
<tbody>
|
|
380
|
+
<For each={[
|
|
381
|
+
['head (verbatim)', p().headTokens],
|
|
382
|
+
['tail (verbatim)', p().tailTokens],
|
|
383
|
+
['middle (foldable)', p().middleTokens],
|
|
384
|
+
['middle chunks', p().middleChunkCount],
|
|
385
|
+
['deepest fold level', `L${p().deepestLevel}`],
|
|
386
|
+
['folds applied', p().appliedCount],
|
|
387
|
+
] as Array<[string, unknown]>}>
|
|
388
|
+
{([k, v]) => (
|
|
389
|
+
<tr>
|
|
390
|
+
<td class="text-neutral-500 pr-2">{k}</td>
|
|
391
|
+
<td class="text-neutral-300 text-right tabular-nums">
|
|
392
|
+
{typeof v === 'number' ? v.toLocaleString() : String(v)}
|
|
393
|
+
</td>
|
|
394
|
+
</tr>
|
|
395
|
+
)}
|
|
396
|
+
</For>
|
|
397
|
+
<tr>
|
|
398
|
+
<td class="text-neutral-500 pr-2">summaries to produce</td>
|
|
399
|
+
<td
|
|
400
|
+
class={`text-right tabular-nums ${
|
|
401
|
+
p().producedCount > 0 ? 'text-amber-400' : 'text-neutral-300'
|
|
402
|
+
}`}
|
|
403
|
+
>
|
|
404
|
+
{p().producedCount}
|
|
405
|
+
</td>
|
|
406
|
+
</tr>
|
|
407
|
+
</tbody>
|
|
408
|
+
</table>
|
|
409
|
+
<Show when={p().producedCount > 0}>
|
|
410
|
+
<div class="text-[10px] text-amber-500/80 mt-1 leading-relaxed">
|
|
411
|
+
{p().producedCount} summar{p().producedCount === 1 ? 'y' : 'ies'} do not exist
|
|
412
|
+
yet — reaching this layout costs that many compression calls first.
|
|
413
|
+
</div>
|
|
414
|
+
</Show>
|
|
415
|
+
</div>
|
|
416
|
+
)}
|
|
417
|
+
</Show>
|
|
418
|
+
</div>
|
|
419
|
+
|
|
420
|
+
{/* ---- restart-only knobs, shown but not offered ---- */}
|
|
421
|
+
<div class="border-t border-neutral-800 mt-3 pt-2">
|
|
422
|
+
<div class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold mb-1">
|
|
423
|
+
restart only
|
|
424
|
+
</div>
|
|
425
|
+
<div class="text-[10px] text-neutral-600 leading-relaxed">
|
|
426
|
+
These come from the recipe and are read at construction — changing them needs a
|
|
427
|
+
restart, not this panel:
|
|
428
|
+
<span class="font-mono text-neutral-500"> {RESTART_ONLY.join(', ')}</span>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
</Show>
|
|
432
|
+
</div>
|
|
433
|
+
);
|
|
434
|
+
}
|