diff --git a/app/desktop/src/hosts.test.ts b/app/desktop/src/hosts.test.ts index e310c26c0..3b2f1a291 100644 --- a/app/desktop/src/hosts.test.ts +++ b/app/desktop/src/hosts.test.ts @@ -14,11 +14,13 @@ import { findHostByOrigin, hostInfos, loadHosts, + moveHost, normalizeOrigin, removeHost, resolveActiveHost, saveHosts, setActiveHost, + setHostAccentColor, setHostLastPath, } from "./hosts"; @@ -362,3 +364,122 @@ test("loadHosts keeps a pre-remote file unchanged (schema still version 1)", () assert.equal(loaded.hosts.length, 1); assert.equal("remote" in loaded.hosts[0], false); }); + +test("loadHosts keeps a string accentColor and drops a wrong-typed one (schema still version 1)", () => { + const dir = tmpDataDir(); + const stored = { + version: 1, + activeId: "a", + hosts: [ + { id: "a", name: "bad", url: "http://h:1", accentColor: 42 }, + { id: "b", name: "good", url: "http://h:2", accentColor: "#8b7ff0" }, + { id: "c", name: "plain", url: "http://h:3" }, + ], + }; + writeFileSync(join(dir, "hosts.json"), JSON.stringify(stored), "utf8"); + const loaded = loadHosts(dir); + assert.equal(loaded.version, 1); + assert.equal(loaded.hosts.length, 3); + assert.equal("accentColor" in loaded.hosts[0], false); + assert.equal(loaded.hosts[1].accentColor, "#8b7ff0"); + assert.equal("accentColor" in loaded.hosts[2], false); +}); + +test("setHostAccentColor sets, overwrites, and round-trips through load", () => { + const dir = tmpDataDir(); + const a = addHost(dir, "a", "http://a:1"); + const b = addHost(dir, "b", "http://b:2"); + assert.equal(a.ok && b.ok, true); + if (!a.ok || !b.ok) return; + + setHostAccentColor(dir, a.host.id, "#8b7ff0"); + const next = setHostAccentColor(dir, a.host.id, "#4a4468"); + assert.equal(next.hosts[0].accentColor, "#4a4468"); + // Only the target entry is patched. + assert.equal("accentColor" in next.hosts[1], false); + assert.deepEqual(loadHosts(dir), next); +}); + +test("setHostAccentColor with an unknown id writes nothing", () => { + const dir = tmpDataDir(); + const a = addHost(dir, "a", "http://a:1"); + assert.equal(a.ok, true); + if (!a.ok) return; + + const before = readFileSync(join(dir, "hosts.json"), "utf8"); + const result = setHostAccentColor(dir, "nope", "#8b7ff0"); + assert.deepEqual(result, a.list); + assert.equal(readFileSync(join(dir, "hosts.json"), "utf8"), before); +}); + +test("setHostAccentColor with an unchanged value writes nothing", () => { + const dir = tmpDataDir(); + const a = addHost(dir, "a", "http://a:1"); + assert.equal(a.ok, true); + if (!a.ok) return; + + const first = setHostAccentColor(dir, a.host.id, "#8b7ff0"); + const before = readFileSync(join(dir, "hosts.json"), "utf8"); + const again = setHostAccentColor(dir, a.host.id, "#8b7ff0"); + assert.deepEqual(again, first); + assert.equal(readFileSync(join(dir, "hosts.json"), "utf8"), before); +}); + +test("moveHost reorders by id and leaves activeId untouched", () => { + const dir = tmpDataDir(); + const a = addHost(dir, "a", "http://a:1"); + assert.equal(a.ok, true); + if (!a.ok) return; + const b = addHost(dir, "b", "http://b:2"); + const c = addHost(dir, "c", "http://c:3"); + assert.equal(b.ok && c.ok, true); + if (!b.ok || !c.ok) return; + + const next = moveHost(dir, c.host.id, 0); + assert.deepEqual( + next.hosts.map((h) => h.id), + [c.host.id, a.host.id, b.host.id], + ); + assert.equal(next.activeId, c.host.id); // c was active (added last) + assert.deepEqual(loadHosts(dir), next); +}); + +test("moveHost clamps an out-of-range target index", () => { + const dir = tmpDataDir(); + const a = addHost(dir, "a", "http://a:1"); + const b = addHost(dir, "b", "http://b:2"); + const c = addHost(dir, "c", "http://c:3"); + assert.equal(a.ok && b.ok && c.ok, true); + if (!a.ok || !b.ok || !c.ok) return; + + const next = moveHost(dir, a.host.id, 5); + assert.deepEqual( + next.hosts.map((h) => h.id), + [b.host.id, c.host.id, a.host.id], + ); +}); + +test("moveHost with an unknown id or a same-index move writes nothing", () => { + const dir = tmpDataDir(); + const a = addHost(dir, "a", "http://a:1"); + const b = addHost(dir, "b", "http://b:2"); + assert.equal(a.ok && b.ok, true); + if (!a.ok || !b.ok) return; + + const before = readFileSync(join(dir, "hosts.json"), "utf8"); + assert.deepEqual(moveHost(dir, "nope", 0).hosts, b.list.hosts); + assert.equal(readFileSync(join(dir, "hosts.json"), "utf8"), before); + assert.deepEqual(moveHost(dir, a.host.id, 0).hosts, b.list.hosts); + assert.equal(readFileSync(join(dir, "hosts.json"), "utf8"), before); +}); + +test("hostInfos carries accentColor when the entry has one (and never fills waiting)", () => { + const colored = { id: "h1", name: "one", url: "http://one:1", accentColor: "#8b7ff0" }; + const plain = { id: "h2", name: "two", url: "http://two:2" }; + const infos = hostInfos({ version: 1, activeId: "h1", hosts: [colored, plain] }); + assert.deepEqual(infos, [ + { id: "h1", name: "one", url: "http://one:1", active: true, accentColor: "#8b7ff0" }, + { id: "h2", name: "two", url: "http://two:2", active: false }, + ]); + for (const info of infos) assert.equal("waiting" in info, false); +}); diff --git a/app/desktop/src/hosts.ts b/app/desktop/src/hosts.ts index 825c35b4f..8790422ba 100644 --- a/app/desktop/src/hosts.ts +++ b/app/desktop/src/hosts.ts @@ -28,6 +28,13 @@ export interface HostEntry { * state when the tunnel is down — acceptable degradation, no v2 bump. */ remote?: string; + /** + * The host's instance accent color (`#…` hex as reported by the SPA's + * `theme-color` meta via `did-change-theme-color`), persisted so the + * host-switcher's edge bar survives cold start. Additive optional field + * like `lastPath` — the schema stays version 1. + */ + accentColor?: string; } export interface HostList { @@ -71,9 +78,9 @@ export function normalizeOrigin(input: string): NormalizeResult { /** * Parse one stored entry. The required fields (id/name/url) must be strings — * anything else rejects the entry (and, via parseHostList, the file). The - * optional `lastPath` and `remote` are tolerant: absent → fine, string → - * kept, any other type → the field is dropped but the entry (and file) still - * loads. + * optional `lastPath`, `remote`, and `accentColor` are tolerant: absent → + * fine, string → kept, any other type → the field is dropped but the entry + * (and file) still loads. */ function parseHostEntry(value: unknown): HostEntry | null { if (typeof value !== "object" || value === null) return null; @@ -92,6 +99,9 @@ function parseHostEntry(value: unknown): HostEntry | null { if ("remote" in value && typeof value.remote === "string") { entry.remote = value.remote; } + if ("accentColor" in value && typeof value.accentColor === "string") { + entry.accentColor = value.accentColor; + } return entry; } @@ -201,6 +211,45 @@ export function setHostLastPath(dir: string, id: string, lastPath: string): Host return next; } +/** + * Record the host's instance accent color (captured from the view's + * `did-change-theme-color` reports in main.ts). Unknown id or an unchanged + * value is a no-op (nothing written) — capture fires on every theme-color + * report, so the fast path avoids rewriting an identical file. + */ +export function setHostAccentColor(dir: string, id: string, accentColor: string): HostList { + const list = loadHosts(dir); + const entry = list.hosts.find((h) => h.id === id); + if (!entry || entry.accentColor === accentColor) return list; + const next: HostList = { + ...list, + hosts: list.hosts.map((h) => (h.id === id ? { ...h, accentColor } : h)), + }; + saveHosts(dir, next); + return next; +} + +/** + * Move a host to `toIndex`, clamped to the list bounds. Order is + * user-meaningful — it IS the ⌥⌘1–9/⇧Ctrl+1–9 accelerator map — so this is + * the reorder seam behind `servers:reorder`. Unknown id or a move landing on + * the entry's current index is a no-op (nothing written); `activeId` and + * every other field are untouched — only array order changes. + */ +export function moveHost(dir: string, id: string, toIndex: number): HostList { + const list = loadHosts(dir); + const from = list.hosts.findIndex((h) => h.id === id); + if (from === -1) return list; + const to = Math.min(Math.max(toIndex, 0), list.hosts.length - 1); + if (to === from) return list; + const hosts = [...list.hosts]; + const [moved] = hosts.splice(from, 1); + hosts.splice(to, 0, moved); + const next: HostList = { ...list, hosts }; + saveHosts(dir, next); + return next; +} + /** * Resolve the host to load at startup / after a mutation: the active entry, * falling back to the first host when `activeId` dangles, `null` when the @@ -226,20 +275,29 @@ export interface HostInfo { name: string; url: string; active: boolean; + /** The entry's persisted instance accent color, when known (never + * null/empty — absent entries omit the field). */ + accentColor?: string; + /** Cached waiting-agent count from the view registry — NEVER filled here + * (this module is store-pure); the `servers:list` handler in main.ts + * joins it in. */ + waiting?: number; } /** * Read-only projection of the list for the `servers:list` IPC surface (the * channel name is the SPA-facing contract and keeps its server naming): every * entry plus an `active` flag derived via `resolveActiveHost`, so a dangling - * `activeId` marks the same first-host fallback that startup would load. + * `activeId` marks the same first-host fallback that startup would load. The + * optional `accentColor` rides along when the entry carries one. */ export function hostInfos(list: HostList): HostInfo[] { const activeId = resolveActiveHost(list)?.id ?? null; - return list.hosts.map(({ id, name, url }) => ({ + return list.hosts.map(({ id, name, url, accentColor }) => ({ id, name, url, active: id === activeId, + ...(accentColor !== undefined ? { accentColor } : {}), })); } diff --git a/app/desktop/src/main.ts b/app/desktop/src/main.ts index bb8d8c98b..68323ae0b 100644 --- a/app/desktop/src/main.ts +++ b/app/desktop/src/main.ts @@ -79,10 +79,12 @@ import { HostInfo, hostInfos, loadHosts, + moveHost, normalizeOrigin, removeHost, resolveActiveHost, setActiveHost, + setHostAccentColor, setHostLastPath, } from "./hosts"; import { @@ -394,6 +396,11 @@ function createHostView(hostId: string): WebContentsView { // the switch seam re-applies the incoming view's cached color instead). contents.on("did-change-theme-color", (_event, color) => { views = setViewThemeColor(views, hostId, color); + // Persist the accent per host entry so the host-switcher's edge bar + // survives cold start. A null report never clears the stored value; the + // dev sentinel view (__dev__) matches no entry — the membership guard + // silently covers it. Unchanged values short-circuit (no write). + if (color !== null) setHostAccentColor(userDataDir(), hostId, color); if (views.activeHostId === hostId) { applyOverlayColor(color ?? DEFAULT_STRIP_COLOR); } @@ -1117,6 +1124,14 @@ function parseAddPayload(value: unknown): { name: string; url: string } | null { return { name, url: value.url }; } +function parseReorderPayload(value: unknown): { id: string; toIndex: number } | null { + if (typeof value !== "object" || value === null) return null; + if (!("id" in value) || typeof value.id !== "string") return null; + if (!("toIndex" in value) || typeof value.toIndex !== "number") return null; + if (!Number.isInteger(value.toIndex) || value.toIndex < 0) return null; + return { id: value.id, toIndex: value.toIndex }; +} + function registerIpcHandlers(): void { ipcMain.handle( "welcome:test-host", @@ -1179,7 +1194,18 @@ function registerIpcHandlers(): void { // entries are hosts shell-side. ipcMain.handle("servers:list", (event): ServersListResult => { if (!isHostsSender(event)) return { ok: false, error: "Not allowed" }; - return { ok: true, servers: hostInfos(loadHosts(userDataDir())) }; + // Join the store projection with the view registry's cached badge counts: + // a host with a live view whose last `badge:set` report was > 0 carries + // `waiting` (the switcher menu's amber ● N); never-visited hosts (no + // view) and zero counts omit the field. The menu refetches on every + // open, so this open-time snapshot needs no subscription. + const servers = hostInfos(loadHosts(userDataDir())).map((info) => { + const view = getView(views, info.id); + return view !== null && view.badgeCount > 0 + ? { ...info, waiting: view.badgeCount } + : info; + }); + return { ok: true, servers }; }); ipcMain.handle("servers:switch", (event, id: unknown): IpcResult => { @@ -1198,6 +1224,21 @@ function registerIpcHandlers(): void { return openAddHost(); }); + // servers:reorder — move-by-id ({id, toIndex}); a full-array payload would + // trust renderer-supplied order, so only the immutable id + target index + // cross the bridge. List order IS the native menu's accelerator map, so a + // committed move rebuilds the menu to re-derive the ⌥⌘1–9/⇧Ctrl+1–9 + // bindings. An unknown id is the store's no-op convention (still ok — the + // rebuild is harmless), not an error. + ipcMain.handle("servers:reorder", (event, payload: unknown): IpcResult => { + if (!isHostsSender(event)) return { ok: false, error: "Not allowed" }; + const parsed = parseReorderPayload(payload); + if (!parsed) return { ok: false, error: "Invalid request" }; + moveHost(userDataDir(), parsed.id, parsed.toIndex); + rebuildMenu(); + return { ok: true }; + }); + // badge:* — the SPA's waiting-agent count report, gated exactly like // `servers:*` (registered host origins + welcome). Structurally validated: // only a non-negative integer reaches the OS badge surface. Counts are diff --git a/app/desktop/src/preload.ts b/app/desktop/src/preload.ts index 82d1b3d47..f6245b36a 100644 --- a/app/desktop/src/preload.ts +++ b/app/desktop/src/preload.ts @@ -5,8 +5,8 @@ * - `version` / `platform`: readable by EVERY page (including pages loaded * from registered rk servers) — this is the SPA's shell-detection seam * (`app/frontend/src/lib/shell.ts`). - * - `servers`: list/switch/add invokers for the SPA command palette and - * titlebar-strip host switcher. The group + * - `servers`: list/switch/add/reorder invokers for the SPA command + * palette and titlebar-strip host switcher. The group * name and its `servers:*` channels are the web SPA's contract and keep * their server naming (the entries are hosts — rk instances — shell-side). * Privileged for registered host origins AND the welcome page — main.ts @@ -48,6 +48,8 @@ contextBridge.exposeInMainWorld("runkitShell", { list: (): Promise => ipcRenderer.invoke("servers:list"), switch: (id: string): Promise => ipcRenderer.invoke("servers:switch", id), add: (): Promise => ipcRenderer.invoke("servers:add"), + reorder: (id: string, toIndex: number): Promise => + ipcRenderer.invoke("servers:reorder", { id, toIndex }), }, badge: { set: (count: number): Promise => ipcRenderer.invoke("badge:set", count), diff --git a/app/frontend/src/components/shell-titlebar-strip.test.tsx b/app/frontend/src/components/shell-titlebar-strip.test.tsx index b76f892aa..66689e221 100644 --- a/app/frontend/src/components/shell-titlebar-strip.test.tsx +++ b/app/frontend/src/components/shell-titlebar-strip.test.tsx @@ -45,9 +45,15 @@ function accentValue(overrides: Partial = {}): InstanceAccent { /** Install a bridge whose `servers.list` resolves the given list (`null` = * rejected call, i.e. an older shell / denial). `withAdd` includes the - * optional `add` invoker (newer shells — drives the `+ Add Host…` footer). - * Returns the list/switch/add spies for call-count and payload assertions. */ -function shellBridge(servers: unknown[] | null, platform = "darwin", withAdd = false) { + * optional `add` invoker (newer shells — drives the `+ Add Host…` footer); + * `withReorder` includes the optional `reorder` invoker (drives the drag + * grip + ⌥↑/⌥↓ move). Returns the spies for call-count/payload assertions. */ +function shellBridge( + servers: unknown[] | null, + platform = "darwin", + withAdd = false, + withReorder = false, +) { const list = vi.fn(() => servers === null ? Promise.reject(new Error("ipc gone")) @@ -55,12 +61,18 @@ function shellBridge(servers: unknown[] | null, platform = "darwin", withAdd = f ); const switchFn = vi.fn(() => Promise.resolve({ ok: true })); const add = vi.fn(() => Promise.resolve({ ok: true })); + const reorder = vi.fn(() => Promise.resolve({ ok: true })); window.runkitShell = { version: "1.2.3", platform, - servers: withAdd ? { list, switch: switchFn, add } : { list, switch: switchFn }, + servers: { + list, + switch: switchFn, + ...(withAdd ? { add } : {}), + ...(withReorder ? { reorder } : {}), + }, }; - return { list, switch: switchFn, add }; + return { list, switch: switchFn, add, reorder }; } function renderStrip(accent: InstanceAccent = accentValue()) { @@ -82,8 +94,13 @@ const hosts = [ /** Render with a populated bridge and wait for the mount fetch to enable the * switcher trigger. */ -async function renderInteractive(list: unknown[] = hosts, platform = "darwin", withAdd = false) { - const bridge = shellBridge(list, platform, withAdd); +async function renderInteractive( + list: unknown[] = hosts, + platform = "darwin", + withAdd = false, + withReorder = false, +) { + const bridge = shellBridge(list, platform, withAdd, withReorder); renderStrip(); await waitFor(() => { expect(screen.getByRole("button", { name: "Switch host" })).toBeInTheDocument(); @@ -447,3 +464,200 @@ describe("ShellTitlebarStrip host switcher (260731-4bqi)", () => { }); }); }); + +describe("ShellTitlebarStrip host menu — accent bars, waiting counts, reorder (1i7j)", () => { + const coloredHosts = [ + { id: "a", name: "studio-mac", url: "http://a:3000", active: true, accentColor: "#8b7ff0" }, + { id: "b", name: "lab", url: "http://b:3000", active: false }, + { id: "c", name: "buildbox", url: "http://c:3000", active: false, accentColor: "#4a4468" }, + ]; + + it("renders the accent edge bar for hosts with a color, none for colorless rows", async () => { + await renderInteractive(coloredHosts); + fireEvent.click(screen.getByRole("button", { name: "Switch host" })); + const rows = screen.getAllByRole("menuitemradio"); + const barA = rows[0].querySelector("[data-testid='shell-host-accent-bar']"); + expect(barA).not.toBeNull(); + expect((barA as HTMLElement).style.backgroundColor).toBe("rgb(139, 127, 240)"); + expect(rows[1].querySelector("[data-testid='shell-host-accent-bar']")).toBeNull(); + const barC = rows[2].querySelector("[data-testid='shell-host-accent-bar']"); + expect((barC as HTMLElement).style.backgroundColor).toBe("rgb(74, 68, 104)"); + // The bar overlays the left edge — the row content keeps its alignment. + expect(rows[1].textContent).toContain("lab"); + expect(rows[1].textContent).toContain("http://b:3000"); + }); + + it("renders no bar for a non-hex accentColor (never reaches style interpolation)", async () => { + await renderInteractive([ + { id: "a", name: "evil", url: "http://a:3000", active: true, accentColor: "javascript:alert(1)" }, + ]); + fireEvent.click(screen.getByRole("button", { name: "Switch host" })); + expect(screen.queryByTestId("shell-host-accent-bar")).not.toBeInTheDocument(); + }); + + it("renders the amber waiting chip on background rows only, before the hint", async () => { + await renderInteractive([ + { id: "a", name: "studio-mac", url: "http://a:3000", active: true, waiting: 2 }, + { id: "b", name: "lab", url: "http://b:3000", active: false, waiting: 3 }, + { id: "c", name: "quiet", url: "http://c:3000", active: false }, + ]); + fireEvent.click(screen.getByRole("button", { name: "Switch host" })); + const rows = screen.getAllByRole("menuitemradio"); + // Active row: the count is suppressed (the dock badge is its surface). + expect(rows[0].textContent).not.toContain("●"); + // Background row: amber ● N between the origin and the accelerator hint. + expect(rows[1].textContent).toContain("● 3"); + const chip = screen.getByText("● 3"); + expect(chip.className).toContain("text-amber-600"); + expect(rows[1].textContent?.indexOf("● 3")).toBeLessThan( + rows[1].textContent?.indexOf("⌥⌘2") ?? Infinity, + ); + // Absent count renders nothing extra. + expect(rows[2].textContent).not.toContain("●"); + }); + + it("⌥↑ moves the focused row with one invoke per press, live hint re-numbering, focus follows", async () => { + const four = [ + { id: "a", name: "alpha", url: "http://a:3000", active: true }, + { id: "b", name: "beta", url: "http://b:3000", active: false }, + { id: "c", name: "gamma", url: "http://c:3000", active: false }, + { id: "d", name: "delta", url: "http://d:3000", active: false }, + ]; + const bridge = await renderInteractive(four, "darwin", false, true); + fireEvent.click(screen.getByRole("button", { name: "Switch host" })); + let rows = screen.getAllByRole("menuitemradio"); + await waitFor(() => { + expect(document.activeElement).toBe(rows[0]); // active row seeded + }); + fireEvent.keyDown(document, { key: "ArrowDown" }); // focus beta (index 1) + expect(document.activeElement).toBe(rows[1]); + fireEvent.keyDown(document, { key: "ArrowUp", altKey: true }); + expect(bridge.reorder).toHaveBeenCalledTimes(1); + expect(bridge.reorder).toHaveBeenCalledWith("b", 0); + // Optimistic local reorder: beta first, hints re-numbered. + rows = screen.getAllByRole("menuitemradio"); + expect(rows[0].textContent).toContain("beta"); + expect(rows[0].textContent).toContain("⌥⌘1"); + expect(rows[1].textContent).toContain("alpha"); + expect(rows[1].textContent).toContain("⌥⌘2"); + // Focus stays on the moved row. + expect(document.activeElement).toBe(rows[0]); + expect(rows[0]).toHaveAttribute("tabindex", "0"); + }); + + it("⌥↑/⌥↓ at the list edges is a no-op (no invoke, no wrap, key swallowed)", async () => { + const bridge = await renderInteractive(hosts, "darwin", false, true); + fireEvent.click(screen.getByRole("button", { name: "Switch host" })); + const rows = screen.getAllByRole("menuitemradio"); + await waitFor(() => { + expect(document.activeElement).toBe(rows[0]); + }); + // First row, move up: swallowed, no invoke (fireEvent false = preventDefaulted). + expect(fireEvent.keyDown(document, { key: "ArrowUp", altKey: true })).toBe(false); + expect(bridge.reorder).not.toHaveBeenCalled(); + expect(screen.getAllByRole("menuitemradio")[0].textContent).toContain("studio-mac"); + // Last row, move down: same. + fireEvent.keyDown(document, { key: "ArrowDown" }); // focus lab (index 1) + expect(fireEvent.keyDown(document, { key: "ArrowDown", altKey: true })).toBe(false); + expect(bridge.reorder).not.toHaveBeenCalled(); + }); + + it("⌥↑/⌥↓ on the Add-Host footer falls through to the roving cycle (footer not movable)", async () => { + const bridge = await renderInteractive(hosts, "darwin", true, true); + fireEvent.click(screen.getByRole("button", { name: "Switch host" })); + const rows = screen.getAllByRole("menuitemradio"); + const footer = screen.getByRole("menuitem", { name: "Add Host…" }); + await waitFor(() => { + expect(document.activeElement).toBe(rows[0]); + }); + fireEvent.keyDown(document, { key: "ArrowUp" }); // wrap to footer + expect(document.activeElement).toBe(footer); + fireEvent.keyDown(document, { key: "ArrowDown", altKey: true }); // roves, never moves + expect(bridge.reorder).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(rows[0]); + }); + + it("without the reorder capability, ⌥↑/⌥↓ fall through to today's roving focus (no grips)", async () => { + await renderInteractive(hosts); // no reorder invoker + fireEvent.click(screen.getByRole("button", { name: "Switch host" })); + const rows = screen.getAllByRole("menuitemradio"); + await waitFor(() => { + expect(document.activeElement).toBe(rows[0]); + }); + expect(screen.queryByText("⋮⋮")).not.toBeInTheDocument(); + for (const row of rows) expect(row).toHaveAttribute("draggable", "false"); + fireEvent.keyDown(document, { key: "ArrowDown", altKey: true }); + expect(document.activeElement).toBe(rows[1]); // plain roving move + }); + + it("drag-drop commits exactly one reorder invocation with optimistic order", async () => { + const four = [ + { id: "a", name: "alpha", url: "http://a:3000", active: true }, + { id: "b", name: "beta", url: "http://b:3000", active: false }, + { id: "c", name: "gamma", url: "http://c:3000", active: false }, + { id: "d", name: "delta", url: "http://d:3000", active: false }, + ]; + const bridge = await renderInteractive(four, "darwin", false, true); + fireEvent.click(screen.getByRole("button", { name: "Switch host" })); + // Grips render (hover-revealed) and rows are draggable. + expect(screen.getAllByText("⋮⋮")).toHaveLength(4); + const rows = screen.getAllByRole("menuitemradio"); + for (const row of rows) expect(row).toHaveAttribute("draggable", "true"); + const dataTransfer = { + setData: vi.fn(), + types: ["application/x-shell-host-reorder"], + effectAllowed: "", + dropEffect: "", + }; + // Drag row 4 (delta) onto row 1 (alpha) — insert-before → index 0. + fireEvent.dragStart(rows[3], { dataTransfer }); + expect(dataTransfer.setData).toHaveBeenCalledWith("application/x-shell-host-reorder", "d"); + fireEvent.dragOver(rows[0], { dataTransfer }); + fireEvent.drop(rows[0], { dataTransfer }); + fireEvent.dragEnd(rows[3], { dataTransfer }); + expect(bridge.reorder).toHaveBeenCalledTimes(1); + expect(bridge.reorder).toHaveBeenCalledWith("d", 0); + // Optimistic order renders with re-numbered hints. + const reordered = screen.getAllByRole("menuitemradio"); + expect(reordered[0].textContent).toContain("delta"); + expect(reordered[0].textContent).toContain("⌥⌘1"); + expect(reordered[1].textContent).toContain("alpha"); + }); + + it("a denied drag-drop reorder surfaces the error toast and refetches the list", async () => { + const bridge = await renderInteractive(hosts, "darwin", false, true); + bridge.reorder.mockResolvedValue({ ok: false }); + fireEvent.click(screen.getByRole("button", { name: "Switch host" })); + expect(bridge.list).toHaveBeenCalledTimes(2); // mount + open refetch + const rows = screen.getAllByRole("menuitemradio"); + const dataTransfer = { + setData: vi.fn(), + types: ["application/x-shell-host-reorder"], + effectAllowed: "", + dropEffect: "", + }; + fireEvent.dragStart(rows[1], { dataTransfer }); + fireEvent.dragOver(rows[0], { dataTransfer }); + fireEvent.drop(rows[0], { dataTransfer }); + await waitFor(() => { + expect(screen.getByText("Shell host reorder failed")).toBeInTheDocument(); + }); + expect(bridge.list).toHaveBeenCalledTimes(3); // failure refetch reconciles + }); + + it("older-shell degradation: a plain 4-field projection renders today's menu with no affordances", async () => { + // list/switch only (no add, no reorder), entries without accentColor/waiting. + await renderInteractive(hosts); + fireEvent.click(screen.getByRole("button", { name: "Switch host" })); + const rows = screen.getAllByRole("menuitemradio"); + expect(rows).toHaveLength(2); + expect(rows[0].textContent).toContain("✓"); + expect(rows[0].textContent).toContain("⌥⌘1"); + expect(rows[1].textContent).toContain("⌥⌘2"); + expect(screen.queryByTestId("shell-host-accent-bar")).not.toBeInTheDocument(); + expect(screen.queryByText("⋮⋮")).not.toBeInTheDocument(); + expect(screen.queryByText(/●/)).not.toBeInTheDocument(); + for (const row of rows) expect(row).toHaveAttribute("draggable", "false"); + expect(screen.queryByRole("menuitem", { name: "Add Host…" })).not.toBeInTheDocument(); + }); +}); diff --git a/app/frontend/src/components/shell-titlebar-strip.tsx b/app/frontend/src/components/shell-titlebar-strip.tsx index dc055f17e..66913e2d6 100644 --- a/app/frontend/src/components/shell-titlebar-strip.tsx +++ b/app/frontend/src/components/shell-titlebar-strip.tsx @@ -3,7 +3,15 @@ import { useInstanceAccent } from "@/contexts/instance-accent-context"; import { useTheme } from "@/contexts/theme-context"; import { Tip } from "@/components/tip"; import { useToast } from "@/components/toast"; -import { addShellHost, canAddShellHost, listShellServers, shellInfo, switchShellServer } from "@/lib/shell"; +import { + addShellHost, + canAddShellHost, + canReorderShellHosts, + listShellServers, + reorderShellHosts, + shellInfo, + switchShellServer, +} from "@/lib/shell"; import type { ShellServer } from "@/lib/shell"; import { activeShellHostName, @@ -14,6 +22,7 @@ import { stripLabelColor, stripSwitcherEnabled, } from "@/lib/shell-strip"; +import type { ShellHostMenuRow } from "@/lib/shell-strip"; /** * Desktop-shell titlebar strip (260731-ofws): a 28px full-width draggable @@ -50,7 +59,23 @@ import { * in add mode (`servers:add` → the same main-side path as the native * `Hosts → Add Host…` menu item). Older shells without the invoker render * the menu without the footer. + * + * Three additive, independently capability-gated row features (1i7j): a + * ~3px left-edge bar in the host's persisted accent color (hex-validated in + * the row model), an amber `● N` waiting-agent count on BACKGROUND rows + * (the active host's attention surface is the dock badge), and manual + * reorder — a hover drag grip (commit-on-drop) plus ⌥↑/⌥↓ while the menu is + * open (one move per keypress). Order IS the ⌥⌘1–9/⇧Ctrl+1–9 accelerator + * map, so reordering re-numbers the hints live; the shell rebuilds its + * native menu on each committed move. All three ride the optional + * `reorder` invoker / additive projection fields — an older shell renders + * exactly the plain marker/name/origin/hint rows. */ + +/** Custom MIME so a host-reorder drag never collides with the other + * drag-reorder payloads (server/session/board-list). */ +const HOST_REORDER_MIME = "application/x-shell-host-reorder"; + export function ShellTitlebarStrip() { const { titlebarHex } = useInstanceAccent(); const { theme } = useTheme(); @@ -108,6 +133,9 @@ export function ShellTitlebarStrip() { // The `+ Add Host…` footer rides the optional `servers.add` invoker — older // shells expose only list/switch and render the menu without it. const canAdd = canAddShellHost(); + // The reorder affordances (drag grip, ⌥↑/⌥↓) ride the optional + // `servers.reorder` invoker — an older shell renders plain rows. + const canReorder = canReorderShellHosts(); // Latest COMMITTED host-row count, read live inside the capture-phase // keydown handler: that handler stays attached from a commit until the @@ -121,6 +149,104 @@ export function ShellTitlebarStrip() { hostCountRef.current = rows.length; }, [rows.length]); + // Live copy of the derived rows for the reorder gestures (the keydown + // handler's subscription can lag a reorder commit the same way it lags a + // count change — the ref always reads the committed order). + const rowsRef = useRef([]); + useLayoutEffect(() => { + rowsRef.current = rows; + }); + + // One committed shell invocation per gesture; a denial/failure surfaces + // the toast precedent and refetches so the list reconciles with the store. + const commitReorder = useCallback( + (id: string, toIndex: number) => { + void reorderShellHosts(id, toIndex).then((ok) => { + if (!ok) { + addToast("Shell host reorder failed", "error"); + fetchServers(); + } + }); + }, + [addToast, fetchServers], + ); + + // Shared move commit (⌥↑/⌥↓ per keypress): the local list reorders + // OPTIMISTICALLY so the accelerator hints re-number immediately, and the + // roving-tabindex seat follows the moved row (DOM focus follows on its + // own — the row's keyed element moves with it). + const moveHostRow = useCallback( + (from: number, to: number) => { + const row = rowsRef.current[from]; + if (!row) return; + setServers((prev) => { + if (!prev) return prev; + const next = [...prev]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + return next; + }); + setFocusedIndex(to); + commitReorder(row.id, to); + }, + [commitReorder], + ); + + // Drag-grip reorder (the board-tile/session-reorder precedent): the local + // order reorders optimistically during the drag (presentation state — the + // open-time refetch reconciles), and exactly ONE reorder invocation + // commits at drop, keyed on the immutable host id. + const dragIdRef = useRef(null); + + const onRowDragStart = useCallback((e: React.DragEvent, id: string) => { + dragIdRef.current = id; + e.dataTransfer.setData(HOST_REORDER_MIME, id); + e.dataTransfer.effectAllowed = "move"; + }, []); + + const onRowDragOver = useCallback((e: React.DragEvent, targetId: string) => { + const dragId = dragIdRef.current; + if (!dragId || !e.dataTransfer.types.includes(HOST_REORDER_MIME)) return; + // Accept the drop BEFORE the self-target bail: the final dragover lands + // on the dragged row's own element, and only a preventDefaulted dragover + // registers the release as a drop. + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + if (dragId === targetId) return; + // Resolve indexes by id INSIDE the functional updater: React can batch + // several dragover updates before a commit refreshes `rowsRef`, so + // ref-derived indexes could be stale relative to the `prev` being spliced. + setServers((prev) => { + if (!prev) return prev; + const from = prev.findIndex((s) => s.id === dragId); + const to = prev.findIndex((s) => s.id === targetId); + if (from === -1 || to === -1 || from === to) return prev; + const next = [...prev]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + return next; + }); + }, []); + + const onRowDrop = useCallback( + (e: React.DragEvent) => { + const dragId = dragIdRef.current; + if (!dragId || !e.dataTransfer.types.includes(HOST_REORDER_MIME)) return; + e.preventDefault(); + dragIdRef.current = null; + // The optimistic dragover splices already landed the row at its drop + // position — commit exactly that index. + const to = rowsRef.current.findIndex((r) => r.id === dragId); + if (to === -1) return; + commitReorder(dragId, to); + }, + [commitReorder], + ); + + const onRowDragEnd = useCallback(() => { + dragIdRef.current = null; + }, []); + // An open-time refetch can EMPTY the list: the trigger and menu unmount // (interactive flips false) while `open` would otherwise stay true, leaving // the capture-phase key handling subscribed with nothing visible. Release @@ -142,8 +268,9 @@ export function ShellTitlebarStrip() { }, [open]); // Escape closes + refocuses the trigger; ArrowDown/ArrowUp move focus with - // wraparound (capture-phase, mirroring BreadcrumbDropdown). Enter needs no - // handler — the focused row is a native ))} {canAdd && ( diff --git a/app/frontend/src/lib/shell-strip.test.ts b/app/frontend/src/lib/shell-strip.test.ts index 905cc88aa..f8c04b243 100644 --- a/app/frontend/src/lib/shell-strip.test.ts +++ b/app/frontend/src/lib/shell-strip.test.ts @@ -85,8 +85,8 @@ describe("shellHostMenuRows", () => { "darwin", ); expect(rows).toEqual([ - { id: "a", name: "studio-mac", origin: "http://a:3000", active: true, hint: "⌥⌘1" }, - { id: "b", name: "gcp-box", origin: "http://b:3000", active: false, hint: "⌥⌘2" }, + { id: "a", name: "studio-mac", origin: "http://a:3000", active: true, hint: "⌥⌘1", accentColor: null, waiting: null }, + { id: "b", name: "gcp-box", origin: "http://b:3000", active: false, hint: "⌥⌘2", accentColor: null, waiting: null }, ]); }); @@ -126,3 +126,39 @@ describe("stripSwitcherEnabled", () => { expect(stripSwitcherEnabled([a])).toBe(true); }); }); + +describe("shellHostMenuRows accentColor / waiting", () => { + const base = { id: "a", name: "studio-mac", url: "http://a:3000", active: false }; + + it("passes a valid hex accentColor through (#RGB / #RRGGBB / #RRGGBBAA)", () => { + for (const hex of ["#fff", "#8b7ff0", "#8b7ff0cc", "#ABC"]) { + const [row] = shellHostMenuRows([{ ...base, accentColor: hex }], "darwin"); + expect(row.accentColor).toBe(hex); + } + }); + + it("nulls a non-hex accentColor (never reaches style interpolation)", () => { + for (const bad of ["javascript:alert(1)", "red", "#12345", "8b7ff0", "#gggggg"]) { + const [row] = shellHostMenuRows([{ ...base, accentColor: bad }], "darwin"); + expect(row.accentColor).toBeNull(); + } + }); + + it("nulls accentColor when the field is absent (older shell)", () => { + const [row] = shellHostMenuRows([base], "darwin"); + expect(row.accentColor).toBeNull(); + expect(row.waiting).toBeNull(); + }); + + it("carries a positive waiting count on a background row", () => { + const [row] = shellHostMenuRows([{ ...base, waiting: 3 }], "darwin"); + expect(row.waiting).toBe(3); + }); + + it("suppresses waiting on the active row and on zero/absent counts", () => { + const [active] = shellHostMenuRows([{ ...base, active: true, waiting: 2 }], "darwin"); + expect(active.waiting).toBeNull(); + const [zero] = shellHostMenuRows([{ ...base, waiting: 0 }], "darwin"); + expect(zero.waiting).toBeNull(); + }); +}); diff --git a/app/frontend/src/lib/shell-strip.ts b/app/frontend/src/lib/shell-strip.ts index 8e59aeb76..63bd1fb99 100644 --- a/app/frontend/src/lib/shell-strip.ts +++ b/app/frontend/src/lib/shell-strip.ts @@ -82,6 +82,12 @@ export interface ShellHostMenuRow { active: boolean; /** Accelerator hint mirroring the native Hosts menu, or null past the cap. */ hint: string | null; + /** The host's instance accent color (left-edge bar), hex-validated — null + * when absent or not a strict hex value (never interpolated unvalidated). */ + accentColor: string | null; + /** Cached waiting-agent count (amber ● N), or null — background rows only + * (the active host's attention surface is the dock badge). */ + waiting: number | null; } /** The entry's origin for display: the store persists origins already, so @@ -94,10 +100,17 @@ function hostOrigin(url: string): string { } } +/** Strict hex gate before style interpolation — the shell-side + * `fallbackStripCss` precedent: `#RGB` / `#RRGGBB` / `#RRGGBBAA` only. */ +const HOST_ACCENT_HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + /** * Menu rows for the host-switcher dropdown, derived from the bridge's - * `servers:list` projection (`{id, name, url, active}` — no bridge change - * needed). List order is preserved (it is the native menu's binding order). + * `servers:list` projection. List order is preserved (it is the native + * menu's binding order). The additive `accentColor` passes through only when + * it hex-validates; `waiting` rides along only when positive and the row is + * NOT active (background hosts only). Both are null on older shells, whose + * projection omits the fields. */ export function shellHostMenuRows(servers: ShellServer[], platform: string): ShellHostMenuRow[] { return servers.map((s, i) => ({ @@ -106,6 +119,10 @@ export function shellHostMenuRows(servers: ShellServer[], platform: string): She origin: hostOrigin(s.url), active: s.active, hint: hostAcceleratorHint(platform, i), + accentColor: s.accentColor !== undefined && HOST_ACCENT_HEX.test(s.accentColor) + ? s.accentColor + : null, + waiting: !s.active && s.waiting !== undefined && s.waiting > 0 ? s.waiting : null, })); } diff --git a/app/frontend/src/lib/shell.test.ts b/app/frontend/src/lib/shell.test.ts index 2fbbbe577..0a1a4e311 100644 --- a/app/frontend/src/lib/shell.test.ts +++ b/app/frontend/src/lib/shell.test.ts @@ -2,8 +2,10 @@ import { describe, it, expect, afterEach } from "vitest"; import { addShellHost, canAddShellHost, + canReorderShellHosts, isShell, listShellServers, + reorderShellHosts, setShellBadge, shellInfo, switchShellServer, @@ -255,3 +257,101 @@ describe("setShellBadge", () => { expect(await setShellBadge(1)).toBe(false); }); }); + +// accentColor / waiting are ADDITIVE optionals on the servers:list entries +// (cross-version shells omit them): absence always parses; a wrong-typed +// present field rejects the list. + +describe("listShellServers optional fields", () => { + it("parses a newer shell's accentColor/waiting and an older shell's 4-field entries", async () => { + bridgeWith({ + list: () => + Promise.resolve({ + ok: true, + servers: [ + { ...serverA, accentColor: "#8b7ff0", waiting: 3 }, + serverB, // older-shell shape: both optionals absent + ], + }), + switch: () => Promise.resolve({ ok: true }), + }); + expect(await listShellServers()).toEqual([ + { ...serverA, accentColor: "#8b7ff0", waiting: 3 }, + serverB, + ]); + }); + + it("resolves null when a present optional is wrong-typed", async () => { + bridgeWith({ + list: () => + Promise.resolve({ ok: true, servers: [{ ...serverA, accentColor: 42 }] }), + switch: () => Promise.resolve({ ok: true }), + }); + expect(await listShellServers()).toBeNull(); + bridgeWith({ + list: () => + Promise.resolve({ ok: true, servers: [{ ...serverA, waiting: "3" }] }), + switch: () => Promise.resolve({ ok: true }), + }); + expect(await listShellServers()).toBeNull(); + }); +}); + +// The reorder invoker is ADDITIVE to the servers group (older shells expose +// only list/switch/add): canReorderShellHosts gates the strip's reorder +// affordances on its presence, and reorderShellHosts degrades exactly like +// its siblings — false for plain browser, pre-reorder shells, a non-function +// member, denial, and rejected invokes. + +describe("canReorderShellHosts / reorderShellHosts", () => { + it("resolves true on an { ok: true } ack when the group carries reorder", async () => { + let seen: { id: string; toIndex: number } | null = null; + bridgeWith({ + list: () => Promise.resolve({ ok: true, servers: [] }), + switch: () => Promise.resolve({ ok: true }), + reorder: (id: string, toIndex: number) => { + seen = { id, toIndex }; + return Promise.resolve({ ok: true }); + }, + }); + expect(canReorderShellHosts()).toBe(true); + expect(await reorderShellHosts("b", 0)).toBe(true); + expect(seen).toEqual({ id: "b", toIndex: 0 }); + }); + + it("reads as unavailable in a plain browser and on a shell without reorder (older shell)", async () => { + expect(canReorderShellHosts()).toBe(false); + expect(await reorderShellHosts("a", 0)).toBe(false); + bridgeWith({ + list: () => Promise.resolve({ ok: true, servers: [serverA, serverB] }), + switch: () => Promise.resolve({ ok: true }), + }); + expect(canReorderShellHosts()).toBe(false); + expect(await reorderShellHosts("a", 0)).toBe(false); + }); + + it("reads as unavailable when reorder is not a function", async () => { + bridgeWith({ + list: () => Promise.resolve({ ok: true, servers: [] }), + switch: () => Promise.resolve({ ok: true }), + reorder: "servers:reorder", + }); + expect(canReorderShellHosts()).toBe(false); + expect(await reorderShellHosts("a", 0)).toBe(false); + }); + + it("resolves false on a denied result and on a rejected invoke", async () => { + bridgeWith({ + list: () => Promise.resolve({ ok: true, servers: [] }), + switch: () => Promise.resolve({ ok: true }), + reorder: () => Promise.resolve({ ok: false, error: "Not allowed" }), + }); + expect(await reorderShellHosts("a", 0)).toBe(false); + bridgeWith({ + list: () => Promise.resolve({ ok: true, servers: [] }), + switch: () => Promise.resolve({ ok: true }), + reorder: () => Promise.reject(new Error("ipc gone")), + }); + expect(await reorderShellHosts("a", 0)).toBe(false); + }); +}); diff --git a/app/frontend/src/lib/shell.ts b/app/frontend/src/lib/shell.ts index de3f257b9..a07843a7b 100644 --- a/app/frontend/src/lib/shell.ts +++ b/app/frontend/src/lib/shell.ts @@ -24,6 +24,12 @@ export interface ShellServer { name: string; url: string; active: boolean; + /** The host's persisted instance accent color (newer shells; absent on + * older shells and for never-visited hosts). */ + accentColor?: string; + /** The host's cached waiting-agent count (newer shells; absent when the + * host has no live view or a zero count). */ + waiting?: number; } /** The bridge's `servers` group — thin IPC invokers resolving unknown shapes. */ @@ -37,6 +43,11 @@ interface ShellServersAddBridge extends ShellServersBridge { add: () => Promise; } +/** A `servers` group that also carries the optional `reorder` invoker (newer shells). */ +interface ShellServersReorderBridge extends ShellServersBridge { + reorder: (id: string, toIndex: number) => Promise; +} + declare global { interface Window { /** Injected by the desktop shell's preload; absent in plain browsers. */ @@ -89,7 +100,12 @@ function isShellServer(value: unknown): value is ShellServer { typeof value.id === "string" && typeof value.name === "string" && typeof value.url === "string" && - typeof value.active === "boolean" + typeof value.active === "boolean" && + // Optional fields are strict-when-present: the response comes from our + // own shell, which omits fields rather than mistyping them — absence is + // always valid (older shells), a wrong-typed present field rejects. + (!("accentColor" in value) || typeof value.accentColor === "string") && + (!("waiting" in value) || typeof value.waiting === "number") ); } @@ -172,6 +188,44 @@ export async function addShellHost(): Promise { ); } +/** + * The `reorder` invoker is additive to the `servers` group (shells older + * than the host-switcher's drag/⌥↑⌥↓ reorder expose only list/switch/add), + * so it is narrowed separately from `isServersBridge` — the group stays + * usable without it. + */ +function isServersReorderBridge( + bridge: ShellServersBridge, +): bridge is ShellServersReorderBridge { + return "reorder" in bridge && typeof Reflect.get(bridge, "reorder") === "function"; +} + +/** True when the shell can reorder its host list (`servers.reorder` present). */ +export function canReorderShellHosts(): boolean { + const bridge = serversBridge(); + return bridge !== null && isServersReorderBridge(bridge); +} + +/** + * Move a host to `toIndex` in the shell's host list — the switcher menu's + * order IS the ⌥⌘1–9/⇧Ctrl+1–9 accelerator map, so the shell rebuilds its + * native menu on commit. Resolves `false` in a plain browser, on an older + * shell whose `servers` group lacks the `reorder` invoker, or when the shell + * rejects/denies the call. Never throws. + */ +export async function reorderShellHosts(id: string, toIndex: number): Promise { + const bridge = serversBridge(); + if (!bridge || !isServersReorderBridge(bridge)) return false; + let result: unknown; + try { + result = await bridge.reorder(id, toIndex); + } catch { + return false; + } + return ( + typeof result === "object" && result !== null && "ok" in result && result.ok === true + ); +} /** The bridge's `badge` group — thin IPC invoker resolving unknown shapes. */ interface ShellBadgeBridge { set: (count: number) => Promise; diff --git a/docs/memory/run-kit/desktop-shell.md b/docs/memory/run-kit/desktop-shell.md index 4501f8efd..4294fef3e 100644 --- a/docs/memory/run-kit/desktop-shell.md +++ b/docs/memory/run-kit/desktop-shell.md @@ -59,7 +59,7 @@ Package tests run via `node --test "dist/**/*.test.js"` after compile — the st "version": 1, "activeId": "b3f1…", "hosts": [ - { "id": "", "name": "studio-mac", "url": "http://100.101.2.3:3000", "lastPath": "/utils2/rk-dev?x=1" }, + { "id": "", "name": "studio-mac", "url": "http://100.101.2.3:3000", "lastPath": "/utils2/rk-dev?x=1", "accentColor": "#8b7ff0" }, { "id": "", "name": "buildbox", "url": "http://127.0.0.1:3100", "remote": "buildbox" } ] } @@ -69,14 +69,15 @@ Package tests run via `node --test "dist/**/*.test.js"` after compile — the st - **Origin normalization**: `url` is stored as `new URL(input).origin` — only `http:`/`https:` accepted; anything else (ftp:, file:, garbage) is a validation error and is never persisted. Path/query/case in the input are dropped by the origin reduction. - **`lastPath` is optional and additive**: the SPA-route remainder (`pathname + search`) last seen for that host, at schema **version 1** — the field carries no version bump because absence is a valid state. - **`remote` is optional and additive too**: the `rk remote` name of an SSH-only host (§ SSH Remote Hosts), also at schema **version 1**. `url` stays required and real for these entries — the stable local tunnel origin `http://127.0.0.1:` — so an older shell reads the entry as a plain URL host and shows the normal dead-host state when the tunnel is down. That acceptable degradation is why the field is additive rather than a v2 bump, which would empty the whole host list into welcome on an older shell. `addHost(dir, name, urlInput, remote?)` persists it only when the trimmed value is non-empty. +- **`accentColor` is optional and additive too**: the host's instance accent color — the `theme-color`-meta value its view reports — persisted so the host-switcher's edge bar survives cold start (§ Hidden Titlebar & Accent Strip). Same version-1 additive shape: absence is a valid state, and a host never visited has no color. - **Atomic write**: tmp-file-then-rename in the same directory (`hosts.json.tmp-` → `hosts.json`). -- **Corrupt → empty, with per-field tolerance on the optional fields**: a missing, unreadable, corrupt, or wrong-shape file loads as an empty list without throwing — the required shape is structurally validated (`version === 1`, `activeId` string-or-null, `hosts` an array, and `id`/`name`/`url` strings on every entry), and any violation there rejects the whole file. The two optional fields, `lastPath` and `remote`, are the tolerant ones: absent → the entry loads unchanged, a string → kept, any other type → the field is dropped and the entry (and file) still loads. Startup routes to welcome only on the empty-list outcome. +- **Corrupt → empty, with per-field tolerance on the optional fields**: a missing, unreadable, corrupt, or wrong-shape file loads as an empty list without throwing — the required shape is structurally validated (`version === 1`, `activeId` string-or-null, `hosts` an array, and `id`/`name`/`url` strings on every entry), and any violation there rejects the whole file. The three optional fields — `lastPath`, `remote`, and `accentColor` — are the tolerant ones: absent → the entry loads unchanged, a string → kept, any other type → the field is dropped and the entry (and file) still loads. Startup routes to welcome only on the empty-list outcome. - **Active resolution**: `resolveActiveHost` returns the `activeId` entry, falls back to the **first** host when `activeId` dangles, `null` when the list is empty. `addHost` sets the new entry active (empty display name defaults to the origin); removing the active host promotes the first remaining entry. - **Origin ownership**: `findHostByOrigin(list, origin)` answers "which entry owns this origin" — `addHost` never dedupes, so several entries can share one origin; the **active** entry wins among the matches, else the first match, else `null`. Its one caller is the local-connect dedupe (§ Local Daemon Control), which asks whether the just-probed local origin is already registered before adding a second entry for it. Last-path capture does not use this rule — it keys on the view's host id (§ Last-Path Capture & Restore) — so the active-wins tiebreak serves only that dedupe, which never has a meaningful tie to break. The tiebreak nuance is a recorded deletion candidate; the function itself stays. -- **Per-host mutators, all `id`-keyed**: `setActiveHost(dir, id)` and `setHostLastPath(dir, id, lastPath)` share one shape — load → membership guard (unknown `id` is a no-op that writes nothing) → patch → atomic `saveHosts`. `setHostLastPath` additionally short-circuits an *unchanged* value, because capture runs on every switch/add/close and the fast path should not rewrite an identical file. Per-host state (`lastPath`, the `activeId` linkage) keys on the immutable `id` and never on the display name — names are not unique, and `addHost` mints a fresh `randomUUID`, so anything id-keyed is scoped to exactly one registration. +- **Per-host mutators, all `id`-keyed**: `setActiveHost(dir, id)`, `setHostLastPath(dir, id, lastPath)`, and `setHostAccentColor(dir, id, accentColor)` share one shape — load → membership guard (unknown `id` is a no-op that writes nothing) → patch → atomic `saveHosts`. `setHostLastPath` and `setHostAccentColor` additionally short-circuit an *unchanged* value, because capture runs on every switch/add/close (last path) or every theme-color report (accent), and the fast path should not rewrite an identical file. `moveHost(dir, id, toIndex)` is the array-move variant behind `servers:reorder`: remove the entry and re-insert at `toIndex` clamped to `[0, hosts.length - 1]`, with an unknown id or a same-index move a no-op that writes nothing; `activeId` and every other field are untouched — only array order changes, and order is user-meaningful (it IS the ⌥⌘1–9/⇧Ctrl+1–9 accelerator map, § Keyboard-Tier Menu Seam). Per-host state (`lastPath`, `accentColor`, the `activeId` linkage) keys on the immutable `id` and never on the display name — names are not unique, and `addHost` mints a fresh `randomUUID`, so anything id-keyed is scoped to exactly one registration. - **Display names are set once, at add-time**: the persisted `name` is whatever the caller passes (the health ping's `hostname`, in every real flow), with the origin as the blank-input fallback. The store exposes no name mutator — changing a display name means removing the entry and re-adding it (§ Design Decisions → Names auto-derive at add-time; there is no rename affordance). - **Electron-free**: the data directory is a parameter (`main.ts` passes `app.getPath('userData')`), keeping the module unit-testable under plain `node --test`. -- **IPC projection**: `hostInfos(list)` is the read-only projection to the `{ id, name, url, active }[]` shape the `servers:list` channel returns (§ Bridge — that channel keeps its server naming as the frozen SPA contract). `active` is derived via `resolveActiveHost`, so a dangling `activeId` flags the **first** host — the same fallback startup would load — and an empty list projects to `[]`. +- **IPC projection**: `hostInfos(list)` is the read-only projection to the `{ id, name, url, active, accentColor? }[]` shape the `servers:list` channel returns (§ Bridge — that channel keeps its server naming as the frozen SPA contract). `active` is derived via `resolveActiveHost`, so a dangling `activeId` flags the **first** host — the same fallback startup would load — and an empty list projects to `[]`. The optional `accentColor` rides along when the entry carries one (absent entries omit the field — never `null`/empty-string). The `HostInfo` wire type also declares an optional `waiting?: number`, which `hostInfos` NEVER fills — this module is store-pure, and the view-registry badge-count join happens in the `servers:list` handler in `main.ts` (§ Bridge). ## Startup Routing & Welcome Flow @@ -196,7 +197,7 @@ Call sites are exactly the two moments a route would otherwise be lost: the main The window has **no native titlebar**: the page's top edge is the visible titlebar. `titleBarStyle` is `"hiddenInset"` on darwin (traffic lights composite over the page-drawn band) and `"hidden"` elsewhere plus a `titleBarOverlay: { color, symbolColor, height: STRIP_HEIGHT_PX }` so the native `─ ▢ ✕` controls draw over the band's right end. `STRIP_HEIGHT_PX = 28` is one value shared by the overlay height and the SPA's strip, so the controls composite over the strip exactly. The overlay always renders **above** an attached host view, which is why views can take the full content bounds. This is a *strip*, not a titlebar merge — the SPA's top bar is untouched below it, and the merge stays possible on the same foundation. -**The SPA draws the strip; the shell provides no color API.** The band is a 28px accent-tinted band the SPA renders above its top bar, gated on `isShell()` (see [ui-patterns](/run-kit/ui-patterns.md) § Desktop-Shell Titlebar Strip). Because the strip owns the titlebar band reservation, the SPA's top bar drops its `pt-[env(safe-area-inset-top)]` safe-area guard in-shell (`isShell()`-gated): macOS hidden-titlebar windows can report the band as `safe-area-inset-top`, and stacking that padding on the strip would reserve the band twice ([ui-patterns](/run-kit/ui-patterns.md) § Safe-Area Insets; `260805-9hn1`). The band is a drag surface with **exactly one** no-drag island: its centered host-name label is the SPA's host-switcher dropdown trigger, which hands off to `switchToHost` through the existing `servers.switch` channel — a renderer-side feature that needs no shell change (§ Design Decisions → The titlebar is colored by a page-drawn strip). The shell's only color seam is the page's **existing `theme-color` meta**: each view's `did-change-theme-color` records the observed color **into that view's registry cache** and repaints the overlay only when that view is the attached one — a background host re-accenting must not tint the window. `applyOverlayColor(color)` is the one painting helper: non-darwin calls `win.setTitleBarOverlay({ color, symbolColor: symbolColorFor(color), height })`; macOS returns early (traffic lights are OS-drawn and take no color); a throwing call degrades silently, since Linux window-controls-overlay support is partial. Because that meta is already accent-aware end-to-end — including the pre-paint localStorage echo in `index.html` — the native controls tint with the instance accent without a single new IPC channel. `symbolColorFor` is a pure WCAG-luminance pick between the two standard text hexes (`#e5e7eb` light / `#111827` dark; unparseable input reads as dark). +**The SPA draws the strip; the shell provides no color API.** The band is a 28px accent-tinted band the SPA renders above its top bar, gated on `isShell()` (see [ui-patterns](/run-kit/ui-patterns.md) § Desktop-Shell Titlebar Strip). Because the strip owns the titlebar band reservation, the SPA's top bar drops its `pt-[env(safe-area-inset-top)]` safe-area guard in-shell (`isShell()`-gated): macOS hidden-titlebar windows can report the band as `safe-area-inset-top`, and stacking that padding on the strip would reserve the band twice ([ui-patterns](/run-kit/ui-patterns.md) § Safe-Area Insets; `260805-9hn1`). The band is a drag surface with **exactly one** no-drag island: its centered host-name label is the SPA's host-switcher dropdown trigger, which hands off to `switchToHost` through the existing `servers.switch` channel — a renderer-side feature that needs no shell change (§ Design Decisions → The titlebar is colored by a page-drawn strip). The shell's only color seam is the page's **existing `theme-color` meta**: each view's `did-change-theme-color` records the observed color **into that view's registry cache**, persists it to the host entry's `accentColor` via `setHostAccentColor` (non-null reports only — a `null` report leaves the stored value untouched, and the dev sentinel view matches no entry, so the mutator's membership guard silently covers it), and repaints the overlay only when that view is the attached one — a background host re-accenting must not tint the window. Persistence is what makes the host-switcher's per-host edge bars correct at cold start and for hosts visited in a previous run but not yet this one (§ Design Decisions → Accent color persists in hosts.json). `applyOverlayColor(color)` is the one painting helper: non-darwin calls `win.setTitleBarOverlay({ color, symbolColor: symbolColorFor(color), height })`; macOS returns early (traffic lights are OS-drawn and take no color); a throwing call degrades silently, since Linux window-controls-overlay support is partial. Because that meta is already accent-aware end-to-end — including the pre-paint localStorage echo in `index.html` — the native controls tint with the instance accent without a single new IPC channel. `symbolColorFor` is a pure WCAG-luminance pick between the two standard text hexes (`#e5e7eb` light / `#111827` dark; unparseable input reads as dark). **A switch repaints the overlay from the incoming view's cache**, so the accent flips with the host at switch speed rather than waiting for the new page to re-report — a view that never reported one (and the welcome page) paints `DEFAULT_STRIP_COLOR`. @@ -307,7 +308,7 @@ The sandboxed preload exposes exactly one bridge via `contextBridge.exposeInMain - **`version`** — the shell app version, read from the `--runkit-shell-version=` argv entry (passed via `webPreferences.additionalArguments`, since sandboxed preloads read `process.argv` but cannot call `app.getVersion()`). - **`platform`** — `process.platform`. -- **`servers`** — `{ list(), switch(id) }`, thin invokers for the `servers:list` / `servers:switch` channels; the SPA command palette's switch path. **This group name, its two channel names, and the `servers` key inside the `servers:list` success envelope are a frozen contract** — they keep their server naming even though the entries they carry are hosts (§ Design Decisions → The SPA bridge boundary keeps its server naming). +- **`servers`** — `{ list(), switch(id), add(), reorder(id, toIndex) }`, thin invokers for the `servers:*` channels; the SPA command palette's switch path and the titlebar-strip host switcher (its `+ Add Host…` footer and drag/⌥↑⌥↓ reorder ride the `add`/`reorder` invokers). **This group name, the channel names, and the `servers` key inside the `servers:list` success envelope are a frozen contract** — they keep their server naming even though the entries they carry are hosts (§ Design Decisions → The SPA bridge boundary keeps its server naming). - **`badge`** — `{ set(count) }`, a thin invoker for the `badge:set` channel; the SPA's waiting-agent-count report driving the dock/taskbar badge (§ Dock/Taskbar Waiting Badge). - **`__welcome`** — `{ testHost(url), addHost(name, url), cancel() }`, thin `ipcRenderer.invoke` wrappers for the `welcome:*` channels. - **`__daemon`** — `{ status(), start(), stop() }`, thin wrappers for the three `daemon:*` channels behind the welcome page's "This Mac" section. All three are argument-less: every parameter the flows need (the origin, the name, the dedupe target) is derived main-side, so the renderer hands over no payload at all. @@ -323,9 +324,9 @@ The sandboxed preload exposes exactly one bridge via `contextBridge.exposeInMain | `servers:*` | registered host origins (the pages that serve the SPA palette) **plus** the welcome page | `isHostsSender` — delegates to `isAllowedNavigation`, the same set the navigation guard computes (so it also covers the `RK_DESKTOP_URL` dev origin) | | `badge:set` | registered host origins **plus** the welcome page | `isHostsSender` — the same gate as `servers:*`, reusing the one allowlist rather than computing a second | -Any sender outside a channel's allowlist gets `{ ok: false, error: "Not allowed" }` and no state change — so a host-loaded page can read shell metadata, switch hosts, and set the badge, but never invoke a `welcome:*` call, and **never reach a `daemon:*` channel** (a subprocess-spawning surface: the gate is what keeps a page loaded from a registered host origin from starting or stopping the daemon). `servers:list` answers the discriminated `{ ok: true, servers: HostInfo[] } | { ok: false, error }` envelope — the `servers` key is part of the frozen contract, the payload type is host-named; `servers:switch` rejects a non-string payload as `"Invalid request"` and an unregistered id as `"Unknown host"` without navigating (error text is not narrowed by the SPA). `badge:set` takes a non-negative **integer** and answers the bare `IpcResult` ack: a float, a negative, or a non-number is rejected `"Invalid request"` and never reaches the OS badge surface. `daemon:status` answers `{ ok: true, status: DaemonStatus } | { ok: false, error }`; `daemon:start` / `daemon:stop` answer the same bare `IpcResult` ack shape the `welcome:*` mutators use. +Any sender outside a channel's allowlist gets `{ ok: false, error: "Not allowed" }` and no state change — so a host-loaded page can read shell metadata, switch hosts, and set the badge, but never invoke a `welcome:*` call, and **never reach a `daemon:*` channel** (a subprocess-spawning surface: the gate is what keeps a page loaded from a registered host origin from starting or stopping the daemon). `servers:list` answers the discriminated `{ ok: true, servers: HostInfo[] } | { ok: false, error }` envelope — the `servers` key is part of the frozen contract, the payload type is host-named — and its handler **joins the store projection with the view registry's cached badge counts**: a host with a live view whose last `badge:set` report was > 0 carries `waiting` (the switcher menu's amber ● N); never-visited hosts (no view) and zero counts omit the field. The menu refetches on every open, so this open-time snapshot needs no subscription. `servers:switch` rejects a non-string payload as `"Invalid request"` and an unregistered id as `"Unknown host"` without navigating (error text is not narrowed by the SPA). `servers:reorder` takes a structurally validated `{ id, toIndex }` payload (string id, non-negative integer index — anything else is `"Invalid request"`), moves the entry via `moveHost`, then calls `rebuildMenu()` because the native Hosts-menu ⌥⌘1–9/⇧Ctrl+1–9 accelerators derive from list order; an unknown id is still `{ ok: true }` — the store's no-op convention, the rebuild harmless — not an error (§ Design Decisions → Reorder is move-by-id). `badge:set` takes a non-negative **integer** and answers the bare `IpcResult` ack: a float, a negative, or a non-number is rejected `"Invalid request"` and never reaches the OS badge surface. `daemon:status` answers `{ ok: true, status: DaemonStatus } | { ok: false, error }`; `daemon:start` / `daemon:stop` answer the same bare `IpcResult` ack shape the `welcome:*` mutators use. -**SPA side** (`app/frontend/src/lib/shell.ts`, the only SPA file the shell touches): `RunkitShell` interface (`{ version, platform }`), a `declare global` Window typing that types `runkitShell` as `unknown` (the bridge is runtime-injected, so it is validated structurally — type-narrowing guards, no `as` casts), `shellInfo()` returning a plain `{ version, platform }` (never leaking `__welcome`) or `null`, `isShell()`, the `servers`-group wrappers `listShellServers(): Promise` / `switchShellServer(id): Promise`, and the `badge`-group wrapper `setShellBadge(count): Promise`. Every wrapper **never throws**: a plain browser, an older shell lacking the group, a malformed entry, an `{ ok: false }` denial, and a rejected invoke all resolve `null`/`false`. Covered by the sibling vitest suite `shell.test.ts` (present / absent / malformed shapes of each surface). +**SPA side** (`app/frontend/src/lib/shell.ts`, the only SPA file the shell touches): `RunkitShell` interface (`{ version, platform }`), a `declare global` Window typing that types `runkitShell` as `unknown` (the bridge is runtime-injected, so it is validated structurally — type-narrowing guards, no `as` casts), `shellInfo()` returning a plain `{ version, platform }` (never leaking `__welcome`) or `null`, `isShell()`, the `servers`-group wrappers `listShellServers(): Promise` / `switchShellServer(id): Promise`, the additive capability pairs `canAddShellHost()`/`addShellHost()` and `canReorderShellHosts()`/`reorderShellHosts(id, toIndex): Promise` (each invoker narrowed separately from the base group, so the group stays usable on shells that lack it), and the `badge`-group wrapper `setShellBadge(count): Promise`. Every wrapper **never throws**: a plain browser, an older shell lacking the group, a malformed entry, an `{ ok: false }` denial, and a rejected invoke all resolve `null`/`false`. Covered by the sibling vitest suite `shell.test.ts` (present / absent / malformed shapes of each surface). Three SPA surfaces consume this seam today. The palette's shell-gated `Server: Switch to ""` block gates on the `servers` group's own emptiness (`listShellServers()` resolving `null`/`[]` outside the shell) rather than calling `isShell()`, since an older shell exposes `version`/`platform` without the group (see [ui-patterns](/run-kit/ui-patterns.md) § Keyboard Shortcuts). The **titlebar strip** and the **badge reporter** mount on `isShell()` instead — they are shell-only *chrome*, so the shell's own presence is the honest gate, and each degrades independently inside it (the strip falls back to `location.hostname` when `servers.list()` is unavailable; `setShellBadge` resolves `false` on an older shell with no `badge` group). The welcome page's own script narrows the bridge the same structural way (`Reflect.get(window, "runkitShell")`, no global augmentation). @@ -523,6 +524,12 @@ The vitest column of the split also covers `palette-shell.test.ts` (the palette- **Rejected**: A `titlebar:color` IPC channel (a second copy of state the meta already broadcasts, plus a gate and an ordering hazard); reading the meta tag from main via `executeJavaScript` (a poll or a race where the event is a push); passing the color through the URL or a query param (nothing to update it on a live accent change). *Introduced by*: 260731-ofws-shell-accent-titlebar-dock-badge +### Accent color persists in hosts.json, not just the view registry +**Decision**: Each host entry carries an additive optional `accentColor` (schema stays version 1), captured from the view's `did-change-theme-color` reports (non-null strings only, unchanged values short-circuited), rather than projecting the registry's session-scoped `themeColor` cache. +**Why**: The registry cache dies with the window and never exists for hosts not yet visited this run; persistence makes the host-switcher's per-host edge bars correct at cold start — precisely when the menu is most needed to tell hosts apart. +**Rejected**: Registry-only projection — colors would blank on every launch until each host is visited. +*Introduced by*: 260813-1i7j-host-switcher-color-reorder-waiting + ### The version-skew fallback strip is self-disabling CSS, not a probe **Decision**: `fallbackStripCss` is injected on **every** registered-host load, with all selectors keyed on `html:not(.rk-shell-strip)`; the SPA sets that marker class on `` while its strip is mounted. The shell never asks the page whether it has a strip. **Why**: Hiding the native titlebar removes the window's only drag surface, so an older SPA under a newer shell would be undraggable — a genuine skew hazard, since shell and SPA ship on independent release trains. CSS is live, which makes the marker a *continuous* answer rather than a point-in-time one: whenever the real strip mounts (including after a slow hydration), the injected rules stop applying, with no timing window in either direction. A probe would have to pick a moment, and every candidate moment is wrong for some page. @@ -619,6 +626,18 @@ The vitest column of the split also covers `palette-shell.test.ts` (the palette- **Rejected**: Bare-array success plus an object failure — two unrelated top-level shapes for one channel to narrow. *Introduced by*: 260730-9lez-shell-keyboard-tier-symmetry +### Reorder is move-by-id (`{id, toIndex}`), committed per gesture +**Decision**: One IPC channel `servers:reorder` carrying `{id, toIndex}`; each keyboard press (⌥↑/⌥↓) or drag-drop commits one move, and each committed move rebuilds the native menu because the Hosts-menu accelerators derive from list order. +**Why**: Keys on the immutable host id (the store's id-keyed-mutator rule); a full-array payload would trust renderer-supplied order and need set-equality validation. Host list order IS the ⌥⌘1–9/⇧Ctrl+1–9 accelerator map, which is also why ordering is manual at all — alphabetical auto-sort would silently remap accelerators whenever a host is added or renamed. +**Rejected**: `{order: id[]}` full-list replace — larger validation surface, and a stale renderer list would silently drop concurrently-added hosts. +*Introduced by*: 260813-1i7j-host-switcher-color-reorder-waiting + +### Waiting count joins at the `servers:list` handler, not in `hostInfos` +**Decision**: `hostInfos` (electron-free, store-only) never fills `waiting`; the `main.ts` handler joins the registry's per-view `badgeCount` caches into the projection (count > 0 → `waiting` set; no view or zero → field omitted). +**Why**: Keeps `hosts.ts` electron-free and store-pure (the module boundary the whole test strategy rides on); the registry is main-side state. +**Rejected**: Passing the registry into `hostInfos` — couples the store module to view state for a three-line join. +*Introduced by*: 260813-1i7j-host-switcher-color-reorder-waiting + ### Shared `switchToHost` seam in main **Decision**: The switch body (set active via the store → attach the host's view → rebuild menu) is one function called by the menu radio callback, the `servers:switch` handler, the local-connect tail, and the `welcome:add-host` handler. **Why**: The IPC switch must behave identically to clicking the radio; a shared function makes divergence structurally impossible instead of merely intended. The seam carries real complexity — view creation, attach/detach, badge and overlay repaint — which is exactly the kind of body that would drift across four call sites were each to keep its own copy. diff --git a/docs/memory/run-kit/ui-patterns.md b/docs/memory/run-kit/ui-patterns.md index 19563493e..0d02a0e8a 100644 --- a/docs/memory/run-kit/ui-patterns.md +++ b/docs/memory/run-kit/ui-patterns.md @@ -1832,15 +1832,17 @@ The instance accent's **third consumer surface**, and the only one that exists o **Host-switcher dropdown — the mouse-secondary switch path.** When the bridge answers a **non-empty** host list (`stripSwitcherEnabled(servers)`, a type guard) the label renders as a trigger button — ` ▾`, `aria-haspopup="menu"` / `aria-expanded`, `aria-label="Switch host"`, with a subtle currentColor-tinted hover pill (`hover:bg-current/15`, which reads on any accent-blended strip background since the label color is already contrast-derived against it) — reusing the top bar's `▾` switcher vocabulary (§ Crumb Affordance Vocabulary). It is the mouse-secondary companion to the ⌥⌘1–9 / ⇧Ctrl+1–9 accelerators and the palette's `Server: Switch to` block, which stay the primary paths (Constitution V). Below the gate — an older shell exposing only `version`/`platform`, a denial, or an empty store — the label is a plain static span with no chevron, no pill, and no click behavior; the band still mounts, because its gate is `isShell()` alone (§ Design Decisions → Shell chrome mounts on `isShell()`). -**Row anatomy** (`shellHostMenuRows(servers, platform)` in `lib/shell-strip.ts`, list order preserved because that IS the native menu's binding order): a fixed-width ✓ marker column so names align, the active row in `text-accent`; the display name; the **dimmed origin** (`new URL(url).origin`, falling back to the raw string on a parse failure) — host display names are not unique because the shell's store never dedupes, so the origin disambiguates; and a trailing accelerator hint `⌥⌘{n}` on darwin / `⇧Ctrl+{n}` elsewhere, `null` past `MAX_SHELL_SWITCHER_HINTS = 9` (mirroring the native menu's `MAX_SWITCHER_ACCELERATORS` — a host with no binding gets no hint). Platform comes from the bridge's `shellInfo()?.platform`. Rows need no bridge change: `servers:list` already projects `{id, name, url, active}`. +**Row anatomy** (`shellHostMenuRows(servers, platform)` in `lib/shell-strip.ts`, list order preserved because that IS the native menu's binding order): a fixed-width ✓ marker column so names align, the active row in `text-accent`; the display name; the **dimmed origin** (`new URL(url).origin`, falling back to the raw string on a parse failure) — host display names are not unique because the shell's store never dedupes, so the origin disambiguates; and a trailing accelerator hint `⌥⌘{n}` on darwin / `⇧Ctrl+{n}` elsewhere, `null` past `MAX_SHELL_SWITCHER_HINTS = 9` (mirroring the native menu's `MAX_SWITCHER_ACCELERATORS` — a host with no binding gets no hint). Platform comes from the bridge's `shellInfo()?.platform`. The row model also carries two additive, independently optional fields from the `servers:list` projection ([desktop-shell](/run-kit/desktop-shell.md) § Bridge): `accentColor: string | null` — the host's persisted instance accent, passed through only when it hex-validates against a strict `#RGB`/`#RRGGBB`/`#RRGGBBAA` pattern (the shell-side `fallbackStripCss` precedent — an unvalidated value never reaches style interpolation); and `waiting: number | null` — the view-registry-joined waiting-agent count, kept only when positive and the row is NOT active (the active host's attention surface is the dock badge). An older shell's plain `{id, name, url, active}` projection parses fine with both fields absent (`isShellServer` validates optionals strict-when-present) and derives `null`/`null`. + +**Three capability-gated row features render off that model.** A non-null `accentColor` paints a ~3px left-edge bar (`absolute bottom-1 left-0 top-1 w-[3px] rounded-full`, `aria-hidden`) overlaid on the row's edge, so a colorless row keeps identical text alignment. A non-null `waiting` renders an amber `● {N}` chip (`text-amber-600`) in the trailing `ml-auto` cluster before the accelerator hint — waiting-only semantics, `0`/absent renders nothing, matching the dock badge's "non-zero means act now" rule. **Manual reorder** rides the optional `reorder` invoker (`canReorderShellHosts()` / `reorderShellHosts(id, toIndex)` in `lib/shell.ts`, following the optional-invoker pattern exactly — separate structural narrowing of the additive bridge member, `false` outside the shell or on rejection, never throws): a hover-revealed drag grip (`⋮⋮`, trailing edge, width reserved by `pr-6` only when the capability is present) starts an HTML5 drag keyed on a custom MIME (`application/x-shell-host-reorder`, so it never collides with the sidebar/board drag payloads); the local order reorders optimistically during the drag (derive-over-store presentation state — the open-time refetch reconciles), and exactly ONE `reorderShellHosts(id, toIndex)` commits at drop, with a failure surfacing the error-toast precedent plus a refetch. With the menu open, ⌥↑/⌥↓ (Alt+ArrowUp/Down on ALL platforms — arrows compose no characters, so the macOS Option-composition issue does not apply) moves the focused row one step per keypress with the same optimistic reorder so the accelerator hints re-number live; focus follows the moved row, there is no wrap at the list edges (an edge move swallows the key without invoking the shell), and the Add-Host footer is not movable. Each committed move lands in the shell's store and rebuilds its native menu ([desktop-shell](/run-kit/desktop-shell.md) § `window.runkitShell` Bridge). Without the capability (older shell) no grips render and Alt+arrows fall through to the plain roving-focus cycle; all three features degrade independently, so an older shell renders exactly the marker/name/origin/hint rows. **Refetch on open, guarded by a monotonic sequence.** Every open issues a fresh `listShellServers()` — the native `Hosts → Remove ""…` menu mutates the store without a page reload, so a mount-time-only list goes stale. One `listSeqRef` counter guards *both* fetches: a resolution whose sequence is not the latest issued is dropped, so out-of-order resolutions can never leave a stale list rendered, and the mount effect's cleanup bump doubles as cancellation (the open-time fetch has no effect cleanup to cancel it). A `null` resolution — the never-throw wrapper's denial signal — keeps the last known list rather than blanking an open menu. -**Menu semantics copy `BreadcrumbDropdown`'s contract** (§ Breadcrumb Dropdowns): `role="menu"` with `role="menuitem"` rows, capture-phase keydown for Escape (close + refocus trigger) and ArrowDown/ArrowUp (wrapping roving tabindex; Enter needs no handler because each row is a native `