From 163a70dc4d0b942cba7aca347dafc2256b2776a0 Mon Sep 17 00:00:00 2001 From: Joao Vitor Date: Sun, 18 Oct 2026 21:58:14 -0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(portal):=20nome=20do=20PC=20e=20"Adici?= =?UTF-8?q?onar=20PC"=20por=20usu=C3=A1rio=20vindos=20do=20portal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O /api/portal/me ganha o campo pc: { name, add_pc }. Com name, o PC local aparece com esse nome (mapeamento único sobre a lista de máquinas, para que sidebar, paleta e feedback vejam o mesmo). Com add_pc false somem todos os controles que terminam em startMachineSetup: Adicionar PC (rodapé, paleta, Configurações), Reconectar/configurar, Atualizar bridge…, os avisos e o banner. Conectar/Desconectar, Remover PC e o update-bridge sem login ficam. Sem o campo (ou fora do portal) nada muda. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++ scripts/ci-browser.sh | 1 + scripts/portal-pc-regression.ts | 72 +++++++++++++++++++++++++++++++ src/App.tsx | 19 +++++--- src/components/CommandPalette.tsx | 2 +- src/components/MachineSidebar.tsx | 19 ++++---- src/components/SettingsDialog.tsx | 4 +- src/lib/actions.ts | 4 +- src/lib/portal.test.ts | 27 ++++++++++++ src/lib/portal.ts | 18 +++++++- 10 files changed, 148 insertions(+), 23 deletions(-) create mode 100644 scripts/portal-pc-regression.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e42ea468d..ea2f9780b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ## [Unreleased] ### Added +- Behind the Data Agile portal, the portal can name the local PC for its user (`pc.name` in `/api/portal/me`) and turn + adding PCs off for them (`pc.add_pc: false`). With it off, Add PC (sidebar footer, command palette, Settings → + Remote PCs) and every control that starts a PC setup (Reconnect / setup, Update bridge…, Set up…, Sign in and + update…, and the banner's Set up… / Reconnect) are not shown; Connect / Disconnect, Remove PC and the bridge + update that needs no sign-in stay. Without the field nothing changes. - The **Files** dialog is a tree: folders open in place and read their children on demand (open folders are remembered per PC and pane folder), with file-type icons, a selected-row rail and arrow-key navigation (up/down, right/left to open and close, Home/End). A **Filter by name** field keeps the matching names and their folders, diff --git a/scripts/ci-browser.sh b/scripts/ci-browser.sh index 9cf3760615..b46a22db65 100644 --- a/scripts/ci-browser.sh +++ b/scripts/ci-browser.sh @@ -39,3 +39,4 @@ bun scripts/voice-server-regression.ts bun scripts/machine-dialog-regression.ts bun scripts/feedback-regression.ts bun scripts/machine-conflict-regression.ts +bun scripts/portal-pc-regression.ts diff --git a/scripts/portal-pc-regression.ts b/scripts/portal-pc-regression.ts new file mode 100644 index 0000000000..9e06e8e18d --- /dev/null +++ b/scripts/portal-pc-regression.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { resolve } from "node:path"; +import { chromium } from "playwright-core"; +import type { Machine } from "../shared/machines.ts"; +import { openSettingsPage } from "./settings-page.ts"; + +// A built client with a fictional roster and an in-memory API standing in for the portal's +// `/api/portal/me`. `pc.add_pc: false` removes every control that ends in a PC setup (the footer, +// the palette, Settings → Remote PCs, Reconnect / setup, Update bridge…, the notices and the +// banner) and nothing else; `pc.name` renames the local PC. Without `pc` the app is unchanged. +const snapshot = { version: "0.9.3", protocol: 22, focused_workspace_id: null, focused_tab_id: null, focused_pane_id: null, workspaces: [], tabs: [], panes: [], layouts: [], agents: [] }; +const local = { id: "local", name: "QA host", kind: "local", state: "connected", enabled: true, error: null, snapshot } as Machine; +const remote = { ...local, id: "qa-remote", name: "QA remote", kind: "ssh", target: { destination: "qa.invalid" }, state: "error", error: null, action_required: "setup" } as unknown as Machine; +let me: unknown = { authenticated: true }; +const setups: string[] = []; +const server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) { + const path = new URL(request.url).pathname; + if (path === "/api/portal/me") return Response.json(me); + if (path === "/api/health") return Response.json({ ok: true, auth: { authenticated: true, required: false, role: "drive" }, herdr: { version: "0.9.3", protocol: 22 }, web_ui: { revision: null, boot_id: "qa" } }); + if (path === "/api/machines") return Response.json({ machines: [local, remote] }); + if (path === "/api/session") return Response.json({ snapshot }); + if (path === "/api/machines/settings") return Response.json({ auto_update_bridges: false }); + if (path === "/api/machines/setup") { setups.push(request.method); return Response.json({ error: { code: "add_pc_disabled", message: "off" } }, { status: 403 }); } + if (path.startsWith("/api/") || path === "/ws") return Response.json({ error: { code: "qa", message: "Unavailable in fixture" } }, { status: 404 }); + const file = Bun.file(resolve("dist", path === "/" ? "index.html" : path.slice(1))); + return new Response(await file.exists() ? file : Bun.file("dist/index.html")); +} }); +const browser = await chromium.launch({ headless: true, executablePath: process.env["CHROME_PATH"] || chromium.executablePath() }); +try { + const open = async (answer: unknown) => { + me = answer; + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + await page.addInitScript(() => localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en" }))); + await page.goto(server.url.href); + await page.locator(".machine-action").waitFor({ timeout: 10_000 }); + return page; + }; + const buttons = async (page: import("playwright-core").Page) => { + const count = (name: string) => page.getByRole("button", { name, exact: true }).count(); + const result = { footer: await count("Add PC"), notice: await page.locator(".machine-action").getByRole("button", { name: "Set up…", exact: true }).count(), banner: await page.locator(".update-notice").getByRole("button", { name: "Set up…", exact: true }).count() }; + await page.getByRole("button", { name: "Manage QA remote" }).click(); + const manage = { reconnect: await count("Reconnect / setup"), update: await count("Update bridge…"), disconnect: await count("Disconnect"), remove: await count("Remove PC") }; + const dialog = page.getByRole("dialog", { name: "Command palette", exact: true }); + await page.keyboard.press("Control+Shift+k"); + await dialog.waitFor(); + const palette = await dialog.getByRole("option", { name: "Add PC" }).count(); + await dialog.getByRole("option", { name: "Settings" }).click(); + await openSettingsPage(page, "Remote PCs"); + const settings = await page.locator(".settings-dialog").getByRole("button", { name: "Add PC", exact: true }).count(); + await page.keyboard.press("Escape"); + return { ...result, ...manage, palette, settings }; + }; + + // without `pc`, or with a `pc` that changes nothing: everything as before + for (const answer of [{ authenticated: true }, { authenticated: true, pc: null }, { authenticated: true, pc: { name: null, add_pc: true } }]) { + const page = await open(answer); + const on = await buttons(page); + assert.deepEqual(on, { footer: 1, notice: 1, banner: 1, reconnect: 1, update: 1, disconnect: 1, remove: 1, palette: 1, settings: 1 }, JSON.stringify(answer)); + await page.getByText("QA host", { exact: true }).first().waitFor(); + await page.close(); + } + + // add_pc: false hides every setup control and keeps the rest; name renames the local PC + const page = await open({ authenticated: true, pc: { name: "Notebook da Ana", add_pc: false } }); + const off = await buttons(page); + assert.deepEqual(off, { footer: 0, notice: 0, banner: 0, reconnect: 0, update: 0, disconnect: 1, remove: 1, palette: 0, settings: 0 }); + await page.getByText("Notebook da Ana", { exact: true }).first().waitFor(); + assert.equal(await page.getByText("QA host", { exact: true }).count(), 0); + assert.deepEqual(setups, [], "no setup request was made"); + await page.close(); + console.log("PASS: add_pc false hides every PC setup control and a portal name renames the local PC; absent or null pc changes nothing"); +} finally { await browser.close(); server.stop(true); } diff --git a/src/App.tsx b/src/App.tsx index be2e0c818d..6b520e344a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,7 +24,7 @@ import { MachineDialog } from "./components/MachineDialog.tsx"; import { copyText } from "./lib/clipboard.ts"; import { focusWorkspaceListToggle } from "./lib/focus.ts"; import { headerCrumb, showsChat } from "./lib/headerCrumb.ts"; -import { paneStorageId, type Machine, type MachineEvent } from "../shared/machines.ts"; +import { LOCAL_MACHINE, paneStorageId, type Machine, type MachineEvent } from "../shared/machines.ts"; import { takeAuthTokenFromUrl } from "./lib/authLink.ts"; import { applyLayout, applyPaneStatus, pinLayouts, type LayoutPin } from "./lib/snapshot.ts"; import { showsSplit, SPLIT_MIN_WIDTH, tabLayout, type ResizeStep } from "./lib/splitLayout.ts"; @@ -161,7 +161,10 @@ export function App() { const alertsOn = settings.alertsOn; const alertsOnRef = useRef(alertsOn); alertsOnRef.current = alertsOn; - const [machines, setMachines] = useState([]); + const [rawMachines, setMachines] = useState([]); + // the portal names the local PC for its user: one mapping here, so every path that sets the roster shows it + const [portalPcName, setPortalPcName] = useState(null); + const machines = useMemo(() => portalPcName ? rawMachines.map((m) => m.id === LOCAL_MACHINE ? { ...m, name: portalPcName } : m) : rawMachines, [rawMachines, portalPcName]); const [selectedMachineId, setSelectedMachineId] = useState(() => { const query = new URLSearchParams(window.location.search); if (query.has("pane")) return query.get("machine") ?? "local"; @@ -196,11 +199,13 @@ export function App() { useEffect(() => { void fetchPortalSession().then((session) => { setPortalSession(session); + setPortalPcName(session.pc.name); // the feedback's technical data (console, failed requests) is recorded only for a portal that takes feedback if (session.portal && session.feedback.enabled) installFeedbackBuffer(); }); }, []); const portal = portalSession?.portal === true; + const canAddPc = portalSession?.pc.addPc !== false; const canSignOut = !portal && auth?.authenticated === true && (auth.via === "token" || auth.via === "device"); // a device that is in only because nothing is paired yet still pairs from the QR code's address const pairedFromAddress = useRef(false); @@ -938,13 +943,13 @@ export function App() { setSettingsSection(null); setSettingsOpen(true); }, - openAddPc: () => { + openAddPc: canAddPc ? () => { // the new PC reports its progress in the sidebar: nothing should sit over it setSettingsOpen(false); setUpdateRemote(false); addPcFocusReturn.current = true; setMachineDialog("new"); - }, + } : null, toggleSidebar: () => { if (window.matchMedia("(max-width: 768px)").matches) setDrawerOpen((open) => !open); else setSidebarCollapsed((collapsed) => !collapsed); @@ -956,7 +961,7 @@ export function App() { refresh: () => void load(), openFiles: selectedPaneId !== null ? () => { setDrawerOpen(false); setFilesOpen(true); } : null, }), - [pickPane, selectPane, selectedPaneId, selectedMachineId, setView, view, splitting, updateSettings, resolvedTheme, canSignOut, lock, portal, portalSignOut, bellVisible, bell.run, enableNotifications, load], + [pickPane, selectPane, selectedPaneId, selectedMachineId, setView, view, splitting, updateSettings, resolvedTheme, canSignOut, canAddPc, lock, portal, portalSignOut, bellVisible, bell.run, enableNotifications, load], ); useShortcuts(actions, locked === false); @@ -1167,7 +1172,7 @@ export function App() { {error &&

{error}

} - { setUpdateRemote(update); setMachineDialog(machine); }} onNew={(id) => { setNewSessionMachineId(id); setNewTab(null); setNewSessionOpen(true); setDrawerOpen(false); }} /> + { setUpdateRemote(update); setMachineDialog(machine); } : null} onNew={(id) => { setNewSessionMachineId(id); setNewTab(null); setNewSessionOpen(true); setDrawerOpen(false); }} /> @@ -1178,7 +1183,7 @@ export function App() {
{/* over the pane only: a bar across the window would cut the sidebar off from its top row in the header */} { setSettingsSection("updates"); setSettingsOpen(true); }} /> - { setDrawerOpen(false); setUpdateRemote(update); setMachineDialog(machine); }} /> + { setDrawerOpen(false); setUpdateRemote(update); setMachineDialog(machine); } : null} /> {snapshot && selectedPane && selectedWorkspace && ( actions.openNewTab()} sideBySide={splitting} /> )} diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index af76e1ce1d..19fe9065f1 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -110,7 +110,7 @@ export function CommandPalette({ open, onClose, snapshot, selectedPaneId, view, { id: "sidebar", label: t("Toggle sidebar"), icon: PanelLeft, shortcut: "toggle-sidebar", run: actions.toggleSidebar }, { id: "theme", label: t("Toggle theme"), icon: SunMoon, run: actions.toggleTheme }, { id: "settings", label: t("Settings"), icon: Settings, shortcut: "settings", run: actions.openSettings }, - { id: "add-pc", label: t("Add PC"), icon: Monitor, run: actions.openAddPc }, + ...(actions.openAddPc ? [{ id: "add-pc", label: t("Add PC"), icon: Monitor, run: actions.openAddPc }] : []), ...(actions.enableNotifications ? [{ id: "notifications", label: t("Enable notifications"), icon: Bell, run: actions.enableNotifications }] : []), ...(actions.portalSignOut ? [{ id: "portal-sign-out", label: t("Sign out"), icon: LogOut, run: actions.portalSignOut }] : []), ...(actions.lock ? [{ id: "lock", label: t("Sign out"), icon: LockKeyhole, run: actions.lock }] : []), diff --git a/src/components/MachineSidebar.tsx b/src/components/MachineSidebar.tsx index b594e9d82c..276ffd154a 100644 --- a/src/components/MachineSidebar.tsx +++ b/src/components/MachineSidebar.tsx @@ -26,7 +26,8 @@ export const STATE_WORD: Readonly> = { error: "Connection error", }; -interface Props { herdrVersion: string | null; machines: Machine[]; /** PCs whose bridge refuses `pane/seen`: this device could never clear a DONE row there */ seenRefused: ReadonlySet; selectedMachineId: string; selectedPaneId: string | null; actions: AppActions; onSelect(machineId: string, paneId: string | null): void; onNew(machineId: string): void; onSetup(machine: Machine, update?: boolean): void } +type SetupFn = (machine: Machine, update?: boolean) => void; +interface Props { herdrVersion: string | null; machines: Machine[]; /** PCs whose bridge refuses `pane/seen`: this device could never clear a DONE row there */ seenRefused: ReadonlySet; selectedMachineId: string; selectedPaneId: string | null; actions: AppActions; onSelect(machineId: string, paneId: string | null): void; onNew(machineId: string): void; onSetup: SetupFn | null } export function MachineSidebar(props: Props) { const t = useT(); const { canInstall, installed, install, help } = useInstallPrompt(); @@ -45,7 +46,7 @@ export function MachineSidebar(props: Props) { {!props.machines.length &&

{t("Loading PCs…")}

}