import { useQueryClient } from "@tanstack/react-query"; import { useBlocker } from "@tanstack/react-router"; import { toast } from "@unom/ui/toast"; import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { type FC, type MouseEvent, type ReactNode, useEffect, useMemo, useRef, useState, } from "react"; import { getGetDisplaySettingsQueryKey, getGetDisplayStateQueryKey, useCreateCustomPreset, useDeleteCustomPreset, useGetDisplaySettings, useGetDisplayState, useReleaseDisplay, useSetDisplayLayout, useSetDisplaySettings, useUpdateCustomPreset, } from "@/api/gen/display/display"; import { useListGpus } from "@/api/gen/gpu/gpu"; import type { ApiDisplayInfo, CustomPreset, DisplayPolicy, EffectivePolicy, GameSession, Identity, KeepAlive, LayoutMode, ModeConflict, Preset, Topology, } from "@/api/gen/model"; import { type ConfirmOptions, useDialogs } from "@/components/dialogs"; import { QueryState } from "@/components/query-state"; import { Stagger } from "@/components/stagger"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { InputNumber } from "@/components/ui/input-number"; import { Label } from "@/components/ui/label"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { apiErrorMessage } from "@/lib/errors"; import { cn } from "@/lib/utils"; 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 { confirm } = useDialogs(); const q = useGetDisplaySettings(); const save = useSetDisplaySettings(); // Local edit buffer, seeded from the server. `seeded` tracks the server value we last seeded // from, so we can adopt a server-side change that happens underneath us — e.g. saving the display // ARRANGEMENT switches `layout` to manual and locks in Custom fields (`PUT /display/layout`) — // WITHOUT clobbering unsaved local edits: re-seed only while the draft still matches the last // seed (no pending edits). Custom edits aren't auto-applied (there's an explicit Save), so a // naive "always re-seed on server change" would eat them. const [draft, setDraft] = useState(null); const seeded = useRef(null); useEffect(() => { if (!q.data) return; const server = q.data.settings; if (draft === null) { setDraft(server); seeded.current = server; } else if ( seeded.current && deepEqual(draft, seeded.current) && !deepEqual(server, seeded.current) ) { setDraft(server); seeded.current = server; } }, [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); seeded.current = res.settings; qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() }); // The policy auto-saves on every preset pick / field edit — without a signal // users kept looking for a Save button. Errors stay inline (apiErrorMessage). toast.success(m.display_settings_saved()); }, }, ); /** * Save the hand-edited Custom block. * * `capture_monitor` (the streamed-screen pin) belongs to the monitor picker below, not to this * form — but it is a field of the same policy object, so a draft seeded before the operator * changed the streamed screen still carried the OLD value and Save quietly put it back. Defer * that one axis to whatever the server currently reports. */ /** The streamed-screen pin as the HOST currently has it. Every write path defers to this rather * than to the draft: the draft is only re-seeded while it is CLEAN, so once the operator has an * unsaved edit its `capture_monitor` is frozen at whatever it was before they used the picker * below — and any write that spreads the draft would put the old pin back. */ const serverCaptureMonitor = () => q.data?.settings.capture_monitor ?? null; const saveDraft = () => { if (!draft) return; apply({ ...draft, capture_monitor: serverCaptureMonitor() }); }; /** * Apply ONE orthogonal axis — game-session, DDC, PnP — without dragging unsaved Custom edits * along for the ride. * * These three controls apply immediately by design, but they used to send `{...draft}`: flipping * DDC while the Custom block held unsaved edits committed those edits too, and the shared * `apply` then overwrote the draft with the server's answer, clearing the "unsaved" badge — so * the operator got a policy they never saved with no trace it had happened. Send the axis on top * of the last SAVED policy, and merge only that axis back into the draft. */ const applyAxis = (patch: Partial) => { const base = seeded.current ?? draft; if (!base) return; // Reflect the flip straight away, keeping every other unsaved edit intact. setDraft((d) => (d ? { ...d, ...patch } : d)); save.mutate( { data: { ...base, capture_monitor: serverCaptureMonitor(), ...patch } }, { onSuccess: (res) => { seeded.current = res.settings; setDraft((d) => (d ? { ...d, ...patch } : res.settings)); qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() }); toast.success(m.display_settings_saved()); }, }, ); }; // Pending edits: the Custom fields do NOT auto-apply (unlike a preset click or an experimental // toggle), so the draft can silently diverge from what the host is actually running. Reading the // ref during render is safe here because every write to it is paired with a `setDraft`, so a // changed seed always comes with the re-render that reads it. const dirty = draft !== null && seeded.current !== null && !deepEqual(draft, seeded.current); const revert = () => { if (seeded.current) setDraft(seeded.current); }; // Don't lose pending edits, whichever way the operator leaves. // // This used to be a bare `beforeunload` listener, which only covers a reload or a tab close — // clicking "Host" in the sidebar is a client-side route change the browser never hears about, // so the draft vanished with no prompt at all. The router's blocker covers in-app navigation // AND still arms `beforeunload` for the reload case, so it replaces the listener outright. // // `shouldBlockFn` may return a promise, which is what lets this ask with the console's own // dialog rather than the browser's. `enableBeforeUnload` stays native by necessity: a reload or // a tab close is the browser's dialog to draw, and it will not wait on ours. useBlocker({ shouldBlockFn: async () => !(await confirm(discardPrompt())), enableBeforeUnload: () => dirty, disabled: !dirty, }); return ( } configuration={ <>

{m.host_displays_help()}

{/* Once the form is on screen, a FAILED BACKGROUND POLL must not replace it — the operator may be mid-edit, and swapping the card for an error box throws the draft away to report a refetch we could simply retry. Only a failure with nothing to show is worth the error state. */} {draft && q.error && (

{m.display_refresh_failed()}

)} {q.data && draft && ( )}
} /> ); }; /** * The page's tab shell: **Configuration** and **Live displays** as the same pill strip the plugin * UIs use, over a card per tab. * * Tabs rather than two stacked cards because the configuration card alone is taller than the * viewport — which is how pending edits went unnoticed — and the live list sat below it, effectively * off screen. * * Presentational on purpose, taking both panes as nodes: `DisplaySection` cannot be rendered in * Storybook (it calls `useBlocker`, which needs a router), so putting the strip here is what keeps * it reachable from a story. That matters more than usual on this page — `Displays.stories.tsx` * exists to pin the MOTION NESTING of the preset grid, and inserting tabs changes that ancestor * chain, so the story has to render the real one. */ export const DisplayTabs: FC<{ dirty: boolean; configuration: ReactNode; live: ReactNode; }> = ({ dirty, configuration, live }) => ( {m.display_config_title()} {/* The dirty marker rides the TAB, not the card header. It used to sit inside a card taller than the viewport; behind a tab it would vanish altogether while the Live tab was open. On the trigger it survives both — and the Custom block keeps its own inline badge, so nothing is lost when this tab IS open. */} {dirty && ( )} {m.display_live()} {configuration} {live} ); /** * The gate on anything that would throw unsaved Custom fields away — asked from three places (a * preset click, applying a saved preset, and leaving the page), so it is written once. A function * rather than a constant because the message is resolved per locale, at call time. */ const discardPrompt = (): ConfirmOptions => ({ title: m.display_discard_confirm(), confirmLabel: m.common_discard(), destructive: true, }); /** 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; /** * The policy form itself — pure, so Storybook can render it (with the `` wrapper the page puts * around it, which is also its motion parent) without a host answering `/display/settings`. */ export const DisplayForm: FC<{ draft: DisplayPolicy; setDraft: (p: DisplayPolicy) => void; presets: { id: string; summary: string; fields: EffectivePolicy }[]; customPresets: CustomPreset[]; /** What the host reports as IN FORCE right now — not derived from the local draft. */ serverEffective: EffectivePolicy; /** The streamed-screen pin as the host has it — the draft's copy goes stale while dirty. */ serverCaptureMonitor: () => string | null; apply: (p: DisplayPolicy) => void; /** Apply one orthogonal axis on top of the SAVED policy — never the unsaved draft. */ applyAxis: (patch: Partial) => void; /** Commit the Custom block, deferring axes this form does not own to the server's value. */ saveDraft: () => void; busy: boolean; /** The draft differs from what the host has stored — drives the save bar + the discard guard. */ dirty: boolean; /** Throw the draft away and go back to the stored policy. */ revert: () => void; error?: string; }> = ({ draft, setDraft, presets, customPresets, serverEffective, serverCaptureMonitor, apply, applyAxis, saveDraft, busy, dirty, revert, error, }) => { const qc = useQueryClient(); const { confirm, promptText } = useDialogs(); // The EDID-lock toggle is gated on an AMD GPU being present — the axis is the AMD driver's // ADL connector-emulation lever and exists nowhere else. GPUs don't hot-swap; one fetch with // the section's lifetime is plenty (no refetch interval). const gpus = useListGpus(); const amdHost = (gpus.data?.gpus ?? []).some((g) => g.vendor === "amd"); const createPreset = useCreateCustomPreset(); const updatePreset = useUpdateCustomPreset(); const deletePreset = useDeleteCustomPreset(); const invalidateSettings = () => qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() }); const presetBusy = createPreset.isPending || updatePreset.isPending || deletePreset.isPending; const presetError = apiErrorMessage( createPreset.error ?? updatePreset.error ?? deletePreset.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 = async (id: string) => { // A preset click overwrites the whole policy, so hand-edits that were never saved would // vanish without a word — the same failure as not finding the Save button, one click later. if (dirty && id !== "custom" && !(await confirm(discardPrompt()))) return; // Already hand-editing: re-seeding would fill in defaults for fields the stored policy // leaves unset and flag "unsaved changes" for a click that changed nothing. if (id === "custom" && isCustom) return; 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, // Game-session + the experimental axes are orthogonal to the preset — carry them // through the Custom switch. game_session: draft.game_session ?? "auto", ddc_power_off: draft.ddc_power_off ?? false, pnp_disable_monitors: draft.pnp_disable_monitors ?? false, edid_lock: draft.edid_lock ?? false, // Which screen we stream is not a display-behavior axis at all — swapping the // streamed screen out from under the operator because they changed a preset would be // the worst kind of surprise. From the SERVER, not the draft (see serverCaptureMonitor). capture_monitor: serverCaptureMonitor(), }); } else { apply({ ...draft, preset: id as Preset }); } }; // Applying a custom preset writes a `Custom` policy carrying its saved fields + game-session (the // one axis a preset DOES set) — the host has no separate apply route (design/gamemode-and-…). const applyCustomPreset = async (p: CustomPreset) => { if (dirty && !(await confirm(discardPrompt()))) return; apply({ version: 1, preset: "custom", ...p.fields, game_session: p.game_session ?? "auto", // The experimental axes aren't part of a preset — keep the current settings. ddc_power_off: draft.ddc_power_off ?? false, pnp_disable_monitors: draft.pnp_disable_monitors ?? false, edid_lock: draft.edid_lock ?? false, // Nor is the streamed screen: this builds a FRESH policy object rather than spreading // the draft, so anything not named here is silently dropped — which is exactly how // applying a saved preset used to switch a mirroring host back to a virtual display // (found on-glass, .136). Every orthogonal axis has to be listed, and this one comes // from the SERVER (see serverCaptureMonitor). capture_monitor: serverCaptureMonitor(), }); }; // A custom card is "current" when the in-force policy is a Custom one whose fields + game-session // value-match this preset (there is no id on DisplayPolicy — match by value). const customSelected = (p: CustomPreset): boolean => isCustom && (draft.game_session ?? "auto") === (p.game_session ?? "auto") && deepEqual(effective, p.fields); const anyCustomSelected = customPresets.some(customSelected); // Save the currently-in-force behavior (built-in OR hand-edited) as a new named preset. const saveAsPreset = async () => { const name = ( await promptText({ title: m.display_preset_save_title(), label: m.display_preset_name(), }) )?.trim(); if (!name) return; // cancelled or empty createPreset.mutate( { data: { name, fields: effective, game_session: draft.game_session ?? "auto", }, }, { onSuccess: invalidateSettings }, ); }; const renamePreset = async (p: CustomPreset) => { const name = ( await promptText({ title: m.display_preset_edit(), label: m.display_preset_name(), defaultValue: p.name, }) )?.trim(); if (!name) return; updatePreset.mutate( { id: p.id, data: { name, fields: p.fields, game_session: p.game_session ?? "auto", }, }, { onSuccess: invalidateSettings }, ); }; const updatePresetToCurrent = (p: CustomPreset) => updatePreset.mutate( { id: p.id, data: { name: p.name, fields: effective, game_session: draft.game_session ?? "auto", }, }, { onSuccess: invalidateSettings }, ); const removePreset = async (p: CustomPreset) => { const ok = await confirm({ title: m.display_preset_delete_confirm(), confirmLabel: m.display_preset_delete(), destructive: true, }); if (!ok) return; deletePreset.mutate({ id: p.id }, { onSuccess: invalidateSettings }); }; const ka = customFields.keep_alive; // The duration value, remembered across the Off/Keep toggle so switching back restores it. const [keepSecs, setKeepSecs] = useState( ka.mode === "duration" ? ka.seconds : 300, ); return (
{/* One-click presets — a 2-up grid so each has room to breathe */}
{/* The preset tiles are cards nested INSIDE this page's config card, so their motion parent is that card — which sets no `delayChildren` and therefore landed all six on the same frame, unlike every other card grid in the console. `Stagger` gives the group its own cadence back (see components/stagger.tsx). */} {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; // The built-in "Custom" card is the hand-edit mode; when the active Custom policy // value-matches a saved preset, that preset's card owns the "current" ring instead. const selected = preset === id && !(id === "custom" && anyCustomSelected); const soon = DISABLED_PRESETS.has(id); const disabled = busy || soon; const pick = () => { if (!disabled) pickPreset(id); }; return ( { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pick(); } }} className={cn( "flex h-full flex-col p-4", disabled ? "cursor-not-allowed opacity-60" : "cursor-pointer", selected && "ring-2 ring-primary", )} >
{(PRESET_LABEL[id] ?? (() => id))()} {soon && ( {m.display_preset_soon()} )} {selected && ( {m.display_preset_current()} )}
{summary && (

{summary}

)} {fields && (
{fmtKeepAlive(fields.keep_alive)} {tr(TOPOLOGY_LABEL, fields.topology)} {tr(CONFLICT_LABEL, fields.mode_conflict)} {tr(IDENTITY_LABEL, fields.identity)}
)}
); })}
{/* Custom presets — the operator's saved field-bundles, rendered like the built-ins but editable/deletable, plus a "Save as preset" that captures the current effective behavior. */}
{customPresets.length > 0 && ( {customPresets.map((p) => ( applyCustomPreset(p)} onRename={() => renamePreset(p)} onUpdate={() => updatePresetToCurrent(p)} onDelete={() => removePreset(p)} /> ))} )} {presetError && (

{presetError}

)}
{/* Custom: every option by hand. Unlike everything else on this page these fields do NOT auto-apply, so the block is titled, ringed while dirty, and ends in a sticky save bar. */} {isCustom && (

{m.display_custom_title()}

{dirty && {m.display_unsaved()}}
{ka.mode === "duration" && (
{ setKeepSecs(n); setDraft({ ...draft, keep_alive: { mode: "duration", seconds: n }, }); }} /> {m.display_keep_alive_seconds()}
)}
setDraft({ ...draft, topology: v as Topology })} /> setDraft({ ...draft, mode_conflict: v as ModeConflict }) } /> setDraft({ ...draft, identity: v as Identity })} /> setDraft({ ...draft, layout: { mode: v as LayoutMode, positions: draft.layout?.positions ?? {}, }, }) } /> {/* 1..=16 is the host's own clamp on write. InputNumber holds the field to it and — unlike the arithmetic this replaces — lets it be EMPTY on the way to a new number instead of snapping to 1 the moment you clear it. */} setDraft({ ...draft, max_displays })} /> {/* Sticky: the Custom block is taller than most viewports, and a save button parked at its bottom edge is invisible until you scroll all the way down — people edited, navigated away, and lost the lot. `bottom-0` pins it to the viewport while any part of the block is on screen, and it settles into place at the end. */}
{dirty ? m.display_unsaved_hint() : m.display_all_saved()} {dirty && ( )}
)} {/* Game-session routing — orthogonal to the preset/lifecycle axes, so it lives outside the Custom block and applies immediately on change (like a preset click). */}
applyAxis({ game_session: v as GameSession })} />
{/* EXPERIMENTAL toggles — orthogonal like game-session (survive preset switches, apply immediately). Windows-only in effect, acted on at the Exclusive isolate. */} applyAxis({ ddc_power_off: on })} /> applyAxis({ pnp_disable_monitors: on })} /> {/* AMD hosts only: the axis is the driver's ADL connector-emulation lever, which exists nowhere else — a toggle NVIDIA/Intel operators could flip but that can never do anything would be the "saved and then did nothing" trap the enforced list exists to prevent. */} {amdHost && ( applyAxis({ edid_lock: on })} /> )} {/* What's in force right now — read from the API's `effective`, not from the local draft. Deriving it from the draft meant the row restated the operator's unsaved edits back to them as though the host had already adopted them. */}
{m.display_effective()}: {fmtKeepAlive(serverEffective.keep_alive)} {tr(TOPOLOGY_LABEL, serverEffective.topology)} {tr(CONFLICT_LABEL, serverEffective.mode_conflict)} {tr(IDENTITY_LABEL, serverEffective.identity)} {tr(LAYOUT_LABEL, serverEffective.layout.mode)} {`${serverEffective.max_displays}×`} {(draft.game_session ?? "auto") === "dedicated" && ( {m.display_game_session_dedicated()} )} {(draft.ddc_power_off ?? false) && ( {m.display_ddc_badge()} )} {(draft.pnp_disable_monitors ?? false) && ( {m.display_pnp_badge()} )} {(draft.edid_lock ?? false) && ( {m.display_edid_badge()} )}

{m.display_pending_note()}

{error && (

{error}

)}
); }; /** A labeled config field — label, then the control, then optional help. The single source of the * label→control→help spacing so every field (keep-alive, the button groups, max-displays) lines up. */ const Field: FC<{ label: string; help?: string; children: ReactNode; /** The id of the single control this labels, when there is one — see below. */ htmlFor?: string; /** Set when the field wraps a GROUP of controls rather than one input. */ group?: boolean; }> = ({ label, help, children, htmlFor, group }) => { const helpId = help && htmlFor ? `${htmlFor}-help` : undefined; const helpText = help && (

{help}

); // A set of related buttons IS a fieldset, so say so with the element rather than an ARIA role. // (The single-control case keeps a plain