Skip to content

Commit 8f53fee

Browse files
committed
feat(gui): move delegation settings onto the sub-agents page
Delegation controls (`/api/injection-model`) lived on the Dashboard, where they were the only interactive panel on an otherwise read-only status page. They belong with the rest of the sub-agent settings. The fetch/patch pair moves into a single hook so both surfaces read one owner instead of duplicating the endpoint, and the Dashboard keeps a one-line summary that links across rather than dropping the information entirely. Includes the accompanying workspace section, styles, and the six locale files; test expectations move with the panels. gui typecheck clean; gui doctor exit 0; gui test 427 pass 0 fail.
1 parent 4c7a8ea commit 8f53fee

17 files changed

Lines changed: 722 additions & 149 deletions

gui/src/components/MemoryObservabilityCard.tsx

Lines changed: 120 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useEffect, useState } from "react";
22
import { formatUptime } from "../formatUptime";
33
import { IconActivity } from "../icons";
4-
import { useI18n, type Locale } from "../i18n/shared";
4+
import { useI18n, type Locale, type TFn } from "../i18n/shared";
55
import { createBoundedFetch, type BoundedFetch } from "../bounded-fetch";
66

77
/**
@@ -123,11 +123,68 @@ function observedGrowthPerHour(samples: MemorySample[]): number | null {
123123
}
124124

125125
/** One labelled monospace metric cell inside a stat-row. */
126-
function Stat({ label, value }: { label: string; value: string }) {
126+
function Stat({ label, value, sub, tone }: { label: string; value: string; sub?: string; tone?: "warn" | "danger" }) {
127127
return (
128128
<div className="stat">
129129
<div className="label">{label}</div>
130-
<div className="value mono">{value}</div>
130+
<div className={`value mono${tone ? ` value--${tone}` : ""}`}>{value}</div>
131+
{sub && <div className="stat-sub mono">{sub}</div>}
132+
</div>
133+
);
134+
}
135+
136+
/**
137+
* Headline pressure gauge: observed memory against the watchdog warn threshold.
138+
* The scalar cells below answer "what is allocated"; this answers "is that a
139+
* problem", which is the only question a glance at this panel should need.
140+
*/
141+
function MemoryPressure({
142+
observedBytes,
143+
thresholdBytes,
144+
metric,
145+
locale,
146+
t,
147+
}: {
148+
observedBytes: number | null;
149+
thresholdBytes: number | null;
150+
metric: MemoryMetric | null;
151+
locale: Locale;
152+
t: TFn;
153+
}) {
154+
const ratio =
155+
observedBytes !== null && thresholdBytes !== null && thresholdBytes > 0
156+
? observedBytes / thresholdBytes
157+
: null;
158+
// Warn before the watchdog fires, not at the same instant: 75% is the point
159+
// where a still-climbing process is worth looking at.
160+
const tone = ratio === null ? "unknown" : ratio >= 1 ? "over" : ratio >= 0.75 ? "warn" : "ok";
161+
const pct = ratio === null ? null : Math.round(ratio * 100);
162+
return (
163+
<div className={`mem-pressure mem-pressure--${tone}`}>
164+
<div className="mem-pressure-head">
165+
<span className="mem-pressure-label">
166+
{t("dash.mem.pressure")}
167+
{metric ? <span className="mem-pressure-metric mono">{metric}</span> : null}
168+
</span>
169+
<span className="mem-pressure-figure mono">
170+
{observedBytes === null ? "—" : formatBytes(observedBytes, locale)}
171+
{thresholdBytes !== null && (
172+
<span className="mem-pressure-limit">
173+
{" / "}
174+
{formatBytes(thresholdBytes, locale)}
175+
</span>
176+
)}
177+
</span>
178+
</div>
179+
<div className="mem-pressure-track" role="presentation">
180+
<span
181+
className="mem-pressure-fill"
182+
style={{ ["--mem-scale" as string]: String(ratio === null ? 0 : Math.min(1, Math.max(0.01, ratio))) }}
183+
/>
184+
</div>
185+
<div className="mem-pressure-foot">
186+
{pct === null ? t("dash.mem.pressureUnknown") : t("dash.mem.pressureOf", { pct })}
187+
</div>
131188
</div>
132189
);
133190
}
@@ -322,25 +379,76 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string }
322379
const growth = data?.watchdog ? observedGrowthPerHour(data.watchdog.samples) : null;
323380
const observedBytes = data ? data.observedBytes ?? data.watchdog?.observedBytes ?? observedMemory(data) : null;
324381
const observedBy = data ? observedMetric(data) : null;
382+
// Drift only matters relative to the headroom it eats. Flag it when the current
383+
// climb would reach the warn threshold within an hour (danger) or a shift (warn);
384+
// a flat or falling process stays neutral no matter how large it already is.
385+
const growthTone = ((): "warn" | "danger" | undefined => {
386+
const threshold = data?.watchdog?.warnThresholdBytes;
387+
if (growth === null || growth <= 0 || observedBytes === null || !threshold) return undefined;
388+
const headroom = threshold - observedBytes;
389+
if (headroom <= 0) return "danger";
390+
const hoursLeft = headroom / growth;
391+
if (hoursLeft <= 1) return "danger";
392+
if (hoursLeft <= 8) return "warn";
393+
return undefined;
394+
})();
325395
// Optional on purpose: a 200 from an older proxy may lack the responseState field.
326396
const responseState = data?.responseState;
327397
const activeTurns = data?.activeTurnCount;
328398
const busy = restartPhase === "draining" || restartPhase === "reconnecting";
329399

330400
return (
331401
<div className="panel" style={{ marginBottom: 24 }}>
332-
<div className="font-semibold" style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
333-
<IconActivity width={16} height={16} aria-hidden="true" />
334-
{t("dash.mem.title")}
402+
{/* Title row carries the in-flight count and the restart action: as its own
403+
block below the stats it cost ~87px of near-empty panel height. */}
404+
<div className="mem-head">
405+
<div className="font-semibold mem-head-title">
406+
<IconActivity width={16} height={16} aria-hidden="true" />
407+
{t("dash.mem.title")}
408+
</div>
409+
{supportsRestart && (
410+
<div className="mem-head-actions">
411+
<span className="mem-inflight">
412+
<span className="mem-inflight-label">{t("dash.mem.inFlight")}</span>
413+
<span className="mem-inflight-value mono">
414+
{typeof activeTurns === "number" ? plainNumberFormat(locale).format(activeTurns) : "—"}
415+
</span>
416+
</span>
417+
<button
418+
type="button"
419+
className="btn btn-ghost btn-sm"
420+
disabled={busy}
421+
onClick={confirmRestart}
422+
>
423+
{t("dash.mem.restart")}
424+
</button>
425+
</div>
426+
)}
335427
</div>
336428

337-
<div className="stat-row">
429+
<MemoryPressure
430+
observedBytes={observedBytes}
431+
thresholdBytes={data?.watchdog?.warnThresholdBytes ?? null}
432+
metric={observedBy}
433+
locale={locale}
434+
t={t}
435+
/>
436+
437+
<div className="stat-row mem-stats">
338438
<Stat label={t("dash.mem.rss")} value={data ? formatBytes(data.rss, locale) : "—"} />
339-
<Stat label={t("dash.mem.jsHeap")} value={data ? `${formatBytes(data.heapUsed, locale)} / ${formatBytes(data.heapTotal, locale)}` : "—"} />
439+
{/* heapUsed can exceed heapTotal on Bun (external buffers are counted in
440+
used but not in the JS-object arena), so a "used / total" label reads
441+
as a contradiction. Show the live figure and demote the arena size. */}
442+
<Stat
443+
label={t("dash.mem.jsHeap")}
444+
value={data ? formatBytes(data.heapUsed, locale) : "—"}
445+
sub={data ? t("dash.mem.jsHeapArena", { total: formatBytes(data.heapTotal, locale) }) : undefined}
446+
/>
340447
<Stat label={t("dash.mem.jscHeap")} value={data?.jscHeap ? formatBytes(data.jscHeap.heapSize, locale) : "—"} />
341448
<Stat
342449
label={t("dash.mem.growth")}
343-
value={growth === null ? "—" : `${growth >= 0 ? "+" : "-"}${formatBytes(Math.abs(growth), locale)}${t("dash.mem.perHour")}`}
450+
value={growth === null ? "—" : `${growth >= 0 ? "+" : "−"}${formatBytes(Math.abs(growth), locale)}${t("dash.mem.perHour")}`}
451+
tone={growthTone}
344452
/>
345453
</div>
346454

@@ -380,20 +488,10 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string }
380488
)}
381489
</details>
382490

491+
{/* Status line only: it stays out of the layout entirely while idle so the
492+
panel does not reserve a row for a message that is usually absent. */}
383493
{supportsRestart && (
384-
<div style={{ marginTop: 14, display: "flex", flexWrap: "wrap", alignItems: "center", gap: 12 }} aria-live="polite">
385-
<Stat
386-
label={t("dash.mem.inFlight")}
387-
value={typeof activeTurns === "number" ? plainNumberFormat(locale).format(activeTurns) : "—"}
388-
/>
389-
<button
390-
type="button"
391-
className="btn btn-ghost btn-sm"
392-
disabled={busy}
393-
onClick={confirmRestart}
394-
>
395-
{t("dash.mem.restart")}
396-
</button>
494+
<div className="mem-status" aria-live="polite">
397495
{restartPhase === "draining" && (
398496
<span className="muted text-control">
399497
{t("dash.mem.draining", { count: typeof activeTurns === "number" ? activeTurns : 0 })}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* Delegation settings for the Subagents tab.
3+
*
4+
* This panel used to sit on the Dashboard, which is otherwise a read-only status page — the
5+
* one place you could change something was also the first thing a new user saw. It reads
6+
* better next to the roster it affects: the roster picks who may be called, this picks who
7+
* gets called first.
8+
*/
9+
import { Select } from "../../ui";
10+
import { useT } from "../../i18n/shared";
11+
import type { DelegationPatch, DelegationModelOption } from "../../pages/use-subagent-delegation";
12+
13+
export interface SubagentDelegationSectionProps {
14+
model: string;
15+
effort: string;
16+
efforts: string[];
17+
available: DelegationModelOption[];
18+
guidanceEnabled: boolean;
19+
syncCodexDefaults: boolean;
20+
saving: boolean;
21+
onSave: (patch: DelegationPatch) => void;
22+
}
23+
24+
export default function SubagentDelegationSection({
25+
model,
26+
effort,
27+
efforts,
28+
available,
29+
guidanceEnabled,
30+
syncCodexDefaults,
31+
saving,
32+
onSave,
33+
}: SubagentDelegationSectionProps) {
34+
const t = useT();
35+
36+
return (
37+
<div className="swi-delegation">
38+
<div className="swi-delegation-row">
39+
<div className="setting-copy">
40+
<div className="font-semibold">{t("sub.delegation.model")}</div>
41+
<div className="muted setting-hint">{t("sub.delegation.modelHint")}</div>
42+
</div>
43+
<div className="swi-delegation-controls">
44+
<Select
45+
value={model}
46+
options={[
47+
{ value: "", label: t("dash.injectionNone") },
48+
...available.map(m => ({ value: m.namespaced, label: `${m.provider} / ${m.model}` })),
49+
]}
50+
onChange={v => onSave({ model: v || null, effort: effort || null })}
51+
disabled={saving}
52+
label={t("dash.injectionLabel")}
53+
/>
54+
{model && efforts.length > 0 && (
55+
<Select
56+
value={effort}
57+
options={[
58+
{ value: "", label: t("dash.injectionEffortNone") },
59+
...efforts.map(e => ({ value: e, label: e })),
60+
]}
61+
onChange={v => onSave({ model: model || null, effort: v || null })}
62+
disabled={saving}
63+
label={t("dash.injectionEffortLabel")}
64+
/>
65+
)}
66+
</div>
67+
</div>
68+
69+
<div className="swi-delegation-row">
70+
<div className="setting-copy">
71+
<div className="font-semibold">{t("dash.syncCodexSubagentDefaults")}</div>
72+
<div className="muted setting-hint">{t("dash.syncCodexSubagentDefaultsHint")}</div>
73+
</div>
74+
<button
75+
type="button"
76+
className={`switch ${syncCodexDefaults ? "on" : ""}`}
77+
onClick={() => onSave({ syncCodexSubagentDefaults: !syncCodexDefaults })}
78+
disabled={saving || !model}
79+
aria-label={t("dash.syncCodexSubagentDefaults")}
80+
aria-pressed={syncCodexDefaults}
81+
>
82+
<span className="knob" />
83+
</button>
84+
</div>
85+
86+
<div className="swi-delegation-row">
87+
<div className="setting-copy">
88+
<div className="font-semibold">{t("dash.multiAgentGuidance")}</div>
89+
<div className="muted setting-hint">{t("dash.multiAgentGuidanceHint")}</div>
90+
</div>
91+
<button
92+
type="button"
93+
className={`switch ${guidanceEnabled ? "on" : ""}`}
94+
onClick={() => onSave({ multiAgentGuidanceEnabled: !guidanceEnabled })}
95+
disabled={saving}
96+
aria-label={t("dash.multiAgentGuidance")}
97+
aria-pressed={guidanceEnabled}
98+
>
99+
<span className="knob" />
100+
</button>
101+
</div>
102+
</div>
103+
);
104+
}

0 commit comments

Comments
 (0)