From 589cdb4aeb986ff8845d5b1537bf79860b87a3df Mon Sep 17 00:00:00 2001 From: burakgon Date: Sun, 19 Jul 2026 10:24:42 +0300 Subject: [PATCH] fix: unify hosted terminal workspace --- CHANGELOG.md | 7 ++ package.json | 2 +- packages/cli/package.json | 2 +- packages/server/package.json | 2 +- packages/web/package.json | 2 +- packages/web/src/App.tsx | 40 +++---- packages/web/src/config.ts | 4 - site/scripts/hosted-product-audit.mjs | 26 ++-- site/src/app-shell.css | 164 +++++++++++++++----------- site/src/app-shell.test.ts | 108 +++++++++++++++-- site/src/app-shell.ts | 80 +++++++------ 11 files changed, 275 insertions(+), 162 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 962bf1b..6429a12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ The format loosely follows [Keep a Changelog](https://keepachangelog.com/); date ## [Unreleased] +## [1.4.3] - 2026-07-19 + +### Changed + +- Open hosted Sessions and Automations as the full RoamCode workspace instead of nesting the terminal inside a + second control-plane frame, keeping one navigation plane on desktop and mobile. + ## [1.4.2] - 2026-07-19 ### Fixed diff --git a/package.json b/package.json index 4bcfce6..a7b2e1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roamcode-workspace", - "version": "1.4.2", + "version": "1.4.3", "private": true, "type": "module", "packageManager": "pnpm@11.9.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index b44ea26..a1730aa 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "roamcode", - "version": "1.4.2", + "version": "1.4.3", "description": "Operate Claude Code or Codex sessions remotely from an installable PWA", "homepage": "https://roamcode.ai", "bugs": "https://github.com/burakgon/roamcode/issues", diff --git a/packages/server/package.json b/packages/server/package.json index 2c2e35d..27af85b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@roamcode.ai/server", - "version": "1.4.2", + "version": "1.4.3", "description": "Host-native server for RoamCode", "homepage": "https://roamcode.ai", "bugs": "https://github.com/burakgon/roamcode/issues", diff --git a/packages/web/package.json b/packages/web/package.json index 2c53af3..32befa6 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@roamcode.ai/web", - "version": "1.4.2", + "version": "1.4.3", "description": "Installable web client bundled with RoamCode", "homepage": "https://roamcode.ai", "bugs": "https://github.com/burakgon/roamcode/issues", diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index c661dcf..a66f538 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -5,7 +5,7 @@ import { defaultDeviceName } from "./auth/device-name"; import { createApiClient, ApiError, claimPairing, type ApiClientOptions } from "./api/client"; import { createProductApiV2Client, ProductApiV2Error } from "./api/v2/client"; import type { AgentRuntimeRecord, NodeRecord, ProductContext, V2Session } from "./api/v2/types"; -import { API_BASE_URL, EMBEDDED_CLOUD, PRODUCT_MODE } from "./config"; +import { API_BASE_URL, PRODUCT_MODE } from "./config"; import { useStore } from "./store/store"; import { useShallow } from "zustand/react/shallow"; import { AppLayout } from "./AppLayout"; @@ -2473,7 +2473,7 @@ export function App() { if (phase === "login" || token === undefined) { return (
- {PRODUCT_MODE === "cloud" && !EMBEDDED_CLOUD && hostSwitcher} + {PRODUCT_MODE === "cloud" && hostSwitcher}
- {PRODUCT_MODE === "cloud" && !EMBEDDED_CLOUD && activeDirectHost.relay && hostSwitcher} + {PRODUCT_MODE === "cloud" && activeDirectHost.relay && hostSwitcher}
- {PRODUCT_MODE === "cloud" && !EMBEDDED_CLOUD && hostSwitcher} + {PRODUCT_MODE === "cloud" && hostSwitcher} - ) + } mobileNavigation={ - EMBEDDED_CLOUD ? undefined : ( - - ) + } sessionList={destination === "sessions" ? list : undefined} showSessionRail={destination === "sessions"} - showMobileNavigation={!EMBEDDED_CLOUD && (destination !== "sessions" || activeSessionId === undefined)} + showMobileNavigation={destination !== "sessions" || activeSessionId === undefined} sessionsOpen={destination === "sessions" && sessionsOpen} conversationActive={destination === "sessions" && activeSessionId !== undefined} onHideSessions={() => setSessionsOpen(false)} diff --git a/packages/web/src/config.ts b/packages/web/src/config.ts index 6223d2b..daac769 100644 --- a/packages/web/src/config.ts +++ b/packages/web/src/config.ts @@ -6,7 +6,3 @@ export type ProductMode = "standalone" | "cloud"; /** Product behavior is explicit. A URL prefix is routing, not a reliable statement about the product mode. */ export const PRODUCT_MODE: ProductMode = (import.meta.env.VITE_PRODUCT_MODE as string | undefined) === "cloud" ? "cloud" : "standalone"; - -/** Same-origin Cloud workbench embeds the encrypted terminal without duplicating global navigation. */ -export const EMBEDDED_CLOUD = - PRODUCT_MODE === "cloud" && new URLSearchParams(window.location.search).get("embed") === "1"; diff --git a/site/scripts/hosted-product-audit.mjs b/site/scripts/hosted-product-audit.mjs index fc200b1..5320847 100644 --- a/site/scripts/hosted-product-audit.mjs +++ b/site/scripts/hosted-product-audit.mjs @@ -90,21 +90,21 @@ const findings = []; const screenshots = []; const stepContracts = new Map( [ - ["01-sessions-desktop", "/app/sessions", "Sessions — RoamCode", "Sessions", "sessions"], - ["02-automations-desktop", "/app/automations", "Automations — RoamCode", "Automations", "automations"], + ["01-sessions-desktop", "/terminal/sessions", "Sessions — RoamCode", "Sessions", "sessions"], + ["02-automations-desktop", "/terminal/automations", "Automations — RoamCode", "Automations", "automations"], ["03-agents-desktop", "/app/agents", "Agents — RoamCode", "Agents", "agents"], ["04-people-desktop", "/app/people", "People & Access — RoamCode", "People & Access", "people"], ["05-account-desktop", "/app/account", "Account — RoamCode", "Account", "account"], - ["06-sessions-mobile", "/app/sessions", "Sessions — RoamCode", "Sessions", "sessions"], + ["06-sessions-mobile", "/terminal/sessions", "Sessions — RoamCode", "Sessions", null], ["07-agents-mobile", "/app/agents", "Agents — RoamCode", "Agents", "agents"], ["08-sign-in-desktop", "/app", "Sign in — RoamCode", "Welcome back", null], ["09-sign-up-desktop", "/app", "Create account — RoamCode", "Create your RoamCode account", null], - ["10-sessions-desktop", "/app/sessions", "Sessions — RoamCode", "Sessions", "sessions"], - ["11-automations-desktop", "/app/automations", "Automations — RoamCode", "Automations", "automations"], + ["10-sessions-desktop", "/terminal/sessions", "Sessions — RoamCode", "Sessions", "sessions"], + ["11-automations-desktop", "/terminal/automations", "Automations — RoamCode", "Automations", "automations"], ["12-agents-desktop", "/app/agents", "Agents — RoamCode", "Agents", "agents"], ["13-people-desktop", "/app/people", "People & Access — RoamCode", "People & Access", "people"], ["14-account-desktop", "/app/account", "Account — RoamCode", "Account", "account"], - ["15-sessions-narrow", "/app/sessions", "Sessions — RoamCode", "Sessions", "sessions"], + ["15-sessions-narrow", "/terminal/sessions", "Sessions — RoamCode", "Sessions", null], [ "16-organization-desktop", "/app/organization", @@ -423,9 +423,9 @@ function terminalWorkbenchFixture(destination) { const detail = automation ? `
Release readinessNext run · Mon 09:00
TRIGGERWeekdays at 09:00 · Europe/IstanbulRUNS ONStudio Mac · /remote-coderACTIONOpen an inspectable Codex session
` : `
Release candidateCodex · connected
~/remote-coder main\n\n› Review the release candidate and run the complete verification suite.\n\n✓ Typecheck, unit tests, and production build passed.\n
`; - return Buffer.from(`${title}
${detail}
`); + return Buffer.from(`${title} — RoamCode
rc

${title}

Studio Mac
${detail}
`); } function staticFixture(url) { @@ -523,7 +523,7 @@ function auditExpression() { minimum: minimumFor(element), }; }; - const heading = document.querySelector("h1"); + const heading = Array.from(document.querySelectorAll("h1")).find(visible); const headingRect = heading?.getBoundingClientRect(); return { title: document.title, @@ -540,6 +540,7 @@ function auditExpression() { mainHeading: heading?.textContent?.trim(), mainHeadingTop: headingRect ? Math.round(headingRect.top) : null, mainHeadingBottom: headingRect ? Math.round(headingRect.bottom) : null, + iframeCount: document.querySelectorAll("iframe").length, bodyText: document.body.innerText.slice(0, 500), }; })()`; @@ -561,6 +562,7 @@ function recordEvidence(browser, step, evidence) { if (JSON.stringify(evidence.activeRoutes) !== JSON.stringify(expectedRoutes)) { failures.push(`active routes ${JSON.stringify(evidence.activeRoutes)} != ${JSON.stringify(expectedRoutes)}`); } + if (evidence.iframeCount !== 0) failures.push(`page contains ${evidence.iframeCount} iframe(s)`); if (Math.abs(evidence.scrollY) > 1) failures.push(`page retained scroll position ${evidence.scrollY}`); if ( typeof evidence.mainHeadingTop !== "number" || @@ -573,7 +575,7 @@ function recordEvidence(browser, step, evidence) { } async function waitForChromePage(page, heading) { - await page.locator("h1", { hasText: heading }).waitFor({ state: "visible", timeout: 15_000 }); + await page.locator("h1:visible", { hasText: heading }).waitFor({ state: "visible", timeout: 15_000 }); await page.waitForFunction( () => Array.from(document.querySelectorAll("iframe")).every( @@ -649,7 +651,7 @@ async function runChrome() { recordRuntimeSignals(mobile, errors, "mobile "); await mobile.goto(`${origin}/app/sessions`, { waitUntil: "domcontentloaded" }); await captureChrome(mobile, "06-sessions-mobile", "Sessions"); - await mobile.getByRole("link", { name: "Agents", exact: true }).last().click(); + await mobile.goto(`${origin}/app/agents`, { waitUntil: "domcontentloaded" }); await captureChrome(mobile, "07-agents-mobile", "Agents"); await mobile.locator("a.rc-cloud-avatar:visible").click(); await mobile.locator("a.rc-cloud-context-manage:visible").click(); diff --git a/site/src/app-shell.css b/site/src/app-shell.css index 7827b31..58ff3b1 100644 --- a/site/src/app-shell.css +++ b/site/src/app-shell.css @@ -1542,106 +1542,121 @@ align-self: center; } -.rc-cloud-workbench { - width: 100%; +.rc-cloud-product-handoff { min-height: calc(100dvh - 48px); display: grid; - grid-template-rows: auto auto minmax(560px, 1fr); - gap: 12px; + align-content: center; + justify-items: center; + gap: 10px; + padding: 32px; + text-align: center; } -.rc-cloud-workbench-head { - display: flex; - align-items: end; - justify-content: space-between; - gap: 20px; +.rc-cloud-product-handoff .rc-cloud-loader { + margin-bottom: 10px; } -.rc-cloud-workbench-head > div { +.rc-cloud-product-handoff h1 { + margin: 0; + color: var(--rc-text); + font-family: var(--display); + font-size: clamp(28px, 4vw, 44px); + letter-spacing: -0.045em; +} + +.rc-cloud-product-handoff p { + max-width: 460px; + margin: 0 0 10px; + color: var(--rc-muted); +} + +.rc-cloud-node-launcher { display: grid; - gap: 5px; + grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1.3fr); + gap: 28px; + padding: 28px; + border: 1px solid var(--rc-line); + border-radius: 18px; + background: var(--rc-panel); +} + +.rc-cloud-node-launcher > div:first-child { + align-content: start; + display: grid; + gap: 8px; } -.rc-cloud-workbench-head h1 { +.rc-cloud-node-launcher h2, +.rc-cloud-node-launcher p { margin: 0; +} + +.rc-cloud-node-launcher h2 { color: var(--rc-text); font-family: var(--display); - font-size: clamp(24px, 3vw, 38px); + font-size: clamp(22px, 3vw, 34px); letter-spacing: -0.04em; } -.rc-cloud-workbench-head p { - margin: 0; +.rc-cloud-node-launcher p { color: var(--rc-muted); - font-size: 12px; } -.rc-cloud-workbench-nodes { - display: flex; - gap: 7px; - overflow-x: auto; - padding-bottom: 2px; +.rc-cloud-node-launcher-grid { + display: grid; + gap: 8px; } -.rc-cloud-workbench-node { - min-width: 150px; +.rc-cloud-node-launcher-grid a { + min-width: 0; display: grid; - grid-template-columns: auto 1fr; + grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; - gap: 2px 8px; - padding: 9px 11px; + gap: 3px 10px; + padding: 14px; border: 1px solid var(--rc-line); - border-radius: 11px; + border-radius: 12px; background: rgba(255, 255, 255, 0.025); - color: var(--rc-muted); - text-align: left; - cursor: pointer; + color: var(--rc-text); + text-decoration: none; + transition: + border-color 140ms ease, + background 140ms ease, + transform 140ms ease; } -.rc-cloud-workbench-node .rc-cloud-dot { +.rc-cloud-node-launcher-grid a:hover { + border-color: var(--rc-line-strong); + background: rgba(255, 255, 255, 0.045); + transform: translateY(-1px); +} + +.rc-cloud-node-launcher-grid a:focus-visible { + outline: 2px solid var(--rc-cyan); + outline-offset: 2px; +} + +.rc-cloud-node-launcher-grid .rc-cloud-dot { grid-row: 1 / 3; } -.rc-cloud-workbench-node b { +.rc-cloud-node-launcher-grid strong, +.rc-cloud-node-launcher-grid small { overflow: hidden; - color: var(--rc-text); - font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } -.rc-cloud-workbench-node small { +.rc-cloud-node-launcher-grid small { color: var(--rc-faint); - font-size: 9px; -} - -.rc-cloud-workbench-node.is-active { - border-color: rgba(92, 225, 230, 0.45); - background: rgba(92, 225, 230, 0.08); - box-shadow: inset 0 0 0 1px rgba(92, 225, 230, 0.06); -} - -.rc-cloud-workbench-node:disabled { - cursor: not-allowed; - opacity: 0.48; -} - -.rc-cloud-workbench-frame { - min-height: 0; - overflow: hidden; - border: 1px solid var(--rc-line-strong); - border-radius: 17px; - background: #08090b; - box-shadow: 0 24px 80px rgba(0, 0, 0, 0.34); + font-size: 10px; } -.rc-cloud-workbench-frame iframe { - width: 100%; - height: 100%; - min-height: 560px; - display: block; - border: 0; - background: #08090b; +.rc-cloud-node-launcher-grid b { + grid-column: 3; + grid-row: 1 / 3; + color: var(--rc-muted); + font-size: 11px; } .rc-cloud-profile, @@ -2335,19 +2350,24 @@ width: 100%; } - .rc-cloud-workbench { - min-height: calc(100dvh - 124px); - grid-template-rows: auto auto minmax(520px, 1fr); + .rc-cloud-product-handoff { + min-height: calc(100dvh - 136px); + padding: 28px 18px; } - .rc-cloud-workbench-head p { - display: none; + .rc-cloud-node-launcher { + grid-template-columns: 1fr; + padding: 22px 18px; + } + + .rc-cloud-node-launcher-grid a { + grid-template-columns: auto minmax(0, 1fr); } - .rc-cloud-workbench-frame, - .rc-cloud-workbench-frame iframe { - min-height: 520px; - border-radius: 12px; + .rc-cloud-node-launcher-grid b { + grid-column: 2; + grid-row: 3; + margin-top: 4px; } .rc-cloud-consent-actions { diff --git a/site/src/app-shell.test.ts b/site/src/app-shell.test.ts index 51a1fcf..33eb5b8 100644 --- a/site/src/app-shell.test.ts +++ b/site/src/app-shell.test.ts @@ -324,6 +324,7 @@ describe("hosted account shell", () => { let organizationCreated = false; let organizationName = "Mühendislik Lab"; let nodeName = "Studio Mac"; + const productHandoffs: string[] = []; const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const request = input instanceof Request ? input : new Request(new URL(String(input), location.origin), init); const url = new URL(request.url); @@ -536,7 +537,7 @@ describe("hosted account shell", () => { }); vi.stubGlobal("fetch", fetchMock); - mountAccountShell(); + mountAccountShell((href) => productHandoffs.push(href)); await vi.waitFor(() => { expect(document.body.textContent).toContain("Connect the computer that will run your agents"); @@ -609,22 +610,21 @@ describe("hosted account shell", () => { expect(document.activeElement).toBe(openNode); document.querySelector('.rc-cloud-primary--bottom [data-route="sessions"]')?.click(); - await vi.waitFor(() => expect(document.body.textContent).toContain("Choose the Node that owns the repository")); - const sessionsWorkbench = document.querySelector('iframe[title="Sessions on Studio Mac"]'); - expect(sessionsWorkbench?.getAttribute("src")).toBe( - `/terminal/sessions?enroll=${HOST_ID}&embed=1&context=${ORGANIZATION_ID}`, + await vi.waitFor(() => + expect(productHandoffs.at(-1)).toBe(`/terminal/sessions?enroll=${HOST_ID}&context=${ORGANIZATION_ID}`), + ); + expect(document.body.textContent).toContain("Opening Sessions"); + expect(document.querySelector("iframe")).toBeNull(); + expect(document.querySelector("a[data-product-handoff]")?.getAttribute("href")).toBe( + `/terminal/sessions?enroll=${HOST_ID}&context=${ORGANIZATION_ID}`, ); - expect(document.querySelector('[aria-label="Execution Node"]')).not.toBeNull(); - expect(document.querySelector('a[aria-label^="Open Sessions on"]')).toBeNull(); document.querySelector('.rc-cloud-primary--bottom [data-route="automations"]')?.click(); - await vi.waitFor(() => expect(document.body.textContent).toContain("Choose the Node that will execute each Run")); - const automationsWorkbench = document.querySelector('iframe[title="Automations on Studio Mac"]'); - expect(automationsWorkbench?.getAttribute("src")).toBe( - `/terminal/automations?enroll=${HOST_ID}&embed=1&context=${ORGANIZATION_ID}`, + await vi.waitFor(() => + expect(productHandoffs.at(-1)).toBe(`/terminal/automations?enroll=${HOST_ID}&context=${ORGANIZATION_ID}`), ); - expect(document.querySelector('[aria-label="Execution Node"]')).not.toBeNull(); - expect(document.querySelector('a[aria-label^="Open Automations on"]')).toBeNull(); + expect(document.body.textContent).toContain("Opening Automations"); + expect(document.querySelector("iframe")).toBeNull(); expect(document.querySelector("#organization-dialog")).toBeNull(); document.querySelector('[data-route="organization"]')?.click(); @@ -958,6 +958,88 @@ describe("hosted account shell", () => { expect(inventoryAttempts).toBe(2); }); + test("keeps multi-Node routing as a single explicit choice before opening the full product surface", async () => { + history.replaceState(null, "", "/app/sessions"); + const secondHostId = "00000000-0000-4000-8000-000000000004"; + const productHandoffs: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(new URL(String(input), location.origin), init); + const url = new URL(request.url); + if (url.pathname === "/api/v1/meta/providers") { + return json({ email_password: true, passkey: false, github: false, google: false, mode: "local_dev" }); + } + if (url.pathname === "/api/auth/get-session") { + return json({ + session: { id: "session-1" }, + user: { id: "user-1", name: "Ada", email: "ada@example.test", emailVerified: true }, + }); + } + if (url.pathname === "/api/v1/account/bootstrap") { + return json({ + user: { id: "user-1", name: "Ada", email: "ada@example.test" }, + contexts: [ + { + id: PERSONAL_ID, + kind: "personal", + slug: "personal-user-1", + name: "Personal", + plan: "free", + role: "owner", + }, + ], + }); + } + if (url.pathname === `/api/v1/orgs/${PERSONAL_ID}/hosts`) { + return json({ + hosts: [ + { id: HOST_ID, name: "Studio Mac", slug: "studio-mac" }, + { id: secondHostId, name: "Build Mac", slug: "build-mac" }, + ].map((host) => ({ + ...host, + organizationId: PERSONAL_ID, + status: "online", + tokenVersion: 1, + provisioningSagaId: `saga-${host.id}`, + agentVersion: "1.4.2", + lastSeenAt: "2026-07-19T00:00:00.000Z", + createdAt: "2026-07-18T00:00:00.000Z", + heartbeatState: "ready", + capabilities: ["terminal.v1", "relay.v1", "managed-device-enrollment.v1"], + revision: 1, + })), + }); + } + if ( + url.pathname === `/api/v1/hosts/${HOST_ID}/status` || + url.pathname === `/api/v1/hosts/${secondHostId}/status` + ) { + return json({ + host: {}, + relay: { + status: { hostOnline: true, activeDevices: 1 }, + route: { id: `route-${url.pathname}`, label: "Node", deviceCount: 1 }, + connection: { path: "/v1/connect", protocolVersion: 1 }, + }, + }); + } + throw new Error(`Unexpected request: ${request.method} ${url.pathname}`); + }), + ); + + mountAccountShell((href) => productHandoffs.push(href)); + + await vi.waitFor(() => expect(document.body.textContent).toContain("Where should Sessions open?")); + expect(productHandoffs).toEqual([]); + expect(document.querySelector("iframe")).toBeNull(); + const choices = Array.from(document.querySelectorAll(".rc-cloud-node-launcher-grid a")); + expect(choices.map((choice) => choice.getAttribute("href"))).toEqual([ + `/terminal/sessions?enroll=${HOST_ID}&context=${PERSONAL_ID}`, + `/terminal/sessions?enroll=${secondHostId}&context=${PERSONAL_ID}`, + ]); + }); + test("does not report a Node offline when only its status request failed", async () => { history.replaceState(null, "", "/app/sessions"); vi.stubGlobal( diff --git a/site/src/app-shell.ts b/site/src/app-shell.ts index fb7273a..8245897 100644 --- a/site/src/app-shell.ts +++ b/site/src/app-shell.ts @@ -19,6 +19,7 @@ import { type ProductRoute = "sessions" | "automations" | "agents" | "organization" | "account" | "people" | "activate" | "invite" | "reset"; +type ProductSurfaceNavigator = (href: string) => void; type AuthMode = "sign-in" | "sign-up" | "reset-request"; type CloudIconName = | "chevron-right" @@ -578,6 +579,7 @@ function safeDocumentUrl(value: string): string | undefined { class AccountShell { private readonly root: HTMLElement; + private readonly navigateToProductSurface: ProductSurfaceNavigator; private route = routeFromPath(location.pathname); private authMode: AuthMode; private providers: AuthProviders = { @@ -616,7 +618,7 @@ class AccountShell { private hostAccessError?: string; private hostInventoryState: "idle" | "loading" | "ready" | "error" = "idle"; private hostInventoryError?: string; - private productHostId?: string; + private pendingProductHandoff?: string; private cloudDevices: CloudDevice[] = []; private cloudDevicesState: "idle" | "loading" | "ready" | "error" = "idle"; private cloudDevicesError?: string; @@ -651,7 +653,8 @@ class AccountShell { private organizationDraft = { name: "", slug: "" }; private organizationSlugEdited = false; - constructor() { + constructor(navigateToProductSurface: ProductSurfaceNavigator) { + this.navigateToProductSurface = navigateToProductSurface; this.authMode = consumeRequestedAuthMode(); document.documentElement.classList.add("rc-account-mode"); document.body.replaceChildren(); @@ -851,9 +854,6 @@ class AccountShell { }); this.hosts = hosts; this.hostStatuses = hostStatuses; - if (!this.productHostId || !hosts.some((host) => host.id === this.productHostId)) { - this.productHostId = hosts.find((host) => this.hostIsOnline(host))?.id ?? hosts[0]?.id; - } this.members = members; this.membersState = !shouldLoadMembers ? "idle" : membersResult.status === "fulfilled" ? "ready" : "error"; this.membersError = @@ -1101,6 +1101,25 @@ class AccountShell { queueMicrotask(() => dialog.querySelector('input[name="name"]')?.focus()); } } + this.scheduleProductHandoff(); + } + + private scheduleProductHandoff(): void { + const link = this.root.querySelector("a[data-product-handoff]"); + const href = link?.getAttribute("href"); + if (!link || !href) { + this.pendingProductHandoff = undefined; + return; + } + if (this.pendingProductHandoff === href) return; + this.pendingProductHandoff = href; + queueMicrotask(() => { + if (!link.isConnected || this.root.querySelector("a[data-product-handoff]") !== link) { + if (this.pendingProductHandoff === href) this.pendingProductHandoff = undefined; + return; + } + this.navigateToProductSurface(href); + }); } private renderContent(): string { @@ -1477,9 +1496,8 @@ class AccountShell { return `${requestLabel}`; } - private terminalDestination(destination: "sessions" | "automations", hostId: string, embedded = false): string { + private terminalDestination(destination: "sessions" | "automations", hostId: string): string { const query = new URLSearchParams({ enroll: hostId }); - if (embedded) query.set("embed", "1"); if (this.context) query.set("context", this.context.id); return `/terminal/${destination}?${query.toString()}`; } @@ -1506,22 +1524,25 @@ class AccountShell { this.hostSupportsManagedEnrollment(host) && (this.context?.kind !== "organization" || this.hostGrantIsCurrent(this.hostAccess.get(host.id))), ); - const selected = usableHosts.find((host) => host.id === this.productHostId) ?? usableHosts[0]; - const tabs = this.hosts - .map((host) => { - const usable = usableHosts.some((candidate) => candidate.id === host.id); - const active = selected?.id === host.id; - return ``; - }) - .join(""); + const selected = usableHosts[0]; if (!selected) { return `${this.pageHeader(eyebrow, title, copy, ``)}
Access required

No Node can open this workspace yet

Online Nodes need a current browser grant and managed terminal capability.

Review Nodes
`; } - const src = this.terminalDestination(destination, selected.id, true); - return `
-
${escapeHtml(eyebrow)}

${escapeHtml(title)}

${escapeHtml(copy)}

-
${tabs}
-
+ if (usableHosts.length > 1) { + return `${this.pageHeader(eyebrow, title, copy)}
Execution Node

Where should ${escapeHtml(title)} open?

Choose the computer that owns this work. The selected Node opens as the full RoamCode workspace, never inside a second app frame.

`; + } + const href = this.terminalDestination(destination, selected.id); + return `
+ + ${escapeHtml(selected.name)} +

Opening ${escapeHtml(title)}

+

Loading the encrypted Node workspace as one RoamCode surface.

+ Open ${escapeHtml(title)}
`; } const statusUnknown = !noHosts && this.hosts.some((host) => !this.hostStatuses.has(host.id)); @@ -1918,19 +1939,6 @@ class AccountShell { if (action === "retry-managed-host-devices") return this.reloadManagedHostDevices(); if (action === "retry-people") return this.refreshPeopleData(); if (action === "retry-organization-settings") return this.loadOrganizationSettings(); - if (action === "select-product-node") { - const hostId = target.dataset.hostId; - const host = this.hosts.find((candidate) => candidate.id === hostId); - if ( - !host || - !this.hostIsOnline(host) || - !this.hostSupportsManagedEnrollment(host) || - (this.context?.kind === "organization" && !this.hostGrantIsCurrent(this.hostAccess.get(host.id))) - ) - return; - this.productHostId = host.id; - return this.render(); - } if (action === "prepare-member-remove") { if (this.busy?.startsWith("member:")) return; const userId = target.dataset.userId; @@ -2789,6 +2797,8 @@ class AccountShell { } } -export function mountAccountShell(): void { - void new AccountShell().start(); +export function mountAccountShell( + navigateToProductSurface: ProductSurfaceNavigator = (href) => window.location.replace(href), +): void { + void new AccountShell(navigateToProductSurface).start(); }