feat(web): move Virtual displays to its own nav section; roomier preset grid

The Host page was crowded (identity, codecs, ports, GPU, displays, compositors) and the
virtual-display config surface is large enough to warrant its own home.

- New **Virtual displays** nav section: `/displays` route + `sections/Displays` (moved
  DisplayCard out of `sections/Host`), a `MonitorPlay` sidebar entry after Host, and
  `nav_displays` i18n. Removed the displays card from the Host page/view.
- On its own page the card splits into two: **Configuration** (presets + custom axes) and
  **Live displays** (the live list + arrangement table) — room to breathe.
- Presets now render in a max-2-column grid (`sm:grid-cols-2`) with larger padding, a bigger
  section heading + preset titles (text-base semibold), roomier spacing, and bottom-aligned
  "what it sets" badges so the cards line up.

web tsc + vite build + biome-lint green; deployed + verified on the Mutter box (.21).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 14:54:24 +00:00
parent 8986667b78
commit 6b4f9f86ed
8 changed files with 82 additions and 50 deletions
+596
View File
@@ -0,0 +1,596 @@
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@unom/ui/button";
import { type FC, useEffect, useState } from "react";
import {
getGetDisplayStateQueryKey,
getGetDisplaySettingsQueryKey,
useGetDisplaySettings,
useGetDisplayState,
useReleaseDisplay,
useSetDisplayLayout,
useSetDisplaySettings,
} from "@/api/gen/display/display";
import type {
ApiDisplayInfo,
DisplayPolicy,
EffectivePolicy,
Identity,
KeepAlive,
LayoutMode,
ModeConflict,
Preset,
Topology,
} from "@/api/gen/model";
import { ApiError } from "@/api/fetcher";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { m } from "@/paraglide/messages";
/**
* Container: the host's virtual-display management policy (design/display-management.md). Lets the
* operator pick a one-click preset OR set every option by hand — all WITHOUT any client connected
* (this is the host's *next-connect* behavior). The live-display list + multi-monitor arrangement
* table below act on whatever is currently streaming.
*/
export const DisplaySection: FC = () => {
const qc = useQueryClient();
const q = useGetDisplaySettings();
const save = useSetDisplaySettings();
// Local edit buffer, seeded once from the server and re-seeded after every successful apply.
const [draft, setDraft] = useState<DisplayPolicy | null>(null);
useEffect(() => {
if (q.data && draft === null) setDraft(q.data.settings);
}, [q.data, draft]);
// Apply a policy (a one-click preset, or the hand-edited Custom draft). A change takes effect on
// the next connect; a live session keeps the display it opened on.
const apply = (policy: DisplayPolicy) =>
save.mutate(
{ data: policy },
{
onSuccess: (res) => {
setDraft(res.settings);
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
},
},
);
return (
<div className="flex flex-col gap-card">
<Card>
<CardHeader>
<CardTitle>{m.display_config_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{m.host_displays_help()}</p>
<QueryState isLoading={q.isLoading} error={q.error} refetch={q.refetch}>
{q.data && draft && (
<DisplayForm
draft={draft}
setDraft={setDraft}
presets={q.data.presets}
apply={apply}
busy={save.isPending}
error={apiErrorMessage(save.error)}
/>
)}
</QueryState>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{m.display_live()}</CardTitle>
</CardHeader>
<CardContent>
<LiveDisplays />
</CardContent>
</Card>
</div>
);
};
/** Preset display order — Default first (the safe baseline), the situational ones, then Custom. */
const PRESET_ORDER = [
"default",
"shared-desktop",
"hotdesk",
"workstation",
"gaming-rig",
"custom",
] as const;
const DisplayForm: FC<{
draft: DisplayPolicy;
setDraft: (p: DisplayPolicy) => void;
presets: { id: string; summary: string; fields: EffectivePolicy }[];
apply: (p: DisplayPolicy) => void;
busy: boolean;
error?: string;
}> = ({ draft, setDraft, presets, apply, busy, error }) => {
const preset: Preset = draft.preset ?? "custom";
const isCustom = preset === "custom";
// The Custom fields (defaults filled): the edit buffer when preset === "custom", and what a
// preset→Custom switch is seeded from, so you customize starting from the current behavior.
const customFields: EffectivePolicy = {
keep_alive: draft.keep_alive ?? { mode: "duration", seconds: 10 },
topology: draft.topology ?? "auto",
mode_conflict: draft.mode_conflict ?? "separate",
identity: draft.identity ?? "per-client",
layout: draft.layout ?? { mode: "auto-row", positions: {} },
max_displays: draft.max_displays ?? 4,
};
const effective: EffectivePolicy =
(isCustom ? undefined : presets.find((p) => p.id === preset)?.fields) ?? customFields;
// The five named presets apply in ONE click; "Custom" reveals the fields, seeded from the current
// effective behavior (nothing changes until you Save).
const pickPreset = (id: string) => {
if (id === "custom") {
setDraft({
version: 1,
preset: "custom",
keep_alive: effective.keep_alive,
topology: effective.topology,
mode_conflict: effective.mode_conflict,
identity: effective.identity,
layout: effective.layout,
max_displays: effective.max_displays,
});
} else {
apply({ ...draft, preset: id as Preset });
}
};
const ka = customFields.keep_alive;
const secondsValue = ka.mode === "duration" ? ka.seconds : 300;
return (
<div className="space-y-4">
{/* One-click presets — a 2-up grid so each has room to breathe */}
<div className="space-y-3">
<Label className="text-base font-semibold">{m.display_preset()}</Label>
<div className="grid gap-3 sm:grid-cols-2">
{PRESET_ORDER.map((id) => {
const p = presets.find((x) => x.id === id);
const fields = id === "custom" ? undefined : p?.fields;
const summary = id === "custom" ? m.display_custom_desc() : p?.summary;
const selected = preset === id;
const soon = DISABLED_PRESETS.has(id);
const cls = [
"flex h-full flex-col rounded-lg border p-4 text-left transition-colors",
selected
? "border-primary ring-1 ring-primary"
: "hover:border-primary/40 hover:bg-muted/50",
soon ? "opacity-60" : "",
].join(" ");
return (
<button
key={id}
type="button"
disabled={busy || soon}
onClick={() => pickPreset(id)}
className={cls}
>
<div className="flex items-center justify-between gap-2">
<span className="text-base font-semibold">
{(PRESET_LABEL[id] ?? (() => id))()}
{soon && (
<span className="ml-2 text-xs font-normal text-muted-foreground">
{m.display_preset_soon()}
</span>
)}
</span>
{selected && (
<Badge variant="success">{m.display_preset_current()}</Badge>
)}
</div>
{summary && (
<p className="mt-1 text-sm text-muted-foreground">{summary}</p>
)}
{fields && (
<div className="mt-auto flex flex-wrap gap-1.5 pt-3">
<Badge variant="secondary">{fmtKeepAlive(fields.keep_alive)}</Badge>
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, fields.topology)}</Badge>
<Badge variant="outline">{tr(CONFLICT_LABEL, fields.mode_conflict)}</Badge>
<Badge variant="outline">{tr(IDENTITY_LABEL, fields.identity)}</Badge>
</div>
)}
</button>
);
})}
</div>
</div>
{/* Custom: every option by hand */}
{isCustom && (
<div className="space-y-4 rounded-md border p-4">
<div className="space-y-2">
<Label>{m.display_keep_alive()}</Label>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={ka.mode === "off" ? "default" : "outline"}
disabled={busy}
onClick={() => setDraft({ ...draft, keep_alive: { mode: "off" } })}
>
{m.display_keep_alive_off()}
</Button>
<Input
type="number"
min={0}
className="w-24"
value={secondsValue}
disabled={busy}
onChange={(e) =>
setDraft({
...draft,
keep_alive: {
mode: "duration",
seconds: Math.max(0, Number(e.target.value) || 0),
},
})
}
/>
<span className="text-sm text-muted-foreground">
{m.display_keep_alive_seconds()}
</span>
</div>
<p className="text-xs text-muted-foreground">{m.display_keep_alive_help()}</p>
</div>
<Choice
label={m.display_topology()}
help={m.display_topology_help()}
value={customFields.topology}
options={["auto", "extend", "primary", "exclusive"]}
labels={TOPOLOGY_LABEL}
disabled={busy}
onPick={(v) => setDraft({ ...draft, topology: v as Topology })}
/>
<Choice
label={m.display_conflict()}
help={m.display_conflict_help()}
value={customFields.mode_conflict}
options={["separate", "steal", "join", "reject"]}
labels={CONFLICT_LABEL}
disabled={busy}
onPick={(v) => setDraft({ ...draft, mode_conflict: v as ModeConflict })}
/>
<Choice
label={m.display_identity()}
help={m.display_identity_help()}
value={customFields.identity}
options={["shared", "per-client", "per-client-mode"]}
labels={IDENTITY_LABEL}
disabled={busy}
onPick={(v) => setDraft({ ...draft, identity: v as Identity })}
/>
<Choice
label={m.display_layout_mode()}
help={m.display_layout_help()}
value={customFields.layout.mode ?? "auto-row"}
options={["auto-row", "manual"]}
labels={LAYOUT_LABEL}
disabled={busy}
onPick={(v) =>
setDraft({
...draft,
layout: { mode: v as LayoutMode, positions: draft.layout?.positions ?? {} },
})
}
/>
<div className="space-y-2">
<Label htmlFor="disp-max">{m.display_max()}</Label>
<Input
id="disp-max"
type="number"
min={1}
max={16}
className="w-24"
value={draft.max_displays ?? 4}
disabled={busy}
onChange={(e) =>
setDraft({
...draft,
max_displays: Math.min(16, Math.max(1, Number(e.target.value) || 1)),
})
}
/>
</div>
<Button onClick={() => apply(draft)} disabled={busy}>
{m.display_save()}
</Button>
</div>
)}
{/* What's in force right now */}
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
<span className="text-sm text-muted-foreground">{m.display_effective()}:</span>
<Badge variant="secondary">{fmtKeepAlive(effective.keep_alive)}</Badge>
<Badge variant="secondary">{tr(TOPOLOGY_LABEL, effective.topology)}</Badge>
<Badge variant="outline">{tr(CONFLICT_LABEL, effective.mode_conflict)}</Badge>
<Badge variant="outline">{tr(IDENTITY_LABEL, effective.identity)}</Badge>
<Badge variant="outline">{tr(LAYOUT_LABEL, effective.layout.mode)}</Badge>
<Badge variant="outline">{`${effective.max_displays}×`}</Badge>
</div>
<p className="text-xs text-muted-foreground">{m.display_pending_note()}</p>
{error && <p className="text-sm text-amber-600 dark:text-amber-500">{error}</p>}
</div>
);
};
/** A labeled row of mutually-exclusive option buttons (topology / conflict / identity / layout). */
const Choice: FC<{
label: string;
help?: string;
value: string;
options: readonly string[];
labels: Record<string, () => string>;
disabled: boolean;
onPick: (v: string) => void;
}> = ({ label, help, value, options, labels, disabled, onPick }) => (
<div className="space-y-2">
<Label>{label}</Label>
<div className="flex flex-wrap gap-2">
{options.map((o) => (
<Button
key={o}
size="sm"
variant={value === o ? "default" : "outline"}
disabled={disabled}
onClick={() => onPick(o)}
>
{(labels[o] ?? (() => o))()}
</Button>
))}
</div>
{help && <p className="text-xs text-muted-foreground">{help}</p>}
</div>
);
/**
* The host's live/kept virtual displays, polled from `/display/state`, each with a Release button
* for lingering/pinned ones (active displays can't be released — that's session control).
*/
const LiveDisplays: FC = () => {
const qc = useQueryClient();
const state = useGetDisplayState({ query: { refetchInterval: 2_000 } });
const release = useReleaseDisplay();
const displays = state.data?.displays ?? [];
const kept = displays.filter((d) => d.state !== "active");
const doRelease = (slot?: number) =>
release.mutate(
{ data: { slot: slot ?? null } },
{ onSuccess: () => qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }) },
);
return (
<div className="space-y-3">
{kept.length > 0 && (
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
disabled={release.isPending}
onClick={() => doRelease()}
>
{m.display_release_all()}
</Button>
</div>
)}
{displays.length === 0 ? (
<p className="text-sm text-muted-foreground">{m.display_none_live()}</p>
) : (
<ul className="divide-y rounded-md border">
{displays.map((d) => (
<DisplayRow
key={d.slot}
d={d}
busy={release.isPending}
onRelease={() => doRelease(d.slot)}
/>
))}
</ul>
)}
<DisplayArrangement displays={displays} />
</div>
);
};
/**
* The multi-monitor **arrangement** editor (design/display-management.md §6.2): an x/y table over the
* live displays that carry a stable identity slot (the manual-layout key). Saving writes
* `PUT /display/layout`, which switches the host to a manual layout and applies from the next connect.
* Shown only for a ≥2-display group — arranging a single display is moot.
*/
const DisplayArrangement: FC<{ displays: ApiDisplayInfo[] }> = ({ displays }) => {
const qc = useQueryClient();
const saveLayout = useSetDisplayLayout();
// Only displays with a stable identity slot can be pinned (shared/anonymous ones have no key).
const arrangeable = displays.filter((d) => d.identity_slot != null);
// Local edit buffer keyed by identity-slot string → {x, y}, seeded once from the current positions.
const [pos, setPos] = useState<Record<string, { x: number; y: number }> | null>(null);
useEffect(() => {
if (pos === null && arrangeable.length > 0) {
const seed: Record<string, { x: number; y: number }> = {};
for (const d of arrangeable) seed[String(d.identity_slot)] = { x: d.x, y: d.y };
setPos(seed);
}
}, [arrangeable, pos]);
if (arrangeable.length < 2) return null;
const cur = pos ?? {};
const setXY = (slot: number, key: "x" | "y", val: number) => {
const k = String(slot);
setPos({ ...cur, [k]: { ...(cur[k] ?? { x: 0, y: 0 }), [key]: val } });
};
const onSave = () =>
saveLayout.mutate(
{ data: { positions: cur } },
{ onSuccess: () => qc.invalidateQueries({ queryKey: getGetDisplayStateQueryKey() }) },
);
return (
<div className="space-y-2 border-t pt-4">
<h4 className="text-sm font-medium">{m.display_arrange()}</h4>
<p className="text-xs text-muted-foreground">{m.display_arrange_help()}</p>
<div className="space-y-2">
{arrangeable.map((d) => {
const slot = d.identity_slot as number;
const p = cur[String(slot)] ?? { x: d.x, y: d.y };
return (
<div key={d.slot} className="flex flex-wrap items-center gap-2 text-sm">
<span className="w-44 truncate">
{d.mode} <code className="text-xs text-muted-foreground">#{slot}</code>
</span>
<Label className="text-xs" htmlFor={`disp-x-${slot}`}>
X
</Label>
<Input
id={`disp-x-${slot}`}
type="number"
className="w-24"
value={p.x}
disabled={saveLayout.isPending}
onChange={(e) => setXY(slot, "x", Math.trunc(Number(e.target.value) || 0))}
/>
<Label className="text-xs" htmlFor={`disp-y-${slot}`}>
Y
</Label>
<Input
id={`disp-y-${slot}`}
type="number"
className="w-24"
value={p.y}
disabled={saveLayout.isPending}
onChange={(e) => setXY(slot, "y", Math.trunc(Number(e.target.value) || 0))}
/>
</div>
);
})}
</div>
{saveLayout.error && (
<p className="text-sm text-amber-600 dark:text-amber-500">
{apiErrorMessage(saveLayout.error)}
</p>
)}
<Button size="sm" onClick={onSave} disabled={saveLayout.isPending}>
{m.display_arrange_save()}
</Button>
</div>
);
};
const DisplayRow: FC<{ d: ApiDisplayInfo; busy: boolean; onRelease: () => void }> = ({
d,
busy,
onRelease,
}) => {
const active = d.state === "active";
const stateLabel =
d.state === "active"
? m.display_state_active()
: d.state === "pinned"
? m.display_state_pinned()
: m.display_state_lingering();
return (
<li className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{d.mode}</span>
<Badge variant={active ? "success" : "secondary"}>{stateLabel}</Badge>
{active && d.sessions > 0 && (
<Badge variant="outline">{m.display_sessions({ count: d.sessions })}</Badge>
)}
</div>
<code className="text-xs text-muted-foreground">
{d.backend}
{d.expires_in_ms != null
? ` · ${m.display_expires_in({ sec: Math.ceil(d.expires_in_ms / 1000) })}`
: ""}
</code>
</div>
{!active && (
<Button size="sm" variant="outline" disabled={busy} onClick={onRelease}>
{m.display_release_btn()}
</Button>
)}
</li>
);
};
/** The server's `{ error }` message from a thrown `ApiError` (its `.data` body), for inline display. */
const apiErrorMessage = (err: unknown): string | undefined => {
if (err instanceof ApiError) {
const data = err.data as { error?: string } | undefined;
return data?.error ?? err.message;
}
return err ? String(err) : undefined;
};
/** `gaming-rig` expands to `keep_alive: forever`, which the host still rejects (Windows has no
* Pinned state yet) — surface it, but disabled, rather than let the one-click apply 400. */
const DISABLED_PRESETS: ReadonlySet<string> = new Set(["gaming-rig"]);
const PRESET_LABEL: Record<string, () => string> = {
custom: m.display_preset_custom,
default: m.display_preset_default,
"gaming-rig": m.display_preset_gaming_rig,
"shared-desktop": m.display_preset_shared_desktop,
hotdesk: m.display_preset_hotdesk,
workstation: m.display_preset_workstation,
};
const TOPOLOGY_LABEL: Record<string, () => string> = {
auto: m.display_topology_auto,
extend: m.display_topology_extend,
primary: m.display_topology_primary,
exclusive: m.display_topology_exclusive,
};
const CONFLICT_LABEL: Record<string, () => string> = {
separate: m.display_conflict_separate,
steal: m.display_conflict_steal,
join: m.display_conflict_join,
reject: m.display_conflict_reject,
};
const IDENTITY_LABEL: Record<string, () => string> = {
shared: m.display_identity_shared,
"per-client": m.display_identity_per_client,
"per-client-mode": m.display_identity_per_client_mode,
};
const LAYOUT_LABEL: Record<string, () => string> = {
"auto-row": m.display_layout_auto_row,
manual: m.display_layout_manual,
};
/** Look up a localized label, tolerating an unknown/undefined key (falls back to the raw value). */
const tr = (map: Record<string, () => string>, key: string | null | undefined): string => {
const fn = key == null ? undefined : map[key];
return fn ? fn() : String(key ?? "");
};
const fmtKeepAlive = (k: KeepAlive): string => {
switch (k.mode) {
case "off":
return m.display_keep_alive_off();
case "duration":
return `${k.seconds} ${m.display_keep_alive_seconds()}`;
case "forever":
return "∞";
}
};