Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ missing credential — the proxy did not recognise the request as loopback. Open
the address the proxy prints on startup (usually `http://127.0.0.1:<port>`), and prefer that exact
host and port over a LAN IP or an alias.

## Dashboard layout

Overview uses matching status cards and full-width settings rows. On wide screens, labels share
one column and model/effort controls share another. On narrower screens, controls move below their
labels in the same reading order. Long version labels are shortened visually; hover the version
badge or the Version card to read the full value.

## What you can do

| Area | What it does |
Expand Down
Binary file added docs/pr-assets/dashboard-settings-aligned.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion gui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ export default function App() {
>
<span className="brand-logo" role="img" aria-label={t("app.logoAria")} />
<span className="name">opencodex</span>
<span className="ver">v{displayedVersion}</span>
<span className="ver" title={displayedVersion}>v{displayedVersion}</span>
</button>
);

Expand Down
10 changes: 5 additions & 5 deletions gui/src/pages/dashboard-overview-head.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,16 @@ export function DashboardOverviewHead({
<IconInfo width={14} height={14} aria-hidden="true" />
</button>
</div>
<div className="value" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<div role="radiogroup" aria-label={t("dash.multiAgent")} style={{ display: "inline-flex", borderRadius: "var(--radius-pill)", background: "var(--surface-soft, var(--raised))", padding: 3, gap: 2 }}>
<div className="value" style={{ display: "flex", alignItems: "center" }}>
<div className="dash-ma-switch" role="radiogroup" aria-label={t("dash.multiAgent")} style={{ display: "inline-flex", borderRadius: "var(--radius-pill)", background: "var(--surface-soft, var(--raised))", padding: 3, gap: 2 }}>
{(["v1", "default", "v2"] as const).map(mode => (
<button
key={mode}
type="button"
role="radio"
aria-checked={maMode === mode}
className={`btn btn-sm text-caption${maMode === mode ? " btn-primary" : " btn-ghost"}`}
style={{ borderRadius: "var(--radius-pill)", minWidth: 36, padding: "5px 10px", border: "none", background: maMode === mode ? undefined : "transparent", color: maMode === mode ? undefined : "var(--muted)" }}
className={`btn btn-sm text-caption dash-ma-option${maMode === mode ? " btn-primary" : " btn-ghost"}`}
style={{ borderRadius: "var(--radius-pill)", border: "none", background: maMode === mode ? undefined : "transparent", color: maMode === mode ? undefined : "var(--muted)" }}
disabled={maBusy}
onClick={() => void switchMaMode(mode)}
>{t(`models.v2Mode_${mode}` as TKey)}</button>
Expand All @@ -76,7 +76,7 @@ export function DashboardOverviewHead({
<span className={`dot ${online ? "dot-green" : "dot-red"}`} />{online ? t("dash.online") : t("dash.offline")}
</div>
</div>
<div className="stat" aria-busy={healthLoading || undefined}><div className="label">{t("dash.version")}</div><div className="value mono">{health?.version ?? "—"}</div></div>
<div className="stat" aria-busy={healthLoading || undefined}><div className="label">{t("dash.version")}</div><div className="value mono dash-stat-version" title={health?.version}>{health?.version ?? "—"}</div></div>
<div className="stat" aria-busy={healthLoading || undefined}><div className="label">{t("dash.uptime")}</div><div className="value mono">{health ? formatUptime(health.uptime, locale) : "—"}</div></div>
<div className="stat" aria-busy={healthLoading || undefined}><div className="label">{t("dash.providers")}</div><div className="value">{providers.length}</div></div>
<div className="stat" aria-busy={usageLoading || undefined}>
Expand Down
106 changes: 54 additions & 52 deletions gui/src/pages/dashboard-overview-sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function DashboardEffortCapPanel({ apiBase, d }: { apiBase: string; d: Da
if (!maModeResolved || maMode === "v1") return null;

return (
<div className="panel">
<div className="panel dash-effort-panel">
<div className="injection-head">
<span className="injection-label" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
{t("dash.effortCapLabel")}
Expand All @@ -62,56 +62,58 @@ export function DashboardEffortCapPanel({ apiBase, d }: { apiBase: string; d: Da
<IconInfo width={13} height={13} aria-hidden="true" />
</button>
</span>
<Select
value={effortCap}
options={[
{ value: "", label: t("dash.effortCapNone") },
...EFFORT_CAP_LEVELS.map(e => ({ value: e, label: e })),
]}
onChange={async (v) => {
if (effortCapSaving) return;
setEffortCapSaving(true);
try {
const res = await fetch(`${apiBase}/api/effort-caps`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ effortCap: v || null }),
});
const data = await requireJson<{ ok: boolean; effortCap?: string | null; subagentEffortCap?: string | null }>(res);
setEffortCap(data.effortCap ?? "");
setSubagentEffortCap(data.subagentEffortCap ?? "");
} catch { /* ignore */ }
finally { setEffortCapSaving(false); }
}}
disabled={effortCapSaving}
label={t("dash.effortCapLabel")}
align="right"
/>
<Select
value={subagentEffortCap}
options={[
{ value: "", label: t("dash.effortCapNone") },
...EFFORT_CAP_LEVELS.map(e => ({ value: e, label: e })),
]}
onChange={async (v) => {
if (effortCapSaving) return;
setEffortCapSaving(true);
try {
const res = await fetch(`${apiBase}/api/effort-caps`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subagentEffortCap: v || null }),
});
const data = await requireJson<{ ok: boolean; effortCap?: string | null; subagentEffortCap?: string | null }>(res);
setEffortCap(data.effortCap ?? "");
setSubagentEffortCap(data.subagentEffortCap ?? "");
} catch { /* ignore */ }
finally { setEffortCapSaving(false); }
}}
disabled={effortCapSaving}
label={t("dash.subagentEffortCapLabel")}
align="right"
/>
<div className="dash-effort-controls">
<Select
value={effortCap}
options={[
{ value: "", label: t("dash.effortCapNone") },
...EFFORT_CAP_LEVELS.map(e => ({ value: e, label: e })),
]}
onChange={async (v) => {
if (effortCapSaving) return;
setEffortCapSaving(true);
try {
const res = await fetch(`${apiBase}/api/effort-caps`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ effortCap: v || null }),
});
const data = await requireJson<{ ok: boolean; effortCap?: string | null; subagentEffortCap?: string | null }>(res);
setEffortCap(data.effortCap ?? "");
setSubagentEffortCap(data.subagentEffortCap ?? "");
} catch { /* ignore */ }
finally { setEffortCapSaving(false); }
}}
disabled={effortCapSaving}
label={t("dash.effortCapLabel")}
align="right"
/>
<Select
value={subagentEffortCap}
options={[
{ value: "", label: t("dash.effortCapNone") },
...EFFORT_CAP_LEVELS.map(e => ({ value: e, label: e })),
]}
onChange={async (v) => {
if (effortCapSaving) return;
setEffortCapSaving(true);
try {
const res = await fetch(`${apiBase}/api/effort-caps`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subagentEffortCap: v || null }),
});
const data = await requireJson<{ ok: boolean; effortCap?: string | null; subagentEffortCap?: string | null }>(res);
setEffortCap(data.effortCap ?? "");
setSubagentEffortCap(data.subagentEffortCap ?? "");
} catch { /* ignore */ }
finally { setEffortCapSaving(false); }
}}
disabled={effortCapSaving}
label={t("dash.subagentEffortCapLabel")}
align="right"
/>
</div>
</div>
</div>
);
Expand Down Expand Up @@ -642,7 +644,7 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) {
</button>
<code className="muted text-caption">{`⚠ ${shadowSourceModelBadge(shadowCall?.sourceModels)}`}</code>
</div>
<div className="setting-controls" style={{ display: "flex", gap: 8, alignItems: "center" }}>
<div className="setting-controls dash-shadow-controls">
<button
type="button"
className={`switch ${shadowCall?.enabled ? "on" : ""}`}
Expand Down
166 changes: 164 additions & 2 deletions gui/src/styles-dashboard-workspace.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@
gap: var(--space-4);
}

/* Sub-agent delegation + Sync models share one row (same auto-fit rhythm as sidecars). */
/* Settings keep one reading order and one control column across the overview. */
.dash-overview-tools {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
grid-template-columns: minmax(0, 1fr);
gap: var(--space-4);
align-items: stretch;
}
Expand Down Expand Up @@ -527,3 +527,165 @@
color: var(--text);
white-space: nowrap;
}


/* Overview settings share a fixed control column, then stack together when the
content area is too narrow. Scope this to Overview; provider/model tables keep
their own layout. Dropdowns and popovers already portal outside the container. */
.dash-overview-stack {
--dash-controls-width: 26rem;

container: dashboard-overview / inline-size;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

.dash-overview-head .stat-row {
grid-template-columns: repeat(6, minmax(0, 1fr));
}

.dash-overview-head .stat-row > .stat {
min-width: 0;
min-height: 126px;
display: grid;
grid-template-rows: 24px 36px 18px;
align-content: center;
justify-content: stretch;
gap: 4px;
}

.dash-overview-head .stat .label {
margin: 0;
min-width: 0;
}

.dash-overview-head .stat .value {
min-width: 0;
align-self: center;
font-variant-numeric: tabular-nums;
}

.dash-overview-head .dash-stat-version {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.dash-overview-head .dash-stat-coverage { margin: 0; }

.dash-overview-stack .panel {
min-width: 0;
padding: 20px;
}

.dash-overview-stack .dash-delegation-summary,
.dash-overview-stack .dash-sync-summary,
.dash-overview-stack .dash-effort-panel .injection-head {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--dash-controls-width);
align-items: center;
gap: 24px;
}

.dash-overview-stack .dash-delegation-controls,
.dash-effort-controls,
.dash-shadow-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}

.dash-overview-tools .dash-delegation-controls {
justify-content: flex-start;
}

.dash-overview-tools .dash-delegation-controls > .custom-select:first-child {
flex: 1 1 14rem;
min-width: 0;
}

.dash-overview-tools .dash-delegation-controls > .btn {
margin-left: auto;
}

.dash-effort-panel .injection-label { order: 0; }
.dash-effort-controls { order: 1; }
.dash-effort-controls > .custom-select { flex: 1; min-width: 0; }

.dash-overview-stack .dash-delegation-controls .select-trigger,
.dash-effort-controls .select-trigger,
.dash-shadow-controls .select-trigger {
justify-content: space-between;
width: 100%;
min-width: 0;
}

.dash-overview-stack .dash-sidecar-row-card .dash-sidecar-copy {
min-width: 0;
}

.dash-overview-stack .dash-sidecar-row-card .setting-hint { min-height: 0; }

.dash-overview-stack .dash-sidecar-row-card .dash-delegation-controls {
width: 100%;
flex-direction: column;
align-items: stretch;
}

.dash-overview-stack .dash-sidecar-select-row .custom-select:first-child {
flex: 1;
min-width: 0;
max-width: none;
}

.dash-shadow-controls {
width: var(--dash-controls-width);
flex-shrink: 0;
}

.dash-shadow-controls > .custom-select { flex: 1; min-width: 0; }
.dash-shadow-controls > .switch { order: 1; }

.dash-overview-stack .setting-hint,
.dash-overview-stack .dash-sync-hint { max-width: 60ch; }

@container dashboard-overview (max-width: 64rem) {
.dash-overview-head .stat-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

@container dashboard-overview (max-width: 46rem) {
.dash-overview-stack .dash-delegation-summary,
.dash-overview-stack .dash-sync-summary,
.dash-overview-stack .dash-effort-panel .injection-head {
grid-template-columns: minmax(0, 1fr);
gap: 16px;
}

.dash-overview-stack .dash-delegation-controls,
.dash-overview-stack .dash-effort-controls,
.dash-overview-stack .dash-shadow-controls { width: 100%; }

.dash-overview-stack .dash-shadow-controls { flex-basis: 100%; }
.dash-overview-stack .spread:has(.dash-shadow-controls) { flex-wrap: wrap; }
.dash-overview-stack .maintenance-actions { justify-content: flex-start; }
}

@container dashboard-overview (max-width: 30rem) {
.dash-overview-head .stat-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dash-overview-stack .panel { padding: 16px; }
.dash-overview-stack .select-trigger,
.dash-overview-stack .panel .btn:not([aria-haspopup="dialog"]),
.dash-overview-stack .dash-vision-advanced-trigger { min-height: 44px; }
}


.dash-ma-option { min-width: 36px; padding: 5px 10px; }

@container dashboard-overview (max-width: 22rem) {
.dash-overview-head .dash-ma-switch { max-width: 100%; }
.dash-overview-head .dash-ma-option {
min-width: 0;
flex: 1 1 auto;
padding: 5px 4px;
}
}
8 changes: 6 additions & 2 deletions gui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -271,11 +271,15 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); }
stays visible in BOTH light and dark (a plain <img> was invisible white-on-white in light). */
.brand-logo { width: 26px; height: 26px; flex-shrink: 0; background: var(--text);
-webkit-mask: url(/logo.png) center / contain no-repeat; mask: url(/logo.png) center / contain no-repeat; }
.brand .name { font-weight: var(--weight-semibold); font-size: var(--text-subtitle); letter-spacing: 0; line-height: 26px; }
.brand .name { flex-shrink: 0; font-weight: var(--weight-semibold); font-size: var(--text-subtitle); letter-spacing: 0; line-height: 26px; }
.brand .ver {
font-family: var(--font-code); font-size: var(--text-micro); color: var(--muted); line-height: var(--leading-tight);
background: var(--raised); border: 1px solid var(--border); padding: 2px 6px; border-radius: var(--radius-pill);
align-self: center;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

/* [Decision Log]
Expand Down Expand Up @@ -2421,7 +2425,7 @@ button.prov-account-row.active { cursor: default; }
.mobile-topbar .brand .name {
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.mobile-topbar .brand .ver { flex-shrink: 0; }
.mobile-topbar .brand .ver { flex-shrink: 1; }
.mobile-topbar .stop-toggle { width: auto; min-width: 44px; min-height: 44px; justify-content: center; padding: 8px; }
/* Both orbs keep the 44x44 touch target the single stop button had; a bare 28px
.sidebar-orb would be a regression on the surface where it matters most. */
Expand Down
6 changes: 5 additions & 1 deletion gui/tests/mobile-topbar-layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ test("the mobile brand can shrink, so the version badge cannot reach the action
expect(name).toContain("text-overflow: ellipsis");

const ver = block(".mobile-topbar .brand .ver");
expect(ver).toContain("flex-shrink: 0");
expect(ver).toContain("flex-shrink: 1");
const badge = block(".brand .ver {");
expect(badge).toContain("min-width: 0");
expect(badge).toContain("white-space: nowrap");
expect(badge).toContain("text-overflow: ellipsis");
});

test("the topbar action orbs keep their touch target", () => {
Expand Down
Loading