From e90f01960cc59b9f8c28935c84303c46095e18d1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:45:27 -0400 Subject: [PATCH 1/8] feat(desktop): Connections panel + Account page + provider-aware avatar Post-login desktop UX for the accounts initiative. New renderer preload bridge for the account daemon actions (status/startLogin/pollLogin/signOut only; getToken not exposed). Provider-aware sidebar avatar routing to a new /account page (rich signed-out card + signed-in identity/machines/sessions + GitHub repo bridge). The three top-bar chips collapse into one Connections panel (Machines/Mobile/Web tabs); Machines extends RemoteTargetList with account machines merged. Graceful degrade when the directory Worker is not deployed. Verified by driving the renderer; screenshots in the proof drawer; typecheck + lint green. Co-Authored-By: Claude Opus 4.8 --- .../main/services/account/accountBridge.ts | 224 ++++++ .../src/main/services/ipc/registerIpc.ts | 47 ++ apps/desktop/src/preload/global.d.ts | 12 + apps/desktop/src/preload/preload.ts | 20 + apps/desktop/src/renderer/browserMock.ts | 82 +++ .../components/account/AccountPage.tsx | 695 ++++++++++++++++++ .../src/renderer/components/app/App.tsx | 22 +- .../components/app/ConnectionsPanel.tsx | 230 ++++++ .../src/renderer/components/app/TabNav.tsx | 82 ++- .../src/renderer/components/app/TopBar.tsx | 355 ++------- .../remoteTargets/AccountMachineRow.tsx | 154 ++++ .../remoteTargets/RemoteTargetList.tsx | 121 ++- .../remoteMachineModel.account.test.ts | 105 +++ .../remoteTargets/remoteMachineModel.ts | 99 ++- apps/desktop/src/renderer/lib/account.ts | 231 ++++++ apps/desktop/src/renderer/lib/accountLogin.ts | 125 ++++ .../src/renderer/lib/connectionsPanel.ts | 24 + apps/desktop/src/shared/ipc.ts | 6 + apps/desktop/src/shared/types/account.ts | 73 ++ apps/desktop/src/shared/types/index.ts | 1 + 20 files changed, 2376 insertions(+), 332 deletions(-) create mode 100644 apps/desktop/src/main/services/account/accountBridge.ts create mode 100644 apps/desktop/src/renderer/components/account/AccountPage.tsx create mode 100644 apps/desktop/src/renderer/components/app/ConnectionsPanel.tsx create mode 100644 apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx create mode 100644 apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts create mode 100644 apps/desktop/src/renderer/lib/account.ts create mode 100644 apps/desktop/src/renderer/lib/accountLogin.ts create mode 100644 apps/desktop/src/renderer/lib/connectionsPanel.ts create mode 100644 apps/desktop/src/shared/types/account.ts diff --git a/apps/desktop/src/main/services/account/accountBridge.ts b/apps/desktop/src/main/services/account/accountBridge.ts new file mode 100644 index 000000000..b49bc2937 --- /dev/null +++ b/apps/desktop/src/main/services/account/accountBridge.ts @@ -0,0 +1,224 @@ +// Main-process bridge for the machine-owned ADE account (Clerk identity, #815). +// +// The renderer never holds the account bearer. This bridge owns the shared +// account auth service in the main process, exposes only the token-free surface +// to IPC (status/startLogin/pollLogin/cancelLogin/signOut), and performs the +// directory-Worker machine fetch here so the token stays in main. +// +// The service is process-global (keyed by secrets dir) and file-backed, so it +// stays consistent with the CLI daemon's own account session on disk. + +import { + getSharedAccountAuthService, + registerAccountConfigProjectRoot, +} from "../../../../../ade-cli/src/services/account/sharedAccountAuthService"; +import { resolveMachineAdeLayout } from "../../../../../ade-cli/src/services/projects/machineLayout"; +import type { + AccountAuthStatus, + AccountLoginPollResult, + AccountLoginStartResult, +} from "../../../../../ade-cli/src/services/account/accountAuthService"; +import { createProjectSecretService } from "../secrets/projectSecretService"; +import type { + AdeAccountMachine, + AdeAccountMachineEndpoint, + AdeAccountMachinesResult, + AdeAccountStatus, +} from "../../../shared/types"; + +const DIRECTORY_URL_SECRET = "ACCOUNT_DIRECTORY_URL"; +const MACHINES_FETCH_TIMEOUT_MS = 8_000; + +type AccountBridgeOptions = { + /** Resolves the active project root so CLERK_* project secrets win config. */ + getProjectRoot: () => string | null; + logger?: { + info(message: string, meta?: Record): void; + warn(message: string, meta?: Record): void; + }; +}; + +function readProjectSecret(projectRoot: string | null, name: string): string | null { + if (!projectRoot) return null; + try { + return createProjectSecretService(projectRoot).get({ name }).value.trim() || null; + } catch { + return null; + } +} + +/** Best-effort: is machine sign-in configured (CLERK issuer + client id present)? */ +function isLoginConfigured(projectRoot: string | null): boolean { + const issuer = + readProjectSecret(projectRoot, "CLERK_ISSUER") ?? process.env.CLERK_ISSUER?.trim() ?? ""; + const clientId = + readProjectSecret(projectRoot, "CLERK_OAUTH_CLIENT_ID") + ?? process.env.CLERK_OAUTH_CLIENT_ID?.trim() + ?? ""; + return Boolean(issuer && clientId); +} + +function resolveDirectoryBaseUrl(projectRoot: string | null): string | null { + const raw = + readProjectSecret(projectRoot, DIRECTORY_URL_SECRET) + ?? process.env.ADE_ACCOUNT_DIRECTORY_URL?.trim() + ?? ""; + if (!raw) return null; + return raw.replace(/\/+$/, ""); +} + +function toAccountStatus( + status: AccountAuthStatus, + configured: boolean, +): AdeAccountStatus { + return { + signedIn: status.signedIn, + userId: status.userId, + email: status.email, + name: status.name, + expiresAt: status.expiresAt, + // The merged daemon status does not yet carry provider/image; leave null so + // the renderer degrades to a GitHub-creds image and a monogram. + provider: null, + imageUrl: null, + configured, + }; +} + +function mapMachine(raw: unknown): AdeAccountMachine | null { + if (!raw || typeof raw !== "object") return null; + const value = raw as Record; + const machineKey = typeof value.machineKey === "string" ? value.machineKey : null; + if (!machineKey) return null; + const endpoints = Array.isArray(value.reachableEndpoints) + ? value.reachableEndpoints + .map((entry): AdeAccountMachineEndpoint | null => { + if (!entry || typeof entry !== "object") return null; + const e = entry as Record; + const kind = e.kind; + if (kind !== "lan" && kind !== "tailnet" && kind !== "relay") return null; + return { + kind, + url: typeof e.url === "string" ? e.url : undefined, + host: typeof e.host === "string" ? e.host : undefined, + port: typeof e.port === "number" ? e.port : undefined, + }; + }) + .filter((entry): entry is AdeAccountMachineEndpoint => entry != null) + : []; + return { + machineKey, + deviceId: typeof value.deviceId === "string" ? value.deviceId : null, + name: typeof value.name === "string" ? value.name : null, + platform: typeof value.platform === "string" ? value.platform : null, + deviceType: typeof value.deviceType === "string" ? value.deviceType : null, + reachableEndpoints: endpoints, + lastSeenAt: typeof value.lastSeenAt === "number" ? value.lastSeenAt : null, + online: value.online === true, + }; +} + +export type AccountBridge = { + status(): AdeAccountStatus; + startLogin(): Promise; + pollLogin(sessionId: string): Promise; + cancelLogin(sessionId: string): void; + signOut(): AdeAccountStatus; + listMachines(): Promise; +}; + +export function createAccountBridge(options: AccountBridgeOptions): AccountBridge { + const secretsDir = resolveMachineAdeLayout().secretsDir; + + const service = () => + getSharedAccountAuthService({ + secretsDir, + projectRoots: () => { + const root = options.getProjectRoot(); + return root ? [root] : []; + }, + logger: options.logger, + }); + + const configured = () => isLoginConfigured(options.getProjectRoot()); + + return { + status: () => toAccountStatus(service().getStatus(), configured()), + + startLogin: () => { + // Prioritize the active project's CLERK_* secrets for config resolution. + const root = options.getProjectRoot(); + if (root) registerAccountConfigProjectRoot(root, secretsDir, { prioritize: true }); + return service().startLogin(); + }, + + pollLogin: (sessionId: string) => service().pollLogin(sessionId), + + cancelLogin: (sessionId: string) => service().cancelLogin(sessionId), + + signOut: () => toAccountStatus(service().signOut(), configured()), + + listMachines: async (): Promise => { + const svc = service(); + if (!svc.getStatus().signedIn) { + return { state: "signed_out", machines: [], message: null }; + } + const projectRoot = options.getProjectRoot(); + const baseUrl = resolveDirectoryBaseUrl(projectRoot); + if (!baseUrl) { + return { + state: "not_configured", + machines: [], + message: "Machine directory isn't configured on this machine yet.", + }; + } + let token: string; + try { + token = await svc.getAccessToken(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // A missing/expired session reads as signed-out to the UI. + if (/not signed in|session expired/i.test(message)) { + return { state: "signed_out", machines: [], message: null }; + } + return { state: "unavailable", machines: [], message }; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), MACHINES_FETCH_TIMEOUT_MS); + try { + const response = await fetch(`${baseUrl}/account/machines`, { + method: "GET", + headers: { accept: "application/json", authorization: `Bearer ${token}` }, + signal: controller.signal, + }); + if (!response.ok) { + return { + state: "unavailable", + machines: [], + message: `Machine directory returned ${response.status}.`, + }; + } + const payload = (await response.json().catch(() => null)) as + | { machines?: unknown[] } + | null; + const machines = Array.isArray(payload?.machines) + ? payload!.machines + .map(mapMachine) + .filter((entry): entry is AdeAccountMachine => entry != null) + : []; + return { state: "ok", machines, message: null }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + options.logger?.warn("account.machines_fetch_failed", { error: message }); + return { + state: "unavailable", + machines: [], + message: controller.signal.aborted ? "Machine directory timed out." : message, + }; + } finally { + clearTimeout(timer); + } + }, + }; +} diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 00f294370..b0203a4c6 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -186,6 +186,10 @@ import type { GitHubAutolink, GitHubRepoRef, GitHubStatus, + AdeAccountStatus, + AdeAccountLoginStart, + AdeAccountLoginPoll, + AdeAccountMachinesResult, CreateLaneFromPrBranchArgs, CreateLaneFromPrBranchPreflightResult, CreateLaneFromPrBranchResult, @@ -568,6 +572,7 @@ import { } from "../transcription/transcriptionService"; import type { createAiIntegrationService } from "../ai/aiIntegrationService"; import { fetchAdeLatestRelease, type createGithubService } from "../github/githubService"; +import { createAccountBridge } from "../account/accountBridge"; import type { createPrService } from "../prs/prService"; import type { createPrPollingService } from "../prs/prPollingService"; import type { createQueueLandingService } from "../prs/queueLandingService"; @@ -8470,6 +8475,48 @@ export function registerIpc({ return ctx.feedbackReporterService.list(); }); + // Machine-owned ADE account (Clerk identity, #815). The bridge owns the auth + // service in main and only ever exposes the token-free surface to the + // renderer — getToken is deliberately never wired here. + const accountBridge = createAccountBridge({ + getProjectRoot: () => getCtx().project.rootPath ?? null, + logger: { + info: (message, meta) => getCtx().logger.info(message, meta), + warn: (message, meta) => getCtx().logger.warn(message, meta), + }, + }); + + ipcMain.handle(IPC.accountStatus, async (): Promise => { + return accountBridge.status(); + }); + + ipcMain.handle(IPC.accountStartLogin, async (): Promise => { + return accountBridge.startLogin(); + }); + + ipcMain.handle( + IPC.accountPollLogin, + async (_event, arg: { sessionId?: string }): Promise => { + return accountBridge.pollLogin(arg?.sessionId ?? ""); + }, + ); + + ipcMain.handle( + IPC.accountCancelLogin, + async (_event, arg: { sessionId?: string }): Promise => { + accountBridge.cancelLogin(arg?.sessionId ?? ""); + return accountBridge.status(); + }, + ); + + ipcMain.handle(IPC.accountSignOut, async (): Promise => { + return accountBridge.signOut(); + }); + + ipcMain.handle(IPC.accountListMachines, async (): Promise => { + return accountBridge.listMachines(); + }); + const ensurePrMutationContext = (): AppContextWith<"prService" | "prPollingService"> => { const ctx = getCtx(); requireAppContextServices(ctx, ["prService", "prPollingService"] as const); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 8650fe3c6..5a91373a3 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -302,6 +302,10 @@ import type { GitHubAutolink, GitHubRepoRef, GitHubStatus, + AdeAccountStatus, + AdeAccountLoginStart, + AdeAccountLoginPoll, + AdeAccountMachinesResult, CreateLaneFromPrBranchArgs, CreateLaneFromPrBranchPreflightResult, CreateLaneFromPrBranchResult, @@ -1967,6 +1971,14 @@ declare global { ) => Promise; onStatusChanged: (cb: (status: GitHubStatus) => void) => () => void; }; + account: { + status: () => Promise; + startLogin: () => Promise; + pollLogin: (args: { sessionId: string }) => Promise; + cancelLogin: (args: { sessionId: string }) => Promise; + signOut: () => Promise; + listMachines: () => Promise; + }; prs: { createFromLane: (args: CreatePrFromLaneArgs) => Promise; linkToLane: (args: LinkPrToLaneArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index cf33f40e7..b131df30e 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -222,6 +222,10 @@ import type { GitHubAutolink, GitHubRepoRef, GitHubStatus, + AdeAccountStatus, + AdeAccountLoginStart, + AdeAccountLoginPoll, + AdeAccountMachinesResult, CreateLaneFromPrBranchArgs, CreateLaneFromPrBranchPreflightResult, CreateLaneFromPrBranchResult, @@ -7577,6 +7581,22 @@ contextBridge.exposeInMainWorld("ade", { }; }, }, + // Machine-owned ADE account (Clerk identity). Token-free surface only — the + // raw bearer stays in the main process and is never exposed here. + account: { + status: (): Promise => + ipcRenderer.invoke(IPC.accountStatus), + startLogin: (): Promise => + ipcRenderer.invoke(IPC.accountStartLogin), + pollLogin: (args: { sessionId: string }): Promise => + ipcRenderer.invoke(IPC.accountPollLogin, args), + cancelLogin: (args: { sessionId: string }): Promise => + ipcRenderer.invoke(IPC.accountCancelLogin, args), + signOut: (): Promise => + ipcRenderer.invoke(IPC.accountSignOut), + listMachines: (): Promise => + ipcRenderer.invoke(IPC.accountListMachines), + }, prs: { createFromLane: async (args: CreatePrFromLaneArgs): Promise => callProjectRuntimeActionOr("pr", "createFromLane", { args }, () => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 3d3d45aa8..14860734a 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3178,6 +3178,88 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { day: new Date().toISOString().slice(0, 10), }), }, + // Machine-owned ADE account (Clerk identity). The dev browser preview toggles + // signed-in vs signed-out via localStorage `ade.mock.account` = "out". + account: (() => { + const signedOut = () => { + try { + return window.localStorage.getItem("ade.mock.account") === "out"; + } catch { + return false; + } + }; + const signedInStatus = { + signedIn: true, + userId: "user_2xMockAccount", + email: "arul@ade.dev", + name: "Arul Sharma", + expiresAt: new Date(Date.now() + 3_600_000).toISOString(), + provider: "github" as const, + imageUrl: null, + configured: true, + }; + const signedOutStatus = { + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, + configured: true, + }; + const status = () => (signedOut() ? signedOutStatus : signedInStatus); + return { + status: async () => status(), + startLogin: async () => ({ + sessionId: "mock-session", + authorizeUrl: "https://accounts.ade.dev/oauth/authorize?mock=1", + expiresAt: new Date(Date.now() + 300_000).toISOString(), + }), + pollLogin: async () => ({ + status: "signed_in" as const, + message: null, + authStatus: signedInStatus, + }), + cancelLogin: async () => status(), + signOut: async () => signedOutStatus, + listMachines: async () => { + if (signedOut()) { + return { state: "signed_out" as const, machines: [], message: null }; + } + return { + state: "ok" as const, + message: null, + machines: [ + { + machineKey: "mk_studio", + deviceId: "dev_studio", + name: "Studio", + platform: "darwin", + deviceType: "desktop", + reachableEndpoints: [ + { kind: "tailnet" as const, host: "100.92.14.3", port: 22 }, + ], + lastSeenAt: Date.now() - 45_000, + online: true, + }, + { + machineKey: "mk_mini", + deviceId: "dev_mini", + name: "Mac mini", + platform: "darwin", + deviceType: "desktop", + reachableEndpoints: [ + { kind: "relay" as const, url: "wss://relay.ade.dev/mini" }, + ], + lastSeenAt: Date.now() - 6 * 3_600_000, + online: false, + }, + ], + }; + }, + }; + })(), app: { ping: resolved("pong" as const), getInfo: resolved({ diff --git a/apps/desktop/src/renderer/components/account/AccountPage.tsx b/apps/desktop/src/renderer/components/account/AccountPage.tsx new file mode 100644 index 000000000..200382b5e --- /dev/null +++ b/apps/desktop/src/renderer/components/account/AccountPage.tsx @@ -0,0 +1,695 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + AppleLogo, + ArrowRight, + CircleNotch, + DesktopTower, + DeviceMobile, + EnvelopeSimple, + GithubLogo, + GoogleLogo, + Laptop, + ShieldCheck, + SignOut, + Sparkle, + Users, + WarningCircle, + X, +} from "@phosphor-icons/react"; +import type { CSSProperties } from "react"; +import type { GitHubStatus } from "../../../shared/types"; +import { + COLORS, + RADII, + SANS_FONT, + cardStyle, + dangerButton, + inlineBadge, + outlineButton, + primaryButton, +} from "../lanes/laneDesignTokens"; +import { + accountAvatarImage, + accountInitials, + accountProviderCaption, + providerTint, + publishAccountStatus, + useAccountStatus, + type AdeAccountMachine, + type AdeAccountMachinesResult, + type AdeAccountStatus, +} from "../../lib/account"; +import { useAccountLogin } from "../../lib/accountLogin"; +import { openConnectionsPanel } from "../../lib/connectionsPanel"; + +const REPO_BRIDGE_DISMISS_KEY = "ade.account.repoBridgeDismissed.v1"; + +function firstName(name: string | null): string { + return name?.trim().split(/\s+/)[0] ?? "there"; +} + +function readDismissed(key: string): boolean { + try { + return window.localStorage.getItem(key) === "1"; + } catch { + return false; + } +} + +function writeDismissed(key: string): void { + try { + window.localStorage.setItem(key, "1"); + } catch { + // localStorage may be unavailable in hardened contexts. + } +} + +function relativeLastSeen(lastSeenAt: number | null): string { + if (!lastSeenAt) return "Never seen"; + const deltaMs = Date.now() - lastSeenAt; + if (deltaMs < 60_000) return "Active just now"; + const minutes = Math.floor(deltaMs / 60_000); + if (minutes < 60) return `Seen ${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `Seen ${hours}h ago`; + const days = Math.floor(hours / 24); + return `Seen ${days}d ago`; +} + +function machineRouteHint(machine: AdeAccountMachine): string | null { + const endpoint = machine.reachableEndpoints[0]; + if (!endpoint) return null; + if (endpoint.kind === "relay") return "relay"; + const host = endpoint.host ?? endpoint.url ?? ""; + return host ? `${endpoint.kind} · ${host}` : endpoint.kind; +} + +const sectionLabelStyle: CSSProperties = { + fontFamily: SANS_FONT, + fontSize: 11, + fontWeight: 600, + letterSpacing: "0.06em", + textTransform: "uppercase", + color: COLORS.textMuted, +}; + +// --------------------------------------------------------------------------- +// Signed-out: the rich sign-in card. +// --------------------------------------------------------------------------- + +function SignInCard({ + configured, + onSignedIn, +}: { + configured: boolean; + onSignedIn: () => void; +}) { + const { phase, error, beginLogin, cancel } = useAccountLogin({ + onSignedIn: () => onSignedIn(), + }); + const busy = phase === "starting" || phase === "awaiting"; + + const secondary: Array<{ label: string; icon: JSX.Element }> = [ + { label: "Email", icon: }, + { label: "Apple", icon: }, + { label: "Google", icon: }, + ]; + + return ( +
+
+ ADE +
+ Sign in to ADE +
+
+ One identity for your machines, mobile, and web sessions — so they find each other wherever you are. +
+
+ + {!configured ? ( +
+ + + Account sign-in isn't set up on this machine yet. You can still pair machines, phones, and web + clients from Connections. + +
+ ) : null} + +
+ + +
+
+ or continue with +
+
+ +
+ {secondary.map((option) => ( + + ))} +
+
+ + {phase === "awaiting" ? ( +
+ + + Finish signing in in your browser… + + +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + +
+ + Local pairing works without an account. +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Signed-in: machines-at-a-glance. +// --------------------------------------------------------------------------- + +function MachinesGlance() { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + const api = (window.ade as typeof window.ade & { + account?: { listMachines: () => Promise }; + }).account; + if (!api?.listMachines) { + setResult({ state: "unavailable", machines: [], message: null }); + setLoading(false); + return; + } + setLoading(true); + try { + setResult(await api.listMachines()); + } catch { + setResult({ state: "unavailable", machines: [], message: null }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const machines = result?.machines ?? []; + const onlineCount = machines.filter((m) => m.online).length; + + let summary: string; + if (loading) summary = "Checking your machines…"; + else if (result?.state === "ok") { + summary = machines.length === 0 + ? "No machines registered yet" + : `${onlineCount} online · ${machines.length} registered`; + } else if (result?.state === "not_configured") { + summary = "Machine directory isn't set up yet"; + } else if (result?.state === "signed_out") { + summary = "Sign in to see your machines"; + } else { + summary = "Can't reach the machine directory"; + } + + return ( +
+
+
+ + + +
+
+ Your machines +
+
{summary}
+
+
+ +
+ + {result?.state === "ok" && machines.length > 0 ? ( +
+ {machines.slice(0, 4).map((machine) => ( +
+ + + + {machine.name ?? "Unnamed machine"} + + + {machine.online ? machineRouteHint(machine) ?? "online" : relativeLastSeen(machine.lastSeenAt)} + +
+ ))} +
+ ) : null} + + {result?.state === "unavailable" || result?.state === "not_configured" ? ( +
+ + {result.state === "not_configured" + ? "Local machines still connect from Connections — the shared directory just isn't live yet." + : "Local machines still connect from Connections while the directory reconnects."} + + {result.state === "unavailable" ? ( + + ) : null} +
+ ) : null} +
+ ); +} + +// --------------------------------------------------------------------------- +// Signed-in: sessions (current session + clearly-marked cross-device seam). +// --------------------------------------------------------------------------- + +function SessionsCard({ onSignOut, signingOut }: { onSignOut: () => void; signingOut: boolean }) { + return ( +
+
+
Sessions
+
+
+ +
+
This machine
+
Signed in here
+
+ Current +
+
+ + Signing out everywhere arrives with cross-device sessions. For now, this signs out on this machine. + + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Page. +// --------------------------------------------------------------------------- + +export function AccountPage() { + const navigate = useNavigate(); + const { status, refresh } = useAccountStatus(); + const [githubStatus, setGithubStatus] = useState(null); + const [signingOut, setSigningOut] = useState(false); + const [justSignedIn, setJustSignedIn] = useState(false); + const [repoBridgeDismissed, setRepoBridgeDismissed] = useState(() => readDismissed(REPO_BRIDGE_DISMISS_KEY)); + + useEffect(() => { + let cancelled = false; + void window.ade.github + ?.getStatus?.() + .then((next) => { + if (!cancelled) setGithubStatus(next); + }) + .catch(() => {}); + const unsubscribe = window.ade.github?.onStatusChanged?.((next) => setGithubStatus(next)); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, []); + + const githubConnected = Boolean(githubStatus?.connected); + const avatarImage = accountAvatarImage(status, githubStatus?.userLogin ?? null); + const ringTint = providerTint(status, githubConnected); + const providerCaption = accountProviderCaption(status); + + const handleSignedIn = useCallback(() => { + setJustSignedIn(true); + void refresh(); + }, [refresh]); + + const handleSignOut = useCallback(async () => { + const api = (window.ade as typeof window.ade & { + account?: { signOut: () => Promise }; + }).account; + if (!api?.signOut) return; + setSigningOut(true); + try { + const next = await api.signOut(); + publishAccountStatus(next); + setJustSignedIn(false); + } finally { + setSigningOut(false); + } + }, []); + + const dismissRepoBridge = useCallback(() => { + writeDismissed(REPO_BRIDGE_DISMISS_KEY); + setRepoBridgeDismissed(true); + }, []); + + const showRepoBridge = useMemo( + () => status.signedIn && !githubConnected && !repoBridgeDismissed, + [status.signedIn, githubConnected, repoBridgeDismissed], + ); + + return ( +
+
+ {!status.signedIn ? ( +
+ +
+ ) : ( + <> + {justSignedIn ? ( +
+ +
+
+ You're in, {firstName(status.name)}. +
+
+ Here are your machines — connect one to pick up where you left off. +
+
+ +
+ ) : null} + + {/* Identity header */} +
+ + {avatarImage ? ( + + ) : ( + + {accountInitials(status)} + + )} + +
+
+ {status.name ?? status.email ?? "Your ADE account"} +
+ {status.email ? ( +
+ {status.email} +
+ ) : null} + {providerCaption ? ( +
{providerCaption}
+ ) : null} +
+
+ + {/* GitHub repo bridge — identity stays decoupled from repo connection */} + {showRepoBridge ? ( +
+ + + +
+
+ Connect your repos & PRs too? +
+
+ Your identity and your GitHub repo access stay separate — link it when you're ready. +
+
+ + +
+ ) : null} + + + + {/* Quick links to the other Connections surfaces */} +
+ + +
+ + void handleSignOut()} signingOut={signingOut} /> + + )} +
+
+ ); +} + +export default AccountPage; diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index 1ac6215fc..31bf9c2f7 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -91,6 +91,9 @@ const WorkspaceGraphPage = React.lazy(() => const PersonalChatsPage = React.lazy(() => import("../personalChats/PersonalChatsPage").then((m) => ({ default: m.PersonalChatsPage })) ); +const AccountPage = React.lazy(() => + import("../account/AccountPage").then((m) => ({ default: m.AccountPage })) +); const ctoRoute = createPreloadableRoute<{ active?: boolean }>(() => import("../cto/CtoPage").then((m) => ({ default: m.CtoPage })) ); @@ -691,6 +694,7 @@ function ProjectTabHost() { const lruRef = React.useRef([]); const [routesBySurfaceKey, setRoutesBySurfaceKey] = React.useState>({}); const isPersonalChatsRoute = location.pathname === "/chats" || location.pathname.startsWith("/chats/"); + const isAccountRoute = location.pathname === "/account" || location.pathname.startsWith("/account/"); const isExternalFilesRoute = location.pathname === "/files" && new URLSearchParams(location.search).has("externalPath"); const activeBinding = !showWelcome && activeProject?.rootPath ? bindingForProject(activeProject, activeProjectBinding) @@ -734,7 +738,10 @@ function ProjectTabHost() { }, [activeSurfaceKey]); React.useEffect(() => { - if (isPersonalChatsRoute) return; + // Machine-level routes (personal chats, account) are not project surfaces; + // the route-restore below would otherwise clobber them with the active + // project's stored route on load. + if (isPersonalChatsRoute || isAccountRoute) return; const previousSurfaceKey = previousActiveSurfaceKeyRef.current; if (previousSurfaceKey === activeSurfaceKey) return; const currentRoute = serializeStoredProjectRoute(location); @@ -758,7 +765,7 @@ function ProjectTabHost() { if (currentRoute !== nextRoute) { navigate(nextRoute, { replace: true }); } - }, [activeSurfaceKey, isPersonalChatsRoute, location, navigate, routesBySurfaceKey]); + }, [activeSurfaceKey, isAccountRoute, isPersonalChatsRoute, location, navigate, routesBySurfaceKey]); React.useEffect(() => { if (!activeSurfaceKey) return; @@ -871,7 +878,7 @@ function ProjectTabHost() { return GuardLoadingFallback; } - if (!isPersonalChatsRoute && (!activeProject || showWelcome || mountedProjects.length === 0)) { + if (!isPersonalChatsRoute && !isAccountRoute && (!activeProject || showWelcome || mountedProjects.length === 0)) { return ( @@ -907,7 +914,7 @@ function ProjectTabHost() { return ( ) : null} + {isAccountRoute ? ( + + + + + + ) : null} {transitionLabel ? : null}
); diff --git a/apps/desktop/src/renderer/components/app/ConnectionsPanel.tsx b/apps/desktop/src/renderer/components/app/ConnectionsPanel.tsx new file mode 100644 index 000000000..de2226fed --- /dev/null +++ b/apps/desktop/src/renderer/components/app/ConnectionsPanel.tsx @@ -0,0 +1,230 @@ +import { useCallback, useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + CaretRight, + DesktopTower, + DeviceMobile, + Globe, + UserCircle, +} from "@phosphor-icons/react"; +import type { CSSProperties } from "react"; +import type { + AdeAccountMachinesResult, + GitHubStatus, + RemoteRuntimeTarget, +} from "../../../shared/types"; +import { COLORS, SANS_FONT } from "../lanes/laneDesignTokens"; +import { RemoteTargetList } from "../remoteTargets/RemoteTargetList"; +import { SyncDevicesSection } from "../settings/SyncDevicesSection"; +import { + accountAvatarImage, + accountInitials, + providerTint, + useAccountStatus, +} from "../../lib/account"; +import type { ConnectionsPanelTab } from "../../lib/connectionsPanel"; + +type ConnectionsPanelProps = { + initialTab?: ConnectionsPanelTab; + onClose: () => void; + onDisconnectRequested?: (target: RemoteRuntimeTarget) => boolean | Promise; + onRemoveRequested?: (target: RemoteRuntimeTarget) => boolean | Promise; +}; + +const TABS: Array<{ key: ConnectionsPanelTab; label: string; icon: typeof DesktopTower }> = [ + { key: "machines", label: "Machines", icon: DesktopTower }, + { key: "mobile", label: "Mobile", icon: DeviceMobile }, + { key: "web", label: "Web client", icon: Globe }, +]; + +function tabStyle(active: boolean): CSSProperties { + return { + display: "inline-flex", + alignItems: "center", + gap: 6, + height: 32, + padding: "0 12px", + borderRadius: 8, + border: "none", + cursor: "pointer", + fontFamily: SANS_FONT, + fontSize: 12, + fontWeight: 600, + color: active ? COLORS.textPrimary : COLORS.textMuted, + background: active + ? "color-mix(in srgb, var(--color-fg) 10%, transparent)" + : "transparent", + transition: "background-color 120ms, color 120ms", + }; +} + +function AccountHeader({ githubStatus, onNavigate }: { githubStatus?: GitHubStatus | null; onNavigate: () => void }) { + const { status } = useAccountStatus(); + const githubConnected = Boolean(githubStatus?.connected); + const avatarImage = accountAvatarImage(status, githubStatus?.userLogin ?? null); + const ringTint = providerTint(status, githubConnected); + const [imgBroken, setImgBroken] = useState(false); + + const title = status.signedIn + ? status.name ?? status.email ?? "Your ADE account" + : "Sign in to ADE"; + const subtitle = status.signedIn + ? status.email ?? "Manage your account" + : "Bring your machines together under one identity"; + + return ( + + ); +} + +export function ConnectionsPanel({ + initialTab = "machines", + onClose, + onDisconnectRequested, + onRemoveRequested, +}: ConnectionsPanelProps) { + const navigate = useNavigate(); + const [tab, setTab] = useState(initialTab); + const [accountMachines, setAccountMachines] = useState(null); + const [githubStatus, setGithubStatus] = useState(null); + + useEffect(() => { + setTab(initialTab); + }, [initialTab]); + + useEffect(() => { + let cancelled = false; + void window.ade.github + ?.getStatus?.() + .then((next) => { + if (!cancelled) setGithubStatus(next); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + const loadAccountMachines = useCallback(async () => { + const api = (window.ade as typeof window.ade & { + account?: { listMachines: () => Promise }; + }).account; + if (!api?.listMachines) return; + try { + setAccountMachines(await api.listMachines()); + } catch { + setAccountMachines({ state: "unavailable", machines: [], message: null }); + } + }, []); + + // Fetch account machines up front (and refresh when the Machines tab opens) so + // the merged list is ready without blocking the local saved/discovered rows. + useEffect(() => { + void loadAccountMachines(); + }, [loadAccountMachines]); + + const goToAccount = useCallback(() => { + onClose(); + navigate("/account"); + }, [navigate, onClose]); + + return ( +
+ + +
+ {TABS.map(({ key, label, icon: Icon }) => ( + + ))} +
+ +
+ {tab === "machines" ? ( + + ) : null} + {tab === "mobile" ? : null} + {tab === "web" ? : null} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index a10d84de6..d756457a1 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -14,12 +14,18 @@ import { ChatCircleDots, GearSix, } from "@phosphor-icons/react"; +import { UserCircle } from "@phosphor-icons/react"; import { cn } from "../ui/cn"; import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition"; import { useAppStore } from "../../state/appStore"; import { revealLabel } from "../../lib/platform"; -import { openExternalUrl } from "../../lib/openExternal"; import { isWebClientMode } from "../../lib/webClientMode"; +import { + accountAvatarImage, + accountInitials, + providerTint, + useAccountStatus, +} from "../../lib/account"; import { logRendererDebugEvent } from "../../lib/debugLog"; import { docs } from "../../onboarding/docsLinks"; import { SmartTooltip, type SmartTooltipContent } from "../ui/SmartTooltip"; @@ -96,16 +102,13 @@ function primaryTabPath(pathname: string): string { return pathname === settingsItem.to || pathname.startsWith(`${settingsItem.to}/`) ? settingsItem.to : pathname; } -function githubProfileUrl(login: string): string { - return `https://github.com/${encodeURIComponent(login)}`; -} - export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) { const project = useAppStore((s) => s.project); const projectBinding = useAppStore((s) => s.projectBinding); const showWelcome = useAppStore((s) => s.showWelcome); const terminalAttention = useAppStore((s) => s.terminalAttention); const location = useLocation(); + const { status: accountStatus } = useAccountStatus(); const activeProjectRoot = projectBinding?.kind === "remote" ? projectBinding.rootPath : (project?.rootPath ?? null); const hasActiveProject = Boolean(activeProjectRoot); @@ -113,10 +116,20 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) const { ref: sidebarMenuRef, position: sidebarMenuPosition } = useClampedFixedPosition(contextMenu); const [avatarBroken, setAvatarBroken] = useState(false); const githubLogin = githubStatus?.userLogin || null; + const githubConnected = Boolean(githubStatus?.connected); + const avatarImage = accountAvatarImage(accountStatus, githubLogin); + const accountRingTint = providerTint(accountStatus, githubConnected); + const accountLabel = + accountStatus.name?.trim() || + accountStatus.email?.trim() || + githubLogin || + (accountStatus.signedIn ? "Account" : "Sign in"); + const accountRouteActive = + location.pathname === "/account" || location.pathname.startsWith("/account/"); useEffect(() => { setAvatarBroken(false); - }, [githubLogin]); + }, [githubLogin, avatarImage]); useEffect(() => { if (!contextMenu) return; @@ -319,27 +332,54 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) - {/* GitHub profile avatar — only shows when token is stored, a login is known, and the image loads */} - {githubLogin && !avatarBroken ? ( - - ) : null} + ) : accountStatus.signedIn ? ( + + {accountInitials(accountStatus)} + + ) : ( + + )} + + {accountLabel} + {!webMode ? ( <> diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index f01374a82..a5c074a1a 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -11,10 +11,8 @@ import { ChatCircleDots, CircleNotch, DesktopTower, - DeviceMobile, Folder, FolderOpen, - Globe, Plus, Minus, Plugs, @@ -28,7 +26,6 @@ import * as Dialog from "@radix-ui/react-dialog"; import { useAppStore } from "../../state/appStore"; import { isRunOwnedSession } from "../../lib/sessions"; import { useGithubProjectRemote } from "../../lib/useGithubProjectRemote"; -import { openExternalUrl } from "../../lib/openExternal"; import { isWebClientMode } from "../../lib/webClientMode"; import { ZOOM_LEVEL_KEY, @@ -44,8 +41,6 @@ import { cn } from "../ui/cn"; import { readStoredProjectRoute } from "./projectRouteStorage"; import { deriveIconAccentColor } from "../../lib/iconAccent"; import { SmartTooltip } from "../ui/SmartTooltip"; -import { ADE_MOBILE_TESTFLIGHT_URL } from "../../../shared/productLinks"; -import { WEB_CLIENT_BASE_URL } from "../../../shared/webClientUrl"; import type { ProcessRuntime, ProjectIcon, @@ -58,13 +53,16 @@ import type { } from "../../../shared/types"; import { AutoUpdateControl } from "./AutoUpdateControl"; import { FeedbackReporterModal } from "./FeedbackReporterModal"; -import { HeaderSheet, useDialogFocusTrap } from "./HeaderSheet"; +import { useDialogFocusTrap } from "./HeaderSheet"; import { HelpMenu } from "../onboarding/HelpMenu"; import { LinearQuickViewButton } from "./LinearQuickViewButton"; import { PublishToGitHubDialog } from "../projects/PublishToGitHubDialog"; -import { RemoteTargetList } from "../remoteTargets/RemoteTargetList"; +import { ConnectionsPanel } from "./ConnectionsPanel"; +import { + subscribeOpenConnectionsPanel, + type ConnectionsPanelTab, +} from "../../lib/connectionsPanel"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; -import { SyncDevicesSection } from "../settings/SyncDevicesSection"; import { HeaderUsageControl } from "../usage/HeaderUsageControl"; import { GlobalVoiceCaptureIndicator } from "../voice/GlobalVoiceCaptureIndicator"; import { appResourcePressureLevel, getAppResourceUsageCoalesced, resourcePressureDescription } from "../../lib/resourcePressure"; @@ -215,35 +213,6 @@ function isWebSyncConnected(snapshot: SyncRoleSnapshot | null): boolean { return connectedWebClients(snapshot).length > 0; } -function deriveWebClientTooltip(snapshot: SyncRoleSnapshot | null): string { - const clients = connectedWebClients(snapshot); - if (clients.length === 0) return "Pair a browser with this machine"; - const first = clients[0]; - const name = first.deviceName?.trim(); - return name ? `${clients.length} connected · ${name}` : `${clients.length} connected`; -} - -function deriveWebSyncLabel(snapshot: SyncRoleSnapshot | null): string | null { - if (!snapshot) return null; - if (snapshot.client.state === "error") return "Web client sync error"; - if (snapshot.role === "brain") { - const count = connectedWebClients(snapshot).length; - if (count > 0) { - const machineName = snapshot.localDevice.name.trim() || "this machine"; - return `${count} web client${count === 1 ? "" : "s"} connected to ${machineName}`; - } - return "Web client pairing ready"; - } - if (snapshot.mode === "standalone") return "Web client pairing ready"; - switch (snapshot.client.state) { - case "connected": - return `Linked to ${snapshot.currentBrain?.name ?? "host"}`; - case "connecting": - return "Connecting…"; - default: - return "Web client sync offline"; - } -} const HEADER_STATUS_COMPACT_MAX_WIDTH_PX = 767; @@ -534,28 +503,6 @@ function confirmProjectTabRemoval(projectName: string): boolean { ); } -function deriveSyncLabel(snapshot: SyncRoleSnapshot | null): string | null { - if (!snapshot) return null; - if (snapshot.client.state === "error") return "Phone sync error"; - if (snapshot.role === "brain") { - const count = snapshot.connectedPeers.filter((peer) => peer.deviceType === "phone").length; - if (count > 0) { - const machineName = snapshot.localDevice.name.trim() || "this machine"; - return `${count} phone${count === 1 ? "" : "s"} connected to ${machineName}`; - } - return "Phone sync ready"; - } - if (snapshot.mode === "standalone") return "Phone sync ready"; - switch (snapshot.client.state) { - case "connected": - return `Linked to ${snapshot.currentBrain?.name ?? "host"}`; - case "connecting": - return "Connecting…"; - default: - return "Phone sync offline"; - } -} - function ProjectTabIcon({ rootPath, isCurrent, @@ -915,8 +862,8 @@ export function TopBar({ const [syncSnapshot, setSyncSnapshot] = useState( null, ); - const [syncPanelOpen, setSyncPanelOpen] = useState<"phone" | "web" | null>(null); - const [remotePanelOpen, setRemotePanelOpen] = useState(false); + const [connectionsOpen, setConnectionsOpen] = useState(false); + const [connectionsTab, setConnectionsTab] = useState("machines"); const { state: remoteDisconnectConfirmState, confirmAsync: confirmRemoteDisconnect, @@ -936,23 +883,22 @@ export function TopBar({ const [dragIdx, setDragIdx] = useState(null); const [dropIdx, setDropIdx] = useState(null); const [windowId, setWindowId] = useState(null); - const phoneSyncPanelRef = useRef(null); - const webSyncPanelRef = useRef(null); - const remotePanelRef = useRef(null); - const closeSyncPanel = useCallback(() => setSyncPanelOpen(null), []); - const closeRemotePanel = useCallback(() => setRemotePanelOpen(false), []); - const handleRemotePanelKeyDown = useDialogFocusTrap( - remotePanelRef, - closeRemotePanel, - remotePanelOpen, + const connectionsPanelRef = useRef(null); + const closeConnections = useCallback(() => setConnectionsOpen(false), []); + const openConnections = useCallback((tab: ConnectionsPanelTab = "machines") => { + setConnectionsTab(tab); + setConnectionsOpen(true); + }, []); + const handleConnectionsPanelKeyDown = useDialogFocusTrap( + connectionsPanelRef, + closeConnections, + connectionsOpen, ); const dragCounterRef = useRef(0); const isProjectBusy = projectTransition != null || relocatingPath != null; const remoteBinding = projectBinding?.kind === "remote" ? projectBinding : null; - const phoneSyncOpen = syncPanelOpen === "phone"; - const webSyncOpen = syncPanelOpen === "web"; - const chromePanelOccludesNativeBrowser = remotePanelOpen || syncPanelOpen !== null; + const chromePanelOccludesNativeBrowser = connectionsOpen; const workspaceProjectOpen = projectHydrated === true && showWelcome !== true && @@ -988,7 +934,6 @@ export function TopBar({ const remoteConnected = connectedRemoteCount > 0; const syncConnected = isSyncConnected(syncSnapshot); const webConnected = isWebSyncConnected(syncSnapshot); - const webClientTooltip = deriveWebClientTooltip(syncSnapshot); const showSyncControl = projectHydrated === true; const syncStatusTargetKey = remoteBinding?.key ?? project?.rootPath ?? "machine"; @@ -1210,7 +1155,7 @@ export function TopBar({ let disposeSyncEvents: (() => void) | null = null; if (!showSyncControl) { setSyncSnapshot(null); - setSyncPanelOpen(null); + setConnectionsOpen(false); return () => { cancelled = true; }; @@ -1252,7 +1197,7 @@ export function TopBar({ }; startupTimer = window.setTimeout( startSyncStatus, - phoneSyncOpen || webSyncOpen ? 0 : PHONE_SYNC_STARTUP_DELAY_MS, + connectionsOpen ? 0 : PHONE_SYNC_STARTUP_DELAY_MS, ); window.addEventListener("focus", onFocus); return () => { @@ -1267,7 +1212,15 @@ export function TopBar({ // current state. With no project open, sync calls fall back to the // machine-level brain service. Focus and explicit drawer opens still // refresh immediately. - }, [phoneSyncOpen, webSyncOpen, showSyncControl, syncStatusTargetKey]); + }, [connectionsOpen, showSyncControl, syncStatusTargetKey]); + + // Let other surfaces (e.g. the Account page) open the Connections panel to a + // specific tab. + useEffect(() => { + return subscribeOpenConnectionsPanel((tab) => { + openConnections(tab); + }); + }, [openConnections]); const checkForActiveWorkloads = useCallback( async (projectRootPath: string): Promise => { @@ -1781,16 +1734,7 @@ export function TopBar({ [], ); - const syncLabel = deriveSyncLabel(syncSnapshot) ?? "Phone sync"; - const webSyncLabel = deriveWebSyncLabel(syncSnapshot) ?? "Web client sync"; - const openMobileTestFlight = useCallback( - () => openExternalUrl(ADE_MOBILE_TESTFLIGHT_URL), - [], - ); - const openWebClient = useCallback( - () => openExternalUrl(WEB_CLIENT_BASE_URL), - [], - ); + const anyConnectionActive = remoteConnected || syncConnected || webConnected; const renderHeaderStatusControls = useCallback( (options?: { menuLayout?: boolean; onActivate?: () => void }) => { @@ -1800,26 +1744,20 @@ export function TopBar({ options?.onActivate?.(); }; - const remoteChip = ( + const connectionsChip = ( { - setSyncPanelOpen(null); - setRemotePanelOpen(true); - }) - : () => { - setSyncPanelOpen(null); - setRemotePanelOpen((open) => !open); - } + ? wrapActivate(() => openConnections("machines")) + : () => (connectionsOpen ? closeConnections() : openConnections("machines")) } icon={( - ); - const mobileChip = showSyncControl ? ( - { - setRemotePanelOpen(false); - setSyncPanelOpen("phone"); - }) - : () => { - setRemotePanelOpen(false); - setSyncPanelOpen((open) => open === "phone" ? null : "phone"); - } - } - icon={( - - )} - /> - ) : null; - - const webChip = showSyncControl && !webMode ? ( - { - setRemotePanelOpen(false); - setSyncPanelOpen("web"); - }) - : () => { - setRemotePanelOpen(false); - setSyncPanelOpen((open) => open === "web" ? null : "web"); - } - } - icon={( - - )} - /> - ) : null; - if (menuLayout) { return (
@@ -1893,9 +1775,7 @@ export function TopBar({ onMenuActivate={options?.onActivate} deferInitialRead={Boolean(remoteBinding)} /> - {remoteChip} - {mobileChip} - {webChip} + {connectionsChip}
); } @@ -1903,24 +1783,17 @@ export function TopBar({ return ( <> - {remoteChip} - {mobileChip} - {webChip} + {connectionsChip} ); }, [ - phoneSyncOpen, - webSyncOpen, + anyConnectionActive, + closeConnections, + connectionsOpen, + openConnections, remoteBinding, - remoteConnected, - remotePanelOpen, - showSyncControl, - syncConnected, - webMode, - webConnected, - webClientTooltip, ], ); @@ -2499,111 +2372,43 @@ export function TopBar({ document.body, ) : null} - {typeof document !== "undefined" + {typeof document !== "undefined" && connectionsOpen ? createPortal( - <> - {remotePanelOpen ? ( -
-
event.stopPropagation()} - onKeyDown={handleRemotePanelKeyDown} - > - -
- -
-
-
- ) : null} - - - } - title="Connect to the ADE mobile app" - subtitle={syncLabel} - headerActions={ - - } - onClose={closeSyncPanel} - ariaLabelledBy="phone-sync-title" - closeTitle="Close phone sync" - > -
- -
-
- - - } - title="Web client" - subtitle={webSyncLabel} - headerActions={ - - } - onClose={closeSyncPanel} - ariaLabelledBy="web-sync-title" - closeTitle="Close web client" +
+
event.stopPropagation()} + onKeyDown={handleConnectionsPanelKeyDown} > -
- -
- - , + + +
+
, document.body, ) : null} diff --git a/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx b/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx new file mode 100644 index 000000000..1bacee67c --- /dev/null +++ b/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx @@ -0,0 +1,154 @@ +import { CaretDown, CaretUp, Cloud, PlugsConnected } from "@phosphor-icons/react"; +import type { AdeAccountMachine } from "../../../shared/types"; +import { COLORS, SANS_FONT, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; +import { ConnectionDoctorPanel } from "./ConnectionDoctorPanel"; +import type { AccountMachineRow as AccountMachineRowModel, MachineSection } from "./remoteMachineModel"; +import { helperTextStyle, inlineDetailStyle, machineRowStyle, nameStyle, subTextStyle } from "./remoteTargetListStyles"; + +type AccountMachineRowProps = { + row: AccountMachineRowModel; + section: MachineSection; + busy: boolean; + connecting: boolean; + detailOpen: boolean; + onToggleDetail: (rowId: string) => void; + onConnect: (machine: AdeAccountMachine) => void; +}; + +/** The best lan/tailnet endpoint an account machine can be adopted+connected on. */ +export function accountMachineConnectHost(machine: AdeAccountMachine): { host: string; port: number | null } | null { + const ranked = [...machine.reachableEndpoints].sort((a, b) => { + const rank = (kind: string) => (kind === "tailnet" ? 0 : kind === "lan" ? 1 : 2); + return rank(a.kind) - rank(b.kind); + }); + for (const endpoint of ranked) { + if (endpoint.kind === "relay") continue; + const host = endpoint.host ?? endpoint.url ?? null; + if (host) return { host, port: endpoint.port ?? null }; + } + return null; +} + +function endpointLabel(endpoint: AdeAccountMachine["reachableEndpoints"][number]): string { + const detail = endpoint.host ?? endpoint.url ?? ""; + return detail ? `${endpoint.kind} · ${detail}` : endpoint.kind; +} + +function relativeLastSeen(lastSeenAt: number | null): string { + if (!lastSeenAt) return "Never seen"; + const deltaMs = Date.now() - lastSeenAt; + const minutes = Math.floor(deltaMs / 60_000); + if (minutes < 1) return "Seen moments ago"; + if (minutes < 60) return `Last seen ${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `Last seen ${hours}h ago`; + const days = Math.floor(hours / 24); + return `Last seen ${days}d ago`; +} + +export function AccountMachineRow({ + row, + section, + busy, + connecting, + detailOpen, + onToggleDetail, + onConnect, +}: AccountMachineRowProps) { + const { machine } = row; + const primaryEndpoint = machine.reachableEndpoints[0]; + const connectHost = accountMachineConnectHost(machine); + const isOffline = section === "unavailable"; + + return ( +
+
+
+
+
+ + {machine.name ?? "Unnamed machine"} + + + account + + {primaryEndpoint ? ( + + {endpointLabel(primaryEndpoint)} + + ) : null} +
+
+ {[machine.platform, machine.deviceType].filter(Boolean).join(" · ") || "Registered on your account"} +
+
+ {machine.online ? "Online · reachable on your account" : relativeLastSeen(machine.lastSeenAt)} +
+
+ +
+ {machine.online && connectHost ? ( + + ) : null} + {isOffline ? ( + + ) : null} +
+
+
+ + {isOffline && detailOpen ? ( +
+
+ {relativeLastSeen(machine.lastSeenAt)} +
+
+ This machine hasn't checked in recently — it's likely asleep or offline. It'll reappear here + the moment it comes back online. +
+ {machine.reachableEndpoints.length > 0 ? ( +
+
+ Last-known routes +
+ {machine.reachableEndpoints.map((endpoint, index) => ( +
+ {endpointLabel(endpoint)} +
+ ))} +
+ ) : ( +
No reachable routes advertised.
+ )} + {row.matchedTargetId ? : null} +
+ ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx index 80bb97c1a..647f317bd 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx @@ -9,6 +9,8 @@ import { primaryButton, } from "../lanes/laneDesignTokens"; import type { + AdeAccountMachine, + AdeAccountMachinesResult, RemoteRuntimeConnectionSnapshot, RemoteRuntimeConnectionStatus, RemoteRuntimeConnectResult, @@ -31,6 +33,7 @@ import { } from "./remoteMachineModel"; import { SavedMachineRow } from "./SavedMachineRow"; import { DiscoveredMachineRow } from "./DiscoveredMachineRow"; +import { AccountMachineRow, accountMachineConnectHost } from "./AccountMachineRow"; import { helperTextStyle, inlineDetailStyle, @@ -46,6 +49,10 @@ type RemoteTargetListProps = { onRemoveRequested?: ( target: RemoteRuntimeTarget, ) => boolean | Promise; + /** Account-directory machines, merged into the sections alongside saved/discovered. */ + accountMachines?: AdeAccountMachine[]; + accountMachinesState?: AdeAccountMachinesResult["state"]; + accountMachinesMessage?: string | null; }; type ConnectTargetOptions = { @@ -81,6 +88,9 @@ export function RemoteTargetList({ onConnected, onDisconnectRequested, onRemoveRequested, + accountMachines, + accountMachinesState, + accountMachinesMessage, }: RemoteTargetListProps) { const [targets, setTargets] = useState([]); const [connectionSnapshot, setConnectionSnapshot] = @@ -132,8 +142,9 @@ export function RemoteTargetList({ statusById, connectedFallbackId: connected?.target.id ?? null, discoveredMachines, + accountMachines, }), - [targets, statusById, connected, discoveredMachines], + [targets, statusById, connected, discoveredMachines, accountMachines], ); const loadTargets = useCallback(async () => { @@ -427,6 +438,31 @@ export function RemoteTargetList({ [saveTargetAndConnect], ); + const connectAccountMachine = useCallback( + async (machine: AdeAccountMachine) => { + const hostInfo = accountMachineConnectHost(machine); + if (!hostInfo) return; + const input: RemoteRuntimeTargetInput = { + name: machine.name ?? hostInfo.host, + hostname: hostInfo.host.replace(/\.$/, ""), + sshUser: null, + port: hostInfo.port, + sshKeyPath: null, + routes: null, + }; + setBusyId(`account:${machine.machineKey}`); + setSelectedId(null); + setHostKeyTrust(null); + setError(null); + try { + await saveTargetAndConnect(input); + } finally { + setBusyId(null); + } + }, + [saveTargetAndConnect], + ); + const onPaired = useCallback( async (targetId: string) => { await loadTargets(); @@ -524,33 +560,50 @@ export function RemoteTargetList({ return (
{SECTION_LABELS[section]}
- {rows.map((row) => - row.kind === "saved" ? ( - void connectTarget(targetId)} - onDisconnect={(targetId) => void disconnectTarget(targetId)} - onToggleTest={toggleTest} - onToggleEdit={toggleEditForm} - onRemove={(targetId) => void removeTarget(targetId)} - onSaveAndConnect={saveAndConnect} - onTrustAndConnect={() => void trustAndConnect()} - onCancelHostKeyTrust={() => setHostKeyTrust(null)} - /> - ) : ( + {rows.map((row) => { + if (row.kind === "saved") { + return ( + void connectTarget(targetId)} + onDisconnect={(targetId) => void disconnectTarget(targetId)} + onToggleTest={toggleTest} + onToggleEdit={toggleEditForm} + onRemove={(targetId) => void removeTarget(targetId)} + onSaveAndConnect={saveAndConnect} + onTrustAndConnect={() => void trustAndConnect()} + onCancelHostKeyTrust={() => setHostKeyTrust(null)} + /> + ); + } + if (row.kind === "account") { + return ( + void connectAccountMachine(machine)} + /> + ); + } + return ( void connectDiscoveredMachine(machine)} onToggleTest={toggleTest} /> - ), - )} + ); + })}
); } @@ -727,6 +780,14 @@ export function RemoteTargetList({ {renderSection("available")} {renderSection("unavailable")} + {accountMachinesState && accountMachinesState !== "ok" && accountMachinesState !== "signed_out" ? ( +
+ {accountMachinesState === "not_configured" + ? "Your account machine directory isn't live yet — saved and nearby machines still connect." + : (accountMachinesMessage ?? "Can't reach your account machines right now.")} +
+ ) : null} + {!loading && totalRows === 0 && !addMode && !loadingDiscovered ? (
{discoveredMachines.length > 0 diff --git a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts new file mode 100644 index 000000000..7173e51ca --- /dev/null +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import type { + AdeAccountMachine, + RemoteRuntimeConnectionStatus, + RemoteRuntimeTarget, +} from "../../../shared/types"; +import { + accountMachineMatchesTarget, + assignMachineSections, +} from "./remoteMachineModel"; + +function accountMachine(overrides: Partial = {}): AdeAccountMachine { + return { + machineKey: "mk_default", + deviceId: "dev_default", + name: "Studio", + platform: "darwin", + deviceType: "desktop", + reachableEndpoints: [{ kind: "tailnet", host: "100.92.14.3", port: 22 }], + lastSeenAt: Date.now() - 30_000, + online: true, + ...overrides, + }; +} + +function savedTarget(overrides: Partial = {}): RemoteRuntimeTarget { + return { + id: "target-1", + name: "Studio", + hostname: "100.92.14.3", + sshUser: null, + port: 22, + sshKeyPath: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, + ...overrides, + }; +} + +const NO_STATUS = new Map(); + +describe("assignMachineSections — account machines", () => { + it("buckets an online account machine into AVAILABLE and offline into UNAVAILABLE", () => { + const sections = assignMachineSections({ + targets: [], + statusById: NO_STATUS, + connectedFallbackId: null, + discoveredMachines: [], + accountMachines: [ + accountMachine({ machineKey: "mk_online", online: true }), + accountMachine({ + machineKey: "mk_offline", + name: "Mac mini", + online: false, + reachableEndpoints: [{ kind: "relay", url: "wss://relay/mini" }], + }), + ], + }); + + expect(sections.available.map((row) => row.id)).toEqual(["account:mk_online"]); + expect(sections.unavailable.map((row) => row.id)).toEqual(["account:mk_offline"]); + expect(sections.available[0]).toMatchObject({ kind: "account" }); + }); + + it("dedupes an account machine that maps to a saved target by host identity", () => { + const sections = assignMachineSections({ + targets: [savedTarget()], + statusById: NO_STATUS, + connectedFallbackId: null, + discoveredMachines: [], + accountMachines: [accountMachine({ machineKey: "mk_dupe" })], + }); + + // The saved target owns the host; no duplicate account row is emitted. + const accountRows = [ + ...sections.connected, + ...sections.available, + ...sections.unavailable, + ].filter((row) => row.kind === "account"); + expect(accountRows).toHaveLength(0); + expect(sections.available.some((row) => row.kind === "saved")).toBe(true); + }); + + it("accountMachineMatchesTarget matches on shared host:port", () => { + expect(accountMachineMatchesTarget(accountMachine(), savedTarget())).toBe(true); + expect( + accountMachineMatchesTarget( + accountMachine({ reachableEndpoints: [{ kind: "lan", host: "10.0.0.9", port: 22 }] }), + savedTarget(), + ), + ).toBe(false); + }); + + it("is a no-op when no account machines are supplied", () => { + const sections = assignMachineSections({ + targets: [], + statusById: NO_STATUS, + connectedFallbackId: null, + discoveredMachines: [], + }); + expect(sections.available).toHaveLength(0); + expect(sections.unavailable).toHaveLength(0); + }); +}); diff --git a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts index f2fdbfeb4..6bab60723 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts @@ -1,5 +1,6 @@ import { extractError } from "../../lib/format"; import type { + AdeAccountMachine, RemoteRuntimeConnectErrorInfo, RemoteRuntimeConnectionRoute, RemoteRuntimeConnectionStatus, @@ -333,7 +334,20 @@ export type DiscoveredMachineRow = { unavailableReason: string | null; }; -export type MachineRow = SavedMachineRow | DiscoveredMachineRow; +/** + * A machine known only through the account directory (#814 Worker) — not saved + * or discovered locally. Online rows can be adopted+connected via their best + * reachable endpoint; offline rows grey out with a last-seen + "why offline?". + */ +export type AccountMachineRow = { + kind: "account"; + id: string; + machine: AdeAccountMachine; + /** The saved target this account machine maps to, if any (enables the doctor). */ + matchedTargetId: string | null; +}; + +export type MachineRow = SavedMachineRow | DiscoveredMachineRow | AccountMachineRow; export type MachineSections = { connected: MachineRow[]; @@ -341,6 +355,46 @@ export type MachineSections = { unavailable: MachineRow[]; }; +/** Host:port identities advertised by an account machine's reachable endpoints. */ +export function accountMachineRouteIdentities( + machine: AdeAccountMachine, +): Set { + const identities = new Set(); + for (const endpoint of machine.reachableEndpoints) { + const host = endpoint.host ?? endpoint.url ?? null; + const identity = routeIdentity(host, endpoint.port ?? null); + if (identity) identities.add(identity); + } + return identities; +} + +function intersects(a: Set, b: Set): boolean { + for (const value of a) { + if (b.has(value)) return true; + } + return false; +} + +/** True when an account machine is the same host as a saved target. */ +export function accountMachineMatchesTarget( + machine: AdeAccountMachine, + target: RemoteRuntimeTarget, +): boolean { + const machineIds = accountMachineRouteIdentities(machine); + if (machineIds.size === 0) return false; + return intersects(machineIds, targetRouteIdentities(target)); +} + +/** True when an account machine is the same host as a locally-discovered machine. */ +export function accountMachineMatchesDiscovered( + machine: AdeAccountMachine, + discovered: RemoteRuntimeDiscoveredMachine, +): boolean { + const machineIds = accountMachineRouteIdentities(machine); + if (machineIds.size === 0) return false; + return intersects(machineIds, discoveredMachineRouteIdentities(discovered)); +} + /** * Splits saved targets and discovered machines into the CONNECTED / AVAILABLE / * UNAVAILABLE buckets the panel renders. A discovered machine that is already @@ -353,8 +407,16 @@ export function assignMachineSections(args: { statusById: Map; connectedFallbackId: string | null; discoveredMachines: RemoteRuntimeDiscoveredMachine[]; + /** Machines from the account directory, merged with saved/discovered. */ + accountMachines?: AdeAccountMachine[]; }): MachineSections { - const { targets, statusById, connectedFallbackId, discoveredMachines } = args; + const { + targets, + statusById, + connectedFallbackId, + discoveredMachines, + accountMachines = [], + } = args; const sections: MachineSections = { connected: [], available: [], @@ -434,5 +496,38 @@ export function assignMachineSections(args: { }); } + // Account-directory machines are merged in as a third source. Any that already + // map to a saved target or a discovered machine are represented by that local + // row (no duplicate); the rest surface as their own rows — online in + // AVAILABLE, offline greyed in UNAVAILABLE. + for (const machine of accountMachines) { + const matchedTarget = targets.find((target) => + accountMachineMatchesTarget(machine, target), + ); + if (matchedTarget) { + // A saved target already owns this host; only annotate offline account + // machines whose target isn't otherwise flagged (rare) — otherwise skip. + continue; + } + if ( + discoveredMachines.some((discovered) => + accountMachineMatchesDiscovered(machine, discovered), + ) + ) { + continue; + } + const row: AccountMachineRow = { + kind: "account", + id: `account:${machine.machineKey}`, + machine, + matchedTargetId: null, + }; + if (machine.online) { + sections.available.push(row); + } else { + sections.unavailable.push(row); + } + } + return sections; } diff --git a/apps/desktop/src/renderer/lib/account.ts b/apps/desktop/src/renderer/lib/account.ts new file mode 100644 index 000000000..6cd4dc75d --- /dev/null +++ b/apps/desktop/src/renderer/lib/account.ts @@ -0,0 +1,231 @@ +import { useCallback, useEffect, useState } from "react"; +import type { + AdeAccountMachine, + AdeAccountMachinesResult, + AdeAccountProvider, + AdeAccountStatus, +} from "../../shared/types"; + +// --------------------------------------------------------------------------- +// Machine-owned ADE account (Clerk identity) — renderer helpers. +// +// One cached status shared across the always-mounted sidebar avatar and the +// Account page, plus a tiny event bus so a sign-in / sign-out in one surface +// refreshes the other without a round-trip storm. All calls degrade quietly +// when the bridge is missing (web-client mock, older preload). +// --------------------------------------------------------------------------- + +export const SIGNED_OUT_ACCOUNT: AdeAccountStatus = { + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, + configured: undefined, +}; + +const STATUS_TTL_MS = 15_000; + +let cachedStatus: { value: AdeAccountStatus; fetchedAtMs: number } | null = null; +let inFlight: Promise | null = null; +const listeners = new Set<(status: AdeAccountStatus) => void>(); + +function accountApi() { + return (window.ade as typeof window.ade & { + account?: { + status: () => Promise; + startLogin: () => Promise<{ sessionId: string; authorizeUrl: string; expiresAt: string }>; + pollLogin: (args: { sessionId: string }) => Promise<{ + status: "pending" | "signed_in" | "expired" | "error"; + message: string | null; + authStatus: AdeAccountStatus; + }>; + cancelLogin: (args: { sessionId: string }) => Promise; + signOut: () => Promise; + listMachines: () => Promise; + }; + })?.account; +} + +function emit(status: AdeAccountStatus): void { + cachedStatus = { value: status, fetchedAtMs: Date.now() }; + for (const listener of listeners) { + try { + listener(status); + } catch { + // A broken subscriber must not stop the rest from updating. + } + } +} + +export async function fetchAccountStatus(options?: { force?: boolean }): Promise { + const api = accountApi(); + if (!api?.status) return SIGNED_OUT_ACCOUNT; + if ( + !options?.force && + cachedStatus && + Date.now() - cachedStatus.fetchedAtMs < STATUS_TTL_MS + ) { + return cachedStatus.value; + } + if (!options?.force && inFlight) return inFlight; + inFlight = api + .status() + .then((status) => { + emit(status); + return status; + }) + .catch(() => cachedStatus?.value ?? SIGNED_OUT_ACCOUNT) + .finally(() => { + inFlight = null; + }); + return inFlight; +} + +/** Push a freshly-known status to every subscriber (after sign-in / sign-out). */ +export function publishAccountStatus(status: AdeAccountStatus): void { + emit(status); +} + +export function subscribeAccountStatus(cb: (status: AdeAccountStatus) => void): () => void { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; +} + +/** + * Shared account-status hook. Reads the cache immediately, refreshes on mount + + * window focus, and stays in sync with sign-in / sign-out from other surfaces. + */ +export function useAccountStatus(): { + status: AdeAccountStatus; + loading: boolean; + refresh: () => Promise; +} { + const [status, setStatus] = useState( + () => cachedStatus?.value ?? SIGNED_OUT_ACCOUNT, + ); + const [loading, setLoading] = useState(cachedStatus == null); + + const refresh = useCallback(async () => { + setLoading(true); + try { + return await fetchAccountStatus({ force: true }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + const unsubscribe = subscribeAccountStatus(setStatus); + void fetchAccountStatus().then(setStatus).finally(() => setLoading(false)); + const onFocus = () => void fetchAccountStatus({ force: true }); + window.addEventListener("focus", onFocus); + return () => { + unsubscribe(); + window.removeEventListener("focus", onFocus); + }; + }, []); + + return { status, loading, refresh }; +} + +// --------------------------------------------------------------------------- +// Presentation helpers — provider accent, monogram, avatar image resolution. +// --------------------------------------------------------------------------- + +/** + * A subtle, provider-tinted accent for the avatar ring. Provider-aware when the + * daemon reports a provider; otherwise a low-confidence hint from the email + * domain or a connected GitHub identity, falling back to the ADE accent. Used + * only for an unlabeled tint — provider *labels* use `knownProvider` so we + * never assert a provider we cannot verify. + */ +const PROVIDER_TINTS: Record = { + github: "#8b949e", // graphite + google: "#4285f4", + apple: "#a1a1a8", // silver + email: "var(--color-accent)", +}; + +export function providerLabel(provider: AdeAccountProvider): string { + switch (provider) { + case "github": + return "GitHub"; + case "google": + return "Google"; + case "apple": + return "Apple"; + case "email": + return "Email"; + } +} + +/** The provider we can *state* — only when the daemon reports it. */ +export function knownProvider(status: AdeAccountStatus): AdeAccountProvider | null { + return status.provider ?? null; +} + +function inferProviderHint( + status: AdeAccountStatus, + githubConnected: boolean, +): AdeAccountProvider | null { + if (status.provider) return status.provider; + const domain = status.email?.split("@")[1]?.toLowerCase() ?? ""; + if (domain === "gmail.com" || domain === "googlemail.com") return "google"; + if (domain === "icloud.com" || domain === "me.com" || domain === "mac.com") { + return "apple"; + } + if (githubConnected) return "github"; + return null; +} + +export function providerTint( + status: AdeAccountStatus, + githubConnected: boolean, +): string { + const provider = inferProviderHint(status, githubConnected); + return provider ? PROVIDER_TINTS[provider] : "var(--color-accent)"; +} + +/** Two-letter (or one-letter) monogram from the account name, else email. */ +export function accountInitials(status: AdeAccountStatus): string { + const name = status.name?.trim(); + if (name) { + const parts = name.split(/\s+/).filter(Boolean); + if (parts.length >= 2) { + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + } + if (parts[0].length >= 2) return parts[0].slice(0, 2).toUpperCase(); + return parts[0][0].toUpperCase(); + } + const email = status.email?.trim(); + if (email) return email[0].toUpperCase(); + return "?"; +} + +/** + * The avatar image URL, following the spec fallback chain: account image → + * current GitHub-creds image → none (caller renders the monogram). + */ +export function accountAvatarImage( + status: AdeAccountStatus, + githubLogin: string | null | undefined, +): string | null { + if (status.imageUrl) return status.imageUrl; + if (githubLogin) { + return `https://github.com/${encodeURIComponent(githubLogin)}.png?size=64`; + } + return null; +} + +/** A short "signed in with X" descriptor for the account header, honest by default. */ +export function accountProviderCaption(status: AdeAccountStatus): string | null { + const provider = knownProvider(status); + return provider ? `Signed in with ${providerLabel(provider)}` : null; +} + +export type { AdeAccountMachine, AdeAccountMachinesResult, AdeAccountStatus }; diff --git a/apps/desktop/src/renderer/lib/accountLogin.ts b/apps/desktop/src/renderer/lib/accountLogin.ts new file mode 100644 index 000000000..bf43f2a7a --- /dev/null +++ b/apps/desktop/src/renderer/lib/accountLogin.ts @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { openExternalUrl } from "./openExternal"; +import { publishAccountStatus, SIGNED_OUT_ACCOUNT } from "./account"; +import type { AdeAccountStatus } from "../../shared/types"; + +// Drives the machine-owned account OAuth PKCE login from the renderer: start the +// flow in main, open the browser to the authorize URL, then poll until the +// loopback callback completes. The raw token never crosses into the renderer — +// only the resulting token-free status does. + +export type AccountLoginPhase = "idle" | "starting" | "awaiting" | "signed_in" | "error"; + +const POLL_INTERVAL_MS = 1_500; +const MAX_POLLS = 200; // ~5 minutes at 1.5s, matching the login session TTL. + +function accountApi() { + return (window.ade as typeof window.ade & { + account?: { + startLogin: () => Promise<{ sessionId: string; authorizeUrl: string; expiresAt: string }>; + pollLogin: (args: { sessionId: string }) => Promise<{ + status: "pending" | "signed_in" | "expired" | "error"; + message: string | null; + authStatus: AdeAccountStatus; + }>; + cancelLogin: (args: { sessionId: string }) => Promise; + }; + })?.account; +} + +export function useAccountLogin(options?: { + onSignedIn?: (status: AdeAccountStatus) => void; +}): { + phase: AccountLoginPhase; + error: string | null; + beginLogin: () => Promise; + cancel: () => void; +} { + const [phase, setPhase] = useState("idle"); + const [error, setError] = useState(null); + const sessionIdRef = useRef(null); + const cancelledRef = useRef(false); + const onSignedInRef = useRef(options?.onSignedIn); + onSignedInRef.current = options?.onSignedIn; + + useEffect(() => { + return () => { + cancelledRef.current = true; + }; + }, []); + + const cancel = useCallback(() => { + cancelledRef.current = true; + const sessionId = sessionIdRef.current; + if (sessionId) { + void accountApi()?.cancelLogin({ sessionId }).catch(() => {}); + } + sessionIdRef.current = null; + setPhase("idle"); + setError(null); + }, []); + + const beginLogin = useCallback(async () => { + const api = accountApi(); + if (!api?.startLogin) { + setPhase("error"); + setError("Account sign-in isn't available on this build."); + return; + } + cancelledRef.current = false; + setError(null); + setPhase("starting"); + let sessionId: string; + try { + const start = await api.startLogin(); + sessionId = start.sessionId; + sessionIdRef.current = sessionId; + openExternalUrl(start.authorizeUrl); + } catch (err) { + if (cancelledRef.current) return; + setPhase("error"); + setError( + err instanceof Error ? err.message : "Couldn't start ADE account sign-in.", + ); + return; + } + + setPhase("awaiting"); + for (let attempt = 0; attempt < MAX_POLLS; attempt += 1) { + if (cancelledRef.current) return; + await new Promise((resolve) => window.setTimeout(resolve, POLL_INTERVAL_MS)); + if (cancelledRef.current) return; + let result: Awaited["pollLogin"]>>; + try { + result = await api.pollLogin({ sessionId }); + } catch { + continue; // Transient IPC hiccup — keep polling until the TTL. + } + if (cancelledRef.current) return; + if (result.status === "signed_in") { + sessionIdRef.current = null; + publishAccountStatus(result.authStatus); + setPhase("signed_in"); + onSignedInRef.current?.(result.authStatus); + return; + } + if (result.status === "expired" || result.status === "error") { + sessionIdRef.current = null; + publishAccountStatus(result.authStatus ?? SIGNED_OUT_ACCOUNT); + setPhase("error"); + setError( + result.message ?? + (result.status === "expired" + ? "Sign-in timed out. Try again." + : "Sign-in didn't complete. Try again."), + ); + return; + } + } + if (cancelledRef.current) return; + setPhase("error"); + setError("Sign-in timed out. Try again."); + }, []); + + return { phase, error, beginLogin, cancel }; +} diff --git a/apps/desktop/src/renderer/lib/connectionsPanel.ts b/apps/desktop/src/renderer/lib/connectionsPanel.ts new file mode 100644 index 000000000..982aff7c8 --- /dev/null +++ b/apps/desktop/src/renderer/lib/connectionsPanel.ts @@ -0,0 +1,24 @@ +// Cross-surface signal to open the header Connections panel to a given tab. +// The Account page and other surfaces dispatch this; TopBar owns the panel and +// subscribes. Keeps the panel a header popover while staying openable elsewhere. + +export type ConnectionsPanelTab = "machines" | "mobile" | "web"; + +const OPEN_CONNECTIONS_EVENT = "ade:open-connections-panel"; + +export function openConnectionsPanel(tab: ConnectionsPanelTab = "machines"): void { + window.dispatchEvent( + new CustomEvent(OPEN_CONNECTIONS_EVENT, { detail: { tab } }), + ); +} + +export function subscribeOpenConnectionsPanel( + cb: (tab: ConnectionsPanelTab) => void, +): () => void { + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ tab?: ConnectionsPanelTab }>).detail; + cb(detail?.tab ?? "machines"); + }; + window.addEventListener(OPEN_CONNECTIONS_EVENT, handler); + return () => window.removeEventListener(OPEN_CONNECTIONS_EVENT, handler); +} diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index eb109e884..5b98c8987 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -565,6 +565,12 @@ export const IPC = { githubListRepoIssues: "ade.github.listRepoIssues", githubListMyRepos: "ade.github.listMyRepos", githubPublishCurrentProject: "ade.github.publishCurrentProject", + accountStatus: "ade.account.status", + accountStartLogin: "ade.account.startLogin", + accountPollLogin: "ade.account.pollLogin", + accountCancelLogin: "ade.account.cancelLogin", + accountSignOut: "ade.account.signOut", + accountListMachines: "ade.account.listMachines", prsCreateFromLane: "ade.prs.createFromLane", prsLinkToLane: "ade.prs.linkToLane", prsPreflightCreateLaneFromPrBranch: "ade.prs.preflightCreateLaneFromPrBranch", diff --git a/apps/desktop/src/shared/types/account.ts b/apps/desktop/src/shared/types/account.ts new file mode 100644 index 000000000..0832a5db0 --- /dev/null +++ b/apps/desktop/src/shared/types/account.ts @@ -0,0 +1,73 @@ +// Renderer/preload contract for the machine-owned ADE account (Clerk identity). +// Mirrors the daemon `account` action domain (#815) but only exposes the +// token-free surface — the raw bearer never crosses into the renderer. + +/** Which identity provider signed this account in, when known. */ +export type AdeAccountProvider = "github" | "google" | "apple" | "email"; + +/** + * Token-free account status surfaced to the renderer. Mirrors the daemon + * `account.status` shape; `provider`/`imageUrl` are optional because the merged + * status may not carry them yet — the UI degrades to a GitHub-creds image and a + * monogram when they are absent. + */ +export type AdeAccountStatus = { + signedIn: boolean; + userId: string | null; + email: string | null; + name: string | null; + expiresAt: string | null; + /** Identity provider, when the daemon can determine it. */ + provider?: AdeAccountProvider | null; + /** Profile image URL, when the daemon can determine it. */ + imageUrl?: string | null; + /** + * True when the daemon has no OAuth config (CLERK_* secrets/env), so login is + * unavailable. Lets the UI explain "not configured" instead of failing hard. + */ + configured?: boolean; +}; + +export type AdeAccountLoginStart = { + sessionId: string; + authorizeUrl: string; + expiresAt: string; +}; + +export type AdeAccountLoginPoll = { + status: "pending" | "signed_in" | "expired" | "error"; + message: string | null; + authStatus: AdeAccountStatus; +}; + +/** A reachable route advertised by an account machine in the directory Worker. */ +export type AdeAccountMachineEndpoint = { + kind: "lan" | "tailnet" | "relay"; + url?: string; + host?: string; + port?: number; +}; + +/** One machine in the account directory (#814 Worker `GET /account/machines`). */ +export type AdeAccountMachine = { + machineKey: string; + deviceId: string | null; + name: string | null; + platform: string | null; + deviceType: string | null; + reachableEndpoints: AdeAccountMachineEndpoint[]; + lastSeenAt: number | null; + online: boolean; +}; + +/** + * Result of listing account machines. The Worker may not be deployed or the + * account may be signed out — the renderer degrades gracefully on each state + * rather than blocking the Machines panel. + */ +export type AdeAccountMachinesResult = { + state: "ok" | "signed_out" | "not_configured" | "unavailable"; + machines: AdeAccountMachine[]; + /** Human-readable detail for the "unavailable"/"not_configured" states. */ + message: string | null; +}; diff --git a/apps/desktop/src/shared/types/index.ts b/apps/desktop/src/shared/types/index.ts index e8da4357d..c2298f118 100644 --- a/apps/desktop/src/shared/types/index.ts +++ b/apps/desktop/src/shared/types/index.ts @@ -37,3 +37,4 @@ export * from "./search"; export * from "./externalSessions"; export * from "./recovery"; export * from "./productAnalytics"; +export * from "./account"; From 0accd270d1adaeb05bc0f85694b536094a3e237f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:23:33 -0400 Subject: [PATCH 2/8] =?UTF-8?q?ship:=20iter=201=20=E2=80=94=20fix=20test-d?= =?UTF-8?q?esktop=20(2)/(3):=20reconcile=20TopBar/TabNav=20tests=20to=20Co?= =?UTF-8?q?nnections=20panel=20+=20/account=20avatar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Connections-panel refactor consolidated the per-service status chips into a single "Connections and usage" trigger + ConnectionsPanel (machines/mobile/web tabs) and turned the sidebar avatar into a /account link. Update the 16 stale renderer tests to the new accessible names/flow; no production code changed, coverage preserved (no skip/only, no test deletion). Co-Authored-By: Claude Opus 4.8 --- .../renderer/components/app/TabNav.test.tsx | 8 +- .../renderer/components/app/TopBar.test.tsx | 198 ++++++++++-------- 2 files changed, 117 insertions(+), 89 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/TabNav.test.tsx b/apps/desktop/src/renderer/components/app/TabNav.test.tsx index 4b32c2e9b..4dac45ea0 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.test.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.test.tsx @@ -68,16 +68,16 @@ describe("TabNav", () => { expect(links[links.indexOf(run) + 1]).toBe(review); }); - it("opens the connected GitHub profile from the sidebar avatar", () => { + it("links the sidebar account avatar to the account page", () => { render( , ); - fireEvent.click(screen.getByRole("button", { name: "Open GitHub profile for arul28" })); - - expect(globalThis.window.ade.app.openExternal).toHaveBeenCalledWith("https://github.com/arul28"); + expect(screen.getByRole("link", { name: "Sign in to ADE" }).getAttribute("href")).toBe( + "/account", + ); }); it("keeps Chats available and active without a project", () => { diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 97cfe5f3a..8366f33cc 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -3,8 +3,10 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; import { TopBar } from "./TopBar"; import { applyShellHeaderInset } from "../../lib/zoom"; +import { openConnectionsPanel } from "../../lib/connectionsPanel"; import { useAppStore } from "../../state/appStore"; import { requestLinearIssueQuickView } from "../../lib/linearIssueQuickViewNavigation"; import { @@ -254,6 +256,20 @@ function renderChatsTopBar({ }; } +function renderTopBarWithRouter() { + return render( + + + , + ); +} + +function getConnectionsControl() { + return screen.getByRole("button", { + name: /^Connections, (?:not )?connected$/, + }); +} + describe("TopBar", () => { const originalAde = globalThis.window.ade; const originalWebClientMode = globalThis.window.__adeWebClient; @@ -388,12 +404,12 @@ describe("TopBar", () => { } }); - it("shows phone sync before a project is open without immediate polling", async () => { + it("shows connections before a project is open without immediate polling", async () => { useAppStore.setState({ project: null, projectHydrated: true, showWelcome: true } as any); render(); - expect(screen.getByRole("button", { name: "Mobile, not connected" })).toBeTruthy(); + expect(getConnectionsControl().getAttribute("aria-expanded")).toBe("false"); expect(globalThis.window.ade.sync.getStatus).not.toHaveBeenCalled(); await waitFor(() => { expect(globalThis.window.ade.project.listRecent).toHaveBeenCalled(); @@ -515,21 +531,24 @@ describe("TopBar", () => { expect(projectTab?.getAttribute("aria-current")).toBe("true"); }); - it("keeps the phone sync drawer open before a project is open", async () => { + it("keeps the mobile connections tab open before a project is open", async () => { useAppStore.setState({ project: null, projectHydrated: true, showWelcome: true } as any); - render(); + renderTopBarWithRouter(); - const mobileButton = screen.getByTitle("Connect a phone to this machine"); - fireEvent.click(mobileButton); + const connectionsButton = getConnectionsControl(); + fireEvent.click(connectionsButton); + const mobileTab = screen.getByRole("tab", { name: "Mobile" }); + fireEvent.click(mobileTab); await act(async () => { await flushMicrotasks(2); }); - expect(screen.getByText("Connect to the ADE mobile app")).toBeTruthy(); - expect(screen.getByTestId("sync-devices-section")).toBeTruthy(); - expect(mobileButton.getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByRole("dialog", { name: "Connections" })).toBeTruthy(); + expect(mobileTab.getAttribute("aria-selected")).toBe("true"); + expect(screen.getByTestId("sync-devices-section").getAttribute("data-variant")).toBe("phone"); + expect(connectionsButton.getAttribute("aria-expanded")).toBe("true"); }); it("does not render recent projects as tabs before a project is open", async () => { @@ -578,7 +597,7 @@ describe("TopBar", () => { expect(screen.getByTitle(rootPath)).toBeTruthy(); }); - it("renders a remote project tab with mobile sync control without immediate polling", async () => { + it("renders a remote project tab with the connections control without immediate polling", async () => { (globalThis.window.ade.remoteRuntime.getConnectionSnapshot as any) .mockResolvedValue(makeRemoteConnectionSnapshot("studio")); useAppStore.setState({ @@ -601,8 +620,7 @@ describe("TopBar", () => { expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app (Connected)")).toBeTruthy(); expect(screen.getByText("Remote App")).toBeTruthy(); expect(screen.getByLabelText("Remote: Mac Studio")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Remote, connected" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Mobile, not connected" })).toBeTruthy(); + expect(await screen.findByRole("button", { name: "Connections, connected" })).toBeTruthy(); expect(globalThis.window.ade.sync.getStatus).not.toHaveBeenCalled(); }); @@ -628,19 +646,21 @@ describe("TopBar", () => { } as any); try { - render(); + renderTopBarWithRouter(); - expect(screen.getByRole("button", { name: "Mobile, not connected" })).toBeTruthy(); + expect(getConnectionsControl()).toBeTruthy(); expect(getStatus).not.toHaveBeenCalled(); - fireEvent.click(screen.getByTitle("Connect a phone to this machine")); + act(() => openConnectionsPanel("mobile")); + const mobileTab = screen.getByRole("tab", { name: "Mobile" }); await act(async () => { vi.advanceTimersByTime(0); await flushMicrotasks(2); }); - expect(screen.getByText("Connect to the ADE mobile app")).toBeTruthy(); - expect(screen.getByTestId("sync-devices-section")).toBeTruthy(); + expect(getConnectionsControl().getAttribute("aria-expanded")).toBe("true"); + expect(mobileTab.getAttribute("aria-selected")).toBe("true"); + expect(screen.getByTestId("sync-devices-section").getAttribute("data-variant")).toBe("phone"); expect(getStatus).toHaveBeenCalledWith({ includeTransferReadiness: false }); } finally { vi.useRealTimers(); @@ -675,7 +695,7 @@ describe("TopBar", () => { expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app (Disconnected)")).toBeTruthy(); expect(screen.getByLabelText("Disconnected: Mac Studio")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Remote, not connected" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Connections, not connected" })).toBeTruthy(); }); it("keeps local tabs visible when a remote project is active", async () => { @@ -1064,25 +1084,26 @@ describe("TopBar", () => { }); }); - it("opens the phone sync drawer from the host status control", async () => { + it("opens mobile sync from the connections control", async () => { vi.useFakeTimers(); try { - render(); + renderTopBarWithRouter(); - expect(screen.getByRole("button", { name: "Mobile, not connected" })).toBeTruthy(); + expect(getConnectionsControl()).toBeTruthy(); expect(globalThis.window.ade.sync.getStatus).not.toHaveBeenCalled(); await advancePhoneSyncStartupDelay(); - expect(screen.getByRole("button", { name: "Mobile, connected" })).toBeTruthy(); + const connectionsButton = screen.getByRole("button", { name: "Connections, connected" }); - fireEvent.click(screen.getByTitle("Connect a phone to this machine")); + fireEvent.click(connectionsButton); + const mobileTab = screen.getByRole("tab", { name: "Mobile" }); + fireEvent.click(mobileTab); - expect(screen.getByText("Connect to the ADE mobile app")).toBeTruthy(); - expect(screen.getByTestId("sync-devices-section")).toBeTruthy(); expect(screen.getByTestId("sync-devices-section").getAttribute("data-variant")).toBe("phone"); - expect(screen.getByTitle("Connect a phone to this machine").getAttribute("aria-expanded")).toBe("true"); + expect(mobileTab.getAttribute("aria-selected")).toBe("true"); + expect(connectionsButton.getAttribute("aria-expanded")).toBe("true"); - fireEvent.click(screen.getByTitle("Close phone sync")); + fireEvent.click(screen.getByTitle("Close connections")); expect(screen.queryByTestId("sync-devices-section")).toBeNull(); } finally { @@ -1090,29 +1111,27 @@ describe("TopBar", () => { } }); - it("opens the web client drawer from the web status control", async () => { + it("opens web clients from the connections control", async () => { vi.useFakeTimers(); try { - render(); + renderTopBarWithRouter(); - // Web chip is present alongside the mobile chip, disconnected until a - // browser peer shows up (mock snapshot only has a phone peer). - const webChip = screen.getByRole("button", { name: "Web, not connected" }); - expect(webChip).toBeTruthy(); - expect(webChip.getAttribute("title")).toBe("Pair a browser with this machine"); + const connectionsButton = getConnectionsControl(); + expect(connectionsButton.getAttribute("title")).toBe("Machines, mobile, and web clients"); await advancePhoneSyncStartupDelay(); - fireEvent.click(webChip); + fireEvent.click(screen.getByRole("button", { name: "Connections, connected" })); + const webTab = screen.getByRole("tab", { name: "Web client" }); + fireEvent.click(webTab); - expect(screen.getByText("Web client")).toBeTruthy(); const section = screen.getByTestId("sync-devices-section"); expect(section.getAttribute("data-variant")).toBe("web"); expect(section.closest("header")).toBeNull(); - expect(screen.getByTitle("Open the ADE web client in your browser")).toBeTruthy(); - expect(screen.getByRole("button", { name: "Web, not connected" }).getAttribute("aria-expanded")).toBe("true"); + expect(webTab.getAttribute("aria-selected")).toBe("true"); + expect(screen.getByRole("dialog", { name: "Connections" })).toBeTruthy(); - fireEvent.click(screen.getByTitle("Close web client")); + fireEvent.click(screen.getByTitle("Close connections")); expect(screen.queryByTestId("sync-devices-section")).toBeNull(); } finally { @@ -1120,7 +1139,7 @@ describe("TopBar", () => { } }); - it("marks the web chip connected when a browser peer is present", async () => { + it("marks the connections control connected when a browser peer is present", async () => { vi.useFakeTimers(); const getStatus = vi.fn().mockResolvedValue( makeSyncSnapshot({ @@ -1131,17 +1150,15 @@ describe("TopBar", () => { ); globalThis.window.ade.sync.getStatus = getStatus as any; try { - render(); + renderTopBarWithRouter(); await advancePhoneSyncStartupDelay(); - const webChip = screen.getByRole("button", { name: "Web, connected" }); - expect(webChip).toBeTruthy(); - expect(webChip.getAttribute("title")).toBe("1 connected · Chrome on MacBook Pro"); - expect(screen.getByRole("button", { name: "Mobile, not connected" })).toBeTruthy(); - fireEvent.click(webChip); - const dialog = screen.getByTitle("Close web client").closest('[role="dialog"]'); - expect(dialog?.textContent).toContain("1 web client connected to ADE Desktop"); + const connectionsButton = screen.getByRole("button", { name: "Connections, connected" }); + expect(connectionsButton.getAttribute("title")).toBe("Machines, mobile, and web clients"); + fireEvent.click(connectionsButton); + fireEvent.click(screen.getByRole("tab", { name: "Web client" })); + expect(screen.getByTestId("sync-devices-section").getAttribute("data-variant")).toBe("web"); } finally { vi.useRealTimers(); } @@ -1155,24 +1172,30 @@ describe("TopBar", () => { expect(screen.queryByRole("button", { name: "Web, not connected" })).toBeNull(); }); - it("keeps the phone and web sync sheets mutually exclusive", () => { - render(); + it("switches between mobile and web within one connections panel", () => { + renderTopBarWithRouter(); - fireEvent.click(screen.getByRole("button", { name: "Mobile, not connected" })); - expect(screen.getByTitle("Close phone sync")).toBeTruthy(); + fireEvent.click(getConnectionsControl()); + const mobileTab = screen.getByRole("tab", { name: "Mobile" }); + const webTab = screen.getByRole("tab", { name: "Web client" }); + fireEvent.click(mobileTab); + expect(screen.getByTestId("sync-devices-section").getAttribute("data-variant")).toBe("phone"); + expect(mobileTab.getAttribute("aria-selected")).toBe("true"); - fireEvent.click(screen.getByRole("button", { name: "Web, not connected" })); + fireEvent.click(webTab); - expect(screen.queryByTitle("Close phone sync")).toBeNull(); - expect(screen.getByTitle("Close web client")).toBeTruthy(); + expect(mobileTab.getAttribute("aria-selected")).toBe("false"); + expect(webTab.getAttribute("aria-selected")).toBe("true"); + expect(screen.getByTestId("sync-devices-section").getAttribute("data-variant")).toBe("web"); expect(screen.getAllByTestId("sync-devices-section")).toHaveLength(1); }); it("traps focus on the visible web clients summary", () => { - render(); - fireEvent.click(screen.getByRole("button", { name: "Web, not connected" })); + renderTopBarWithRouter(); + fireEvent.click(getConnectionsControl()); + fireEvent.click(screen.getByRole("tab", { name: "Web client" })); - const first = screen.getByTitle("Open the ADE web client in your browser"); + const first = screen.getByTitle("Close connections"); const summary = screen.getByText("Web clients summary"); summary.focus(); fireEvent.keyDown(summary, { key: "Tab" }); @@ -1183,42 +1206,44 @@ describe("TopBar", () => { expect(document.activeElement).toBe(summary); }); - it("closes the web sheet on Escape without disturbing chip state", () => { - render(); + it("closes the web tab on Escape without disturbing connection state", () => { + renderTopBarWithRouter(); - fireEvent.click(screen.getByRole("button", { name: "Web, not connected" })); - const dialog = screen.getByTitle("Close web client").closest('[role="dialog"]'); - expect(dialog).toBeTruthy(); + const connectionsButton = getConnectionsControl(); + fireEvent.click(connectionsButton); + fireEvent.click(screen.getByRole("tab", { name: "Web client" })); + const dialog = screen.getByRole("dialog", { name: "Connections" }); - fireEvent.keyDown(dialog as HTMLElement, { key: "Escape" }); + fireEvent.keyDown(dialog, { key: "Escape" }); expect(screen.queryByTestId("sync-devices-section")).toBeNull(); - expect( - screen.getByRole("button", { name: "Web, not connected" }).getAttribute("aria-expanded"), - ).toBe("false"); + expect(screen.queryByRole("dialog", { name: "Connections" })).toBeNull(); + expect(connectionsButton.getAttribute("aria-expanded")).toBe("false"); + expect(connectionsButton.getAttribute("aria-label")).toBe("Connections, not connected"); }); - it("occludes the native browser while the remote machines panel is open", async () => { + it("occludes the native browser while the connections machines tab is open", async () => { const events: string[] = []; const onStart = () => events.push("start"); const onEnd = () => events.push("end"); window.addEventListener(ADE_BROWSER_VIEW_OCCLUSION_START_EVENT, onStart); window.addEventListener(ADE_BROWSER_VIEW_OCCLUSION_END_EVENT, onEnd); try { - render(); + renderTopBarWithRouter(); - fireEvent.click(await screen.findByTitle("Manage remote machines")); + fireEvent.click(getConnectionsControl()); expect( - await screen.findByRole("dialog", { name: "Remote machines" }), + await screen.findByRole("dialog", { name: "Connections" }), ).toBeTruthy(); + expect(screen.getByRole("tab", { name: "Machines" }).getAttribute("aria-selected")).toBe("true"); await waitFor(() => expect(events).toEqual(["start"])); - fireEvent.click(screen.getByTitle("Close remote machines")); + fireEvent.click(screen.getByTitle("Close connections")); await waitFor(() => expect( - screen.queryByRole("dialog", { name: "Remote machines" }), + screen.queryByRole("dialog", { name: "Connections" }), ).toBeNull(), ); expect(events).toEqual(["start", "end"]); @@ -1228,23 +1253,26 @@ describe("TopBar", () => { } }); - it("occludes the native browser while the mobile panel is open", async () => { + it("occludes the native browser while the connections mobile tab is open", async () => { const events: string[] = []; const onStart = () => events.push("start"); const onEnd = () => events.push("end"); window.addEventListener(ADE_BROWSER_VIEW_OCCLUSION_START_EVENT, onStart); window.addEventListener(ADE_BROWSER_VIEW_OCCLUSION_END_EVENT, onEnd); try { - render(); + renderTopBarWithRouter(); - fireEvent.click(await screen.findByTitle("Connect a phone to this machine")); + fireEvent.click(getConnectionsControl()); + fireEvent.click(screen.getByRole("tab", { name: "Mobile" })); - expect(await screen.findByText("Connect to the ADE mobile app")).toBeTruthy(); + expect( + (await screen.findByTestId("sync-devices-section")).getAttribute("data-variant"), + ).toBe("phone"); await waitFor(() => expect(events).toEqual(["start"])); - fireEvent.click(screen.getByTitle("Close phone sync")); + fireEvent.click(screen.getByTitle("Close connections")); - await waitFor(() => expect(screen.queryByText("Connect to the ADE mobile app")).toBeNull()); + await waitFor(() => expect(screen.queryByTestId("sync-devices-section")).toBeNull()); expect(events).toEqual(["start", "end"]); } finally { window.removeEventListener(ADE_BROWSER_VIEW_OCCLUSION_START_EVENT, onStart); @@ -1252,7 +1280,7 @@ describe("TopBar", () => { } }); - it("refreshes the phone sync label from global sync events", async () => { + it("refreshes the connections state from global sync events", async () => { vi.useFakeTimers(); let syncEventHandler: ((event: any) => void) | null = null; const getStatus = vi.fn() @@ -1269,7 +1297,7 @@ describe("TopBar", () => { render(); await advancePhoneSyncStartupDelay(); - expect(screen.getByRole("button", { name: "Mobile, not connected" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Connections, not connected" })).toBeTruthy(); await act(async () => { syncEventHandler?.({ @@ -1282,7 +1310,7 @@ describe("TopBar", () => { }); }); - expect(screen.getByRole("button", { name: "Mobile, connected" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Connections, connected" })).toBeTruthy(); } finally { vi.useRealTimers(); } @@ -1313,7 +1341,7 @@ describe("TopBar", () => { } }); - it("refreshes phone sync status when the window regains focus", async () => { + it("refreshes connection status when the window regains focus", async () => { vi.useFakeTimers(); const getStatus = vi.fn() .mockResolvedValueOnce(makeSyncSnapshot({ connectedPeers: [] })) @@ -1328,14 +1356,14 @@ describe("TopBar", () => { await flushMicrotasks(2); }); - expect(screen.getByRole("button", { name: "Mobile, not connected" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Connections, not connected" })).toBeTruthy(); await act(async () => { window.dispatchEvent(new Event("focus")); await flushMicrotasks(2); }); - expect(screen.getByRole("button", { name: "Mobile, connected" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Connections, connected" })).toBeTruthy(); expect(getStatus).toHaveBeenCalledTimes(2); } finally { vi.useRealTimers(); From 2e0c2db5acb03df7252da9a3eb3373feec0861c6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:02:14 -0400 Subject: [PATCH 3/8] =?UTF-8?q?ship:=20iter=202=20=E2=80=94=20address=20re?= =?UTF-8?q?view:=20harden=20account-directory=20token=20trust=20(P1)=20+?= =?UTF-8?q?=20fix=20account-machine=20SSH=20port=20(P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 (Greptile, security): the machine account bearer in accountBridge.listMachines was sent to a baseUrl that a per-project ACCOUNT_DIRECTORY_URL secret could point at any host. Add parseTrustedDirectoryBaseUrl (https-only, http allowed on loopback) and resolve machine-level env FIRST, per-project secret only as fallback — a project can no longer redirect the machine token, and it is only ever attached to a trusted https/loopback origin. Adds a focused unit test. P2 (Codex, correctness): connectAccountMachine passed the account machine's ADE service endpoint port (e.g. 8787) as the SSH port, so Connect SSHed to the service port and failed. Leave the SSH port null (default 22), matching discoveredTargetInput; adds a regression test. Co-Authored-By: Claude Opus 4.8 --- .../account/accountBridge.trust.test.ts | 51 ++++++++++++++ .../main/services/account/accountBridge.ts | 59 ++++++++++++++-- .../remoteTargets/RemoteTargetList.test.tsx | 67 +++++++++++++++++++ .../remoteTargets/RemoteTargetList.tsx | 4 +- 4 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/main/services/account/accountBridge.trust.test.ts diff --git a/apps/desktop/src/main/services/account/accountBridge.trust.test.ts b/apps/desktop/src/main/services/account/accountBridge.trust.test.ts new file mode 100644 index 000000000..1f59b965c --- /dev/null +++ b/apps/desktop/src/main/services/account/accountBridge.trust.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { parseTrustedDirectoryBaseUrl } from "./accountBridge"; + +// The bearer sent to the directory is the machine's account token, so the only +// security-relevant unit is where that token is allowed to go: an https origin, +// or http on a loopback host for local dev. Everything else must be rejected. +describe("parseTrustedDirectoryBaseUrl", () => { + it("accepts an https URL and normalizes trailing slashes", () => { + expect(parseTrustedDirectoryBaseUrl("https://directory.ade.dev/")).toBe( + "https://directory.ade.dev", + ); + expect( + parseTrustedDirectoryBaseUrl("https://directory.ade.dev/account//"), + ).toBe("https://directory.ade.dev/account"); + }); + + it("accepts http on loopback hosts for local dev", () => { + expect(parseTrustedDirectoryBaseUrl("http://localhost:8787")).toBe( + "http://localhost:8787", + ); + expect(parseTrustedDirectoryBaseUrl("http://127.0.0.1:8787/")).toBe( + "http://127.0.0.1:8787", + ); + expect(parseTrustedDirectoryBaseUrl("http://[::1]:8787")).toBe( + "http://[::1]:8787", + ); + }); + + it("rejects http to a remote host", () => { + expect(parseTrustedDirectoryBaseUrl("http://evil.example.com")).toBeNull(); + expect( + parseTrustedDirectoryBaseUrl("http://directory.ade.dev/account"), + ).toBeNull(); + }); + + it("rejects bare, relative, or non-URL strings", () => { + expect(parseTrustedDirectoryBaseUrl("directory.ade.dev")).toBeNull(); + expect(parseTrustedDirectoryBaseUrl("/account/machines")).toBeNull(); + expect(parseTrustedDirectoryBaseUrl("not a url")).toBeNull(); + expect(parseTrustedDirectoryBaseUrl("")).toBeNull(); + expect(parseTrustedDirectoryBaseUrl(" ")).toBeNull(); + expect(parseTrustedDirectoryBaseUrl(null)).toBeNull(); + expect(parseTrustedDirectoryBaseUrl(undefined)).toBeNull(); + }); + + it("rejects non-http(s) schemes", () => { + expect(parseTrustedDirectoryBaseUrl("ftp://directory.ade.dev")).toBeNull(); + expect(parseTrustedDirectoryBaseUrl("ws://localhost:8787")).toBeNull(); + expect(parseTrustedDirectoryBaseUrl("file:///etc/passwd")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/services/account/accountBridge.ts b/apps/desktop/src/main/services/account/accountBridge.ts index b49bc2937..761dd0afa 100644 --- a/apps/desktop/src/main/services/account/accountBridge.ts +++ b/apps/desktop/src/main/services/account/accountBridge.ts @@ -58,13 +58,59 @@ function isLoginConfigured(projectRoot: string | null): boolean { return Boolean(issuer && clientId); } +function isLoopbackHost(hostname: string): boolean { + // URL.hostname wraps IPv6 in brackets (e.g. "[::1]"); strip them before match. + const host = hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return host === "localhost" || host === "127.0.0.1" || host === "::1"; +} + +/** + * Trust boundary for the machine account bearer. `listMachines` attaches the + * machine's account token to `${baseUrl}/account/machines`, so `baseUrl` must be + * an origin the machine owner explicitly trusts — never one that a per-project + * secret can point at an arbitrary host. Accept only an absolute `https:` origin + * (or `http:` on a loopback host for local dev) and reject everything else, so + * the bearer can only ever leave over TLS (or stay on the local machine). + * Returns the normalized base URL (trailing slashes stripped) or null. + */ +export function parseTrustedDirectoryBaseUrl( + raw: string | null | undefined, +): string | null { + const trimmed = raw?.trim(); + if (!trimmed) return null; + let url: URL; + try { + url = new URL(trimmed); + } catch { + return null; + } + if ( + url.protocol === "https:" || + (url.protocol === "http:" && isLoopbackHost(url.hostname)) + ) { + return trimmed.replace(/\/+$/, ""); + } + return null; +} + +/** + * Resolve the machine directory origin the account bearer may be sent to. + * + * Trust model: the bearer is the MACHINE's account token (machine-scoped + * infrastructure), so where it is sent must be controlled by the machine owner, + * not by whatever project happens to be open. Read the machine-level + * `ADE_ACCOUNT_DIRECTORY_URL` env FIRST and only fall back to the per-project + * `ACCOUNT_DIRECTORY_URL` secret when no machine-level value is set — a project + * can never redirect a machine-configured directory. The chosen value is then + * passed through `parseTrustedDirectoryBaseUrl`, so the token is only ever + * attached to a trusted https (or loopback) origin. + */ function resolveDirectoryBaseUrl(projectRoot: string | null): string | null { const raw = - readProjectSecret(projectRoot, DIRECTORY_URL_SECRET) - ?? process.env.ADE_ACCOUNT_DIRECTORY_URL?.trim() - ?? ""; - if (!raw) return null; - return raw.replace(/\/+$/, ""); + process.env.ADE_ACCOUNT_DIRECTORY_URL?.trim() + || readProjectSecret(projectRoot, DIRECTORY_URL_SECRET) + || null; + return parseTrustedDirectoryBaseUrl(raw); } function toAccountStatus( @@ -169,7 +215,8 @@ export function createAccountBridge(options: AccountBridgeOptions): AccountBridg return { state: "not_configured", machines: [], - message: "Machine directory isn't configured on this machine yet.", + message: + "Machine directory isn't configured — set a trusted https directory URL on this machine.", }; } let token: string; diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx index 4516645ff..61be2d712 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx @@ -8,6 +8,7 @@ import { waitFor, } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AdeAccountMachine } from "../../../shared/types"; import { RemoteTargetList } from "./RemoteTargetList"; const remoteRuntimeMock = { @@ -1082,4 +1083,70 @@ describe("RemoteTargetList", () => { ); expect(screen.getByText("No saved or detected machines yet.")).toBeTruthy(); }); + + it("does not reuse an account machine's service port as the SSH port", async () => { + remoteRuntimeMock.listTargets.mockResolvedValue([]); + remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ + machines: [], + diagnostics: [], + }); + const savedTarget = { + id: "target-account", + name: "Cloud Studio", + hostname: "100.92.14.3", + sshUser: null, + port: null, + sshKeyPath: null, + routes: null, + lastSeenArch: null, + runtimeBinaryVersion: null, + lastConnectedAt: null, + }; + remoteRuntimeMock.saveTarget.mockResolvedValue(savedTarget); + remoteRuntimeMock.connect.mockResolvedValue({ + target: savedTarget, + arch: "darwin-arm64", + version: "1.0.0", + projects: [], + }); + installAdeMock(); + + // The directory advertises the ADE service port (8787), not an SSH port. + const accountMachines: AdeAccountMachine[] = [ + { + machineKey: "mk_cloud", + deviceId: "dev_cloud", + name: "Cloud Studio", + platform: "darwin", + deviceType: "desktop", + reachableEndpoints: [ + { kind: "tailnet", host: "100.92.14.3", port: 8787 }, + ], + lastSeenAt: Date.now() - 30_000, + online: true, + }, + ]; + + render( + , + ); + + await waitFor(() => + expect(screen.getByText("Cloud Studio")).toBeTruthy(), + ); + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => + expect(remoteRuntimeMock.saveTarget).toHaveBeenCalledWith( + expect.objectContaining({ + hostname: "100.92.14.3", + // The 8787 service port must NOT become the SSH port; leave it default. + port: null, + }), + ), + ); + }); }); diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx index 647f317bd..7046d2e75 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx @@ -446,7 +446,9 @@ export function RemoteTargetList({ name: machine.name ?? hostInfo.host, hostname: hostInfo.host.replace(/\.$/, ""), sshUser: null, - port: hostInfo.port, + // hostInfo.port is the ADE service port (lan/tailnet), not an SSH port — + // leave it null so the SSH transport falls back to its default (22). + port: null, sshKeyPath: null, routes: null, }; From c5f2fb4c67495b32bde84f7bda22388d613ce36c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:27:10 -0400 Subject: [PATCH 4/8] =?UTF-8?q?ship:=20iter=203=20=E2=80=94=20close=20resi?= =?UTF-8?q?dual=20review=20findings=20(token=20trust=20+=20dedup=20port)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex re-review of the prior fix surfaced the next instance of each class: P1 (accountBridge): env-first still fell back to the per-project ACCOUNT_DIRECTORY_URL secret when ADE_ACCOUNT_DIRECTORY_URL was unset, so a project could still point the directory at an https host it controls. Resolve the machine account bearer's destination from the machine-level env ONLY; the project secret is no longer consulted, so an opened project can never redirect the token. P2 (remoteMachineModel): accountMachineRouteIdentities keyed dedup on the advertised service port (e.g. 8787), so an account machine never matched its saved SSH target (:22) and showed a duplicate row. Key identities on host at the default SSH port, ignoring the service port. Adds a regression test with a realistic 8787 endpoint. Co-Authored-By: Claude Opus 4.8 --- .../main/services/account/accountBridge.ts | 25 ++++++--------- .../remoteMachineModel.account.test.ts | 32 +++++++++++++++++++ .../remoteTargets/remoteMachineModel.ts | 11 +++++-- 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/main/services/account/accountBridge.ts b/apps/desktop/src/main/services/account/accountBridge.ts index 761dd0afa..4b726d7a6 100644 --- a/apps/desktop/src/main/services/account/accountBridge.ts +++ b/apps/desktop/src/main/services/account/accountBridge.ts @@ -26,7 +26,6 @@ import type { AdeAccountStatus, } from "../../../shared/types"; -const DIRECTORY_URL_SECRET = "ACCOUNT_DIRECTORY_URL"; const MACHINES_FETCH_TIMEOUT_MS = 8_000; type AccountBridgeOptions = { @@ -97,20 +96,15 @@ export function parseTrustedDirectoryBaseUrl( * Resolve the machine directory origin the account bearer may be sent to. * * Trust model: the bearer is the MACHINE's account token (machine-scoped - * infrastructure), so where it is sent must be controlled by the machine owner, - * not by whatever project happens to be open. Read the machine-level - * `ADE_ACCOUNT_DIRECTORY_URL` env FIRST and only fall back to the per-project - * `ACCOUNT_DIRECTORY_URL` secret when no machine-level value is set — a project - * can never redirect a machine-configured directory. The chosen value is then - * passed through `parseTrustedDirectoryBaseUrl`, so the token is only ever - * attached to a trusted https (or loopback) origin. + * infrastructure), so where it is sent must be controlled by the machine owner + * alone. We read ONLY the machine-level `ADE_ACCOUNT_DIRECTORY_URL` env — the + * per-project `ACCOUNT_DIRECTORY_URL` secret is deliberately NOT consulted, so + * an opened project can never redirect the token to a host it controls. The + * value is passed through `parseTrustedDirectoryBaseUrl`, so the token is only + * ever attached to a trusted https (or loopback) origin. */ -function resolveDirectoryBaseUrl(projectRoot: string | null): string | null { - const raw = - process.env.ADE_ACCOUNT_DIRECTORY_URL?.trim() - || readProjectSecret(projectRoot, DIRECTORY_URL_SECRET) - || null; - return parseTrustedDirectoryBaseUrl(raw); +function resolveDirectoryBaseUrl(): string | null { + return parseTrustedDirectoryBaseUrl(process.env.ADE_ACCOUNT_DIRECTORY_URL); } function toAccountStatus( @@ -209,8 +203,7 @@ export function createAccountBridge(options: AccountBridgeOptions): AccountBridg if (!svc.getStatus().signedIn) { return { state: "signed_out", machines: [], message: null }; } - const projectRoot = options.getProjectRoot(); - const baseUrl = resolveDirectoryBaseUrl(projectRoot); + const baseUrl = resolveDirectoryBaseUrl(); if (!baseUrl) { return { state: "not_configured", diff --git a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts index 7173e51ca..ff8256780 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts @@ -92,6 +92,38 @@ describe("assignMachineSections — account machines", () => { ).toBe(false); }); + it("matches a saved SSH target even when the account endpoint advertises the ADE service port", () => { + // Real account-directory endpoints advertise the ADE service port (8787), + // not the SSH port — matching/dedup must ignore it and key on the host. + expect( + accountMachineMatchesTarget( + accountMachine({ + reachableEndpoints: [{ kind: "tailnet", host: "100.92.14.3", port: 8787 }], + }), + savedTarget(), + ), + ).toBe(true); + + const sections = assignMachineSections({ + targets: [savedTarget()], + statusById: NO_STATUS, + connectedFallbackId: null, + discoveredMachines: [], + accountMachines: [ + accountMachine({ + machineKey: "mk_service_port", + reachableEndpoints: [{ kind: "tailnet", host: "100.92.14.3", port: 8787 }], + }), + ], + }); + const accountRows = [ + ...sections.connected, + ...sections.available, + ...sections.unavailable, + ].filter((row) => row.kind === "account"); + expect(accountRows).toHaveLength(0); + }); + it("is a no-op when no account machines are supplied", () => { const sections = assignMachineSections({ targets: [], diff --git a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts index 6bab60723..f96b7768a 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts @@ -355,14 +355,21 @@ export type MachineSections = { unavailable: MachineRow[]; }; -/** Host:port identities advertised by an account machine's reachable endpoints. */ +/** + * Host identities advertised by an account machine's reachable endpoints, keyed + * at the default SSH port. The advertised endpoint port is the ADE service port + * (e.g. 8787), not an SSH port, so it is deliberately ignored when building the + * identity — otherwise an account machine would never dedupe against its saved + * SSH target or a discovered peer (both keyed at :22) and would surface as a + * duplicate row. + */ export function accountMachineRouteIdentities( machine: AdeAccountMachine, ): Set { const identities = new Set(); for (const endpoint of machine.reachableEndpoints) { const host = endpoint.host ?? endpoint.url ?? null; - const identity = routeIdentity(host, endpoint.port ?? null); + const identity = routeIdentity(host, null); if (identity) identities.add(identity); } return identities; From a99a203b4cb749a3606ed5825947afc7df22f91e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:57:34 -0400 Subject: [PATCH 5/8] =?UTF-8?q?ship:=20iter=204=20=E2=80=94=20address=20re?= =?UTF-8?q?view:=20leave=20/account=20on=20tab=20switch=20(P1)=20+=20relay?= =?UTF-8?q?-only=20account=20machines=20out=20of=20AVAILABLE=20(P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 (Codex, account routing): switching project tabs while /account was open changed the active binding but left the URL on /account, so the user kept seeing the Account page. Derive isAccountRoute in AppShell and plumb accountRouteActive into TopBar; generalize leavePersonalChatsRoute → leaveMachineRoute so a project-tab switch also leaves the /account machine route (same stored-route replay as /chats). P2 (Codex, relay-only bucketing): an online account machine advertising only relay endpoints has no directly-connectable host (accountMachineConnectHost skips relay), so it appeared in AVAILABLE as a dead row with no Connect and no explanation. Add accountMachineHasDirectRoute; keep relay-only online machines out of AVAILABLE, and have AccountMachineRow show "relay only" copy instead of a false offline/asleep label. Adds bucketing regression tests. Co-Authored-By: Claude Opus 4.8 --- .../src/renderer/components/app/AppShell.tsx | 3 ++ .../src/renderer/components/app/TopBar.tsx | 30 ++++++++-------- .../remoteTargets/AccountMachineRow.tsx | 12 +++++-- .../remoteMachineModel.account.test.ts | 36 +++++++++++++++++++ .../remoteTargets/remoteMachineModel.ts | 11 +++++- 5 files changed, 74 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index e6485f1c2..8d458777c 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -416,6 +416,8 @@ export function AppShell({ children }: { children: React.ReactNode }) { const isOnboardingRoute = location.pathname === "/onboarding"; const isPersonalChatsRoute = location.pathname === "/chats" || location.pathname.startsWith("/chats/"); + const isAccountRoute = + location.pathname === "/account" || location.pathname.startsWith("/account/"); const isLanesRoute = location.pathname.startsWith("/lanes"); const isWorkRoute = location.pathname === "/work" || location.pathname.startsWith("/work/"); const productAnalyticsScreen = productAnalyticsScreenForPathname(location.pathname); @@ -1282,6 +1284,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
navigate(path, opts)} />
diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index a5c074a1a..13c3ae9a3 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -823,9 +823,11 @@ function ProjectTabIcon({ export function TopBar({ personalChatsRouteActive = false, + accountRouteActive = false, onNavigate, }: { personalChatsRouteActive?: boolean; + accountRouteActive?: boolean; onNavigate?: (path: string, opts?: { replace?: boolean }) => void; } = {}) { const project = useAppStore((s) => s.project); @@ -1292,21 +1294,21 @@ export function TopBar({ const handleOpenNew = useCallback(() => { if (isProjectBusy) return; openNewTab(); - if (personalChatsRouteActive) onNavigate?.("/work"); - }, [isProjectBusy, onNavigate, openNewTab, personalChatsRouteActive]); + if (personalChatsRouteActive || accountRouteActive) onNavigate?.("/work"); + }, [accountRouteActive, isProjectBusy, onNavigate, openNewTab, personalChatsRouteActive]); const handleOpenNewWindow = useCallback(() => { if (isProjectBusy) return; window.ade.app.newWindow().catch(() => {}); }, [isProjectBusy]); - // Clicking a project tab while the Chats machine tab is foreground must - // leave /chats, or ProjectTabHost's route replay (which skips personal chats - // routes) never surfaces the project. Navigate to the CURRENT binding's - // stored route so the route-cache effect writes that same value back instead - // of stamping /work over the project's remembered position. - const leavePersonalChatsRoute = useCallback(() => { - if (!personalChatsRouteActive) return; + // Clicking a project tab while either the personal-chats or account machine + // route is foreground must leave it, or ProjectTabHost's route replay never + // surfaces the project. Navigate to the CURRENT binding's stored route so the + // route-cache effect writes that same value back instead of stamping /work + // over the project's remembered position. + const leaveMachineRoute = useCallback(() => { + if (!personalChatsRouteActive && !accountRouteActive) return; const currentBindingKey = remoteBinding ? remoteBinding.key : project?.rootPath @@ -1314,12 +1316,12 @@ export function TopBar({ : null; const route = (currentBindingKey ? readStoredProjectRoute(currentBindingKey) : null) ?? "/work"; onNavigate?.(route, { replace: true }); - }, [onNavigate, personalChatsRouteActive, project?.rootPath, remoteBinding]); + }, [accountRouteActive, onNavigate, personalChatsRouteActive, project?.rootPath, remoteBinding]); const handleSwitchProject = useCallback( (rootPath: string) => { if (isProjectBusy) return; - leavePersonalChatsRoute(); + leaveMachineRoute(); if (!remoteBinding && project?.rootPath === rootPath) { cancelNewTab(); return; @@ -1329,7 +1331,7 @@ export function TopBar({ [ cancelNewTab, isProjectBusy, - leavePersonalChatsRoute, + leaveMachineRoute, project?.rootPath, remoteBinding, switchProjectToPath, @@ -1339,7 +1341,7 @@ export function TopBar({ const handleSwitchRemoteProject = useCallback( (binding: RemoteProjectTab) => { if (isProjectBusy) return; - leavePersonalChatsRoute(); + leaveMachineRoute(); if (remoteBinding?.key === binding.key) { cancelNewTab(); return; @@ -1349,7 +1351,7 @@ export function TopBar({ [ cancelNewTab, isProjectBusy, - leavePersonalChatsRoute, + leaveMachineRoute, remoteBinding?.key, switchRemoteProject, ], diff --git a/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx b/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx index 1bacee67c..64e87e5b4 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx @@ -59,6 +59,8 @@ export function AccountMachineRow({ const primaryEndpoint = machine.reachableEndpoints[0]; const connectHost = accountMachineConnectHost(machine); const isOffline = section === "unavailable"; + const relayOnly = machine.online && !connectHost; + const trulyOffline = !machine.online; return (
@@ -91,7 +93,11 @@ export function AccountMachineRow({ {[machine.platform, machine.deviceType].filter(Boolean).join(" · ") || "Registered on your account"}
- {machine.online ? "Online · reachable on your account" : relativeLastSeen(machine.lastSeenAt)} + {relayOnly + ? "Online · relay only — connect from a machine on its network" + : machine.online + ? "Online · reachable on your account" + : relativeLastSeen(machine.lastSeenAt)}
@@ -107,7 +113,7 @@ export function AccountMachineRow({ {connecting ? "Connecting…" : "Connect"} ) : null} - {isOffline ? ( + {trulyOffline ? (
- {isOffline && detailOpen ? ( + {trulyOffline && detailOpen ? (
{relativeLastSeen(machine.lastSeenAt)} diff --git a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts index ff8256780..87b866acf 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts @@ -63,6 +63,42 @@ describe("assignMachineSections — account machines", () => { expect(sections.available[0]).toMatchObject({ kind: "account" }); }); + it("buckets an online relay-only account machine into UNAVAILABLE", () => { + const sections = assignMachineSections({ + targets: [], + statusById: NO_STATUS, + connectedFallbackId: null, + discoveredMachines: [], + accountMachines: [ + accountMachine({ + machineKey: "mk_relay_only", + reachableEndpoints: [{ kind: "relay", url: "wss://relay/x" }], + }), + ], + }); + + expect(sections.available).toHaveLength(0); + expect(sections.unavailable.map((row) => row.id)).toEqual(["account:mk_relay_only"]); + }); + + it("keeps an online account machine with a direct route in AVAILABLE", () => { + const sections = assignMachineSections({ + targets: [], + statusById: NO_STATUS, + connectedFallbackId: null, + discoveredMachines: [], + accountMachines: [ + accountMachine({ + machineKey: "mk_lan", + reachableEndpoints: [{ kind: "lan", host: "10.0.0.9", port: 22 }], + }), + ], + }); + + expect(sections.available.map((row) => row.id)).toEqual(["account:mk_lan"]); + expect(sections.unavailable).toHaveLength(0); + }); + it("dedupes an account machine that maps to a saved target by host identity", () => { const sections = assignMachineSections({ targets: [savedTarget()], diff --git a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts index f96b7768a..5ee3705ef 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts @@ -375,6 +375,13 @@ export function accountMachineRouteIdentities( return identities; } +/** True when an account machine advertises a directly-connectable (non-relay) endpoint. */ +export function accountMachineHasDirectRoute(machine: AdeAccountMachine): boolean { + return machine.reachableEndpoints.some( + (endpoint) => endpoint.kind !== "relay" && Boolean(endpoint.host ?? endpoint.url), + ); +} + function intersects(a: Set, b: Set): boolean { for (const value of a) { if (b.has(value)) return true; @@ -529,7 +536,9 @@ export function assignMachineSections(args: { machine, matchedTargetId: null, }; - if (machine.online) { + // A relay-only online machine has no direct SSH route, so it must not sit in + // AVAILABLE as a dead (unconnectable) row. + if (machine.online && accountMachineHasDirectRoute(machine)) { sections.available.push(row); } else { sections.unavailable.push(row); From 1ffe1720ce02ec8307e2b39750f86075eabf3ea7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:39:25 -0400 Subject: [PATCH 6/8] =?UTF-8?q?ship:=20iter=205=20=E2=80=94=20normalize=20?= =?UTF-8?q?account=20endpoint=20URLs=20to=20a=20bare=20host=20(Codex=20P2?= =?UTF-8?q?=20+=20Greptile=20P1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A direct lan/tailnet account endpoint may populate only `url` (e.g. http://10.0.0.9:8787), not `host`. The old `endpoint.host ?? endpoint.url` then fed the whole URL string into the SSH hostname, so Connect saved an unresolvable `RemoteRuntimeTargetInput.hostname` and dedupe keyed on the URL. Add accountEndpointHost() (parses url-valued endpoints to the hostname alone) and use it in the connect path (accountMachineConnectHost), the dedupe identities, and the has-direct-route check. Adds regression tests: URL-valued connect saves the bare host, and a URL-valued endpoint dedupes against its saved target. Co-Authored-By: Claude Opus 4.8 --- .../remoteTargets/AccountMachineRow.tsx | 8 ++++-- .../remoteTargets/RemoteTargetList.test.tsx | 5 ++-- .../remoteMachineModel.account.test.ts | 25 ++++++++++++++++++ .../remoteTargets/remoteMachineModel.ts | 26 +++++++++++++++++-- 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx b/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx index 64e87e5b4..dd6ced3a0 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx @@ -2,7 +2,11 @@ import { CaretDown, CaretUp, Cloud, PlugsConnected } from "@phosphor-icons/react import type { AdeAccountMachine } from "../../../shared/types"; import { COLORS, SANS_FONT, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; import { ConnectionDoctorPanel } from "./ConnectionDoctorPanel"; -import type { AccountMachineRow as AccountMachineRowModel, MachineSection } from "./remoteMachineModel"; +import { + accountEndpointHost, + type AccountMachineRow as AccountMachineRowModel, + type MachineSection, +} from "./remoteMachineModel"; import { helperTextStyle, inlineDetailStyle, machineRowStyle, nameStyle, subTextStyle } from "./remoteTargetListStyles"; type AccountMachineRowProps = { @@ -23,7 +27,7 @@ export function accountMachineConnectHost(machine: AdeAccountMachine): { host: s }); for (const endpoint of ranked) { if (endpoint.kind === "relay") continue; - const host = endpoint.host ?? endpoint.url ?? null; + const host = accountEndpointHost(endpoint); if (host) return { host, port: endpoint.port ?? null }; } return null; diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx index 61be2d712..478836402 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx @@ -1084,7 +1084,7 @@ describe("RemoteTargetList", () => { expect(screen.getByText("No saved or detected machines yet.")).toBeTruthy(); }); - it("does not reuse an account machine's service port as the SSH port", async () => { + it("parses an account endpoint URL without reusing its service port", async () => { remoteRuntimeMock.listTargets.mockResolvedValue([]); remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ machines: [], @@ -1120,7 +1120,7 @@ describe("RemoteTargetList", () => { platform: "darwin", deviceType: "desktop", reachableEndpoints: [ - { kind: "tailnet", host: "100.92.14.3", port: 8787 }, + { kind: "tailnet", url: "http://100.92.14.3:8787" }, ], lastSeenAt: Date.now() - 30_000, online: true, @@ -1142,6 +1142,7 @@ describe("RemoteTargetList", () => { await waitFor(() => expect(remoteRuntimeMock.saveTarget).toHaveBeenCalledWith( expect.objectContaining({ + // The endpoint URL must be reduced to its bare SSH hostname. hostname: "100.92.14.3", // The 8787 service port must NOT become the SSH port; leave it default. port: null, diff --git a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts index 87b866acf..3b0fc4a38 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts @@ -118,6 +118,31 @@ describe("assignMachineSections — account machines", () => { expect(sections.available.some((row) => row.kind === "saved")).toBe(true); }); + it("dedupes a URL-valued account endpoint against a saved target by host identity", () => { + const sections = assignMachineSections({ + targets: [savedTarget()], + statusById: NO_STATUS, + connectedFallbackId: null, + discoveredMachines: [], + accountMachines: [ + accountMachine({ + machineKey: "mk_url_dupe", + reachableEndpoints: [ + { kind: "tailnet", url: "https://100.92.14.3:8787" }, + ], + }), + ], + }); + + const accountRows = [ + ...sections.connected, + ...sections.available, + ...sections.unavailable, + ].filter((row) => row.kind === "account"); + expect(accountRows).toHaveLength(0); + expect(sections.available.some((row) => row.kind === "saved")).toBe(true); + }); + it("accountMachineMatchesTarget matches on shared host:port", () => { expect(accountMachineMatchesTarget(accountMachine(), savedTarget())).toBe(true); expect( diff --git a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts index 5ee3705ef..53f84d58e 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts @@ -1,6 +1,7 @@ import { extractError } from "../../lib/format"; import type { AdeAccountMachine, + AdeAccountMachineEndpoint, RemoteRuntimeConnectErrorInfo, RemoteRuntimeConnectionRoute, RemoteRuntimeConnectionStatus, @@ -355,6 +356,27 @@ export type MachineSections = { unavailable: MachineRow[]; }; +/** + * The bare host/address for an account endpoint. Directory endpoints may carry + * either a plain `host` or a full `url` (e.g. https://100.92.14.3:8787); the SSH + * connect + dedupe paths need the hostname alone, never the whole URL string. + */ +export function accountEndpointHost( + endpoint: AdeAccountMachineEndpoint, +): string | null { + const host = endpoint.host?.trim(); + if (host) return host.replace(/\.$/, ""); + const raw = endpoint.url?.trim(); + if (!raw) return null; + try { + // URL.hostname wraps IPv6 in brackets; strip them for a usable SSH host. + return new URL(raw).hostname.replace(/^\[|\]$/g, "") || null; + } catch { + // Not a URL — accept a bare host (no scheme/slash/space) as-is. + return /^[^/\s]+$/.test(raw) ? raw.replace(/\.$/, "") : null; + } +} + /** * Host identities advertised by an account machine's reachable endpoints, keyed * at the default SSH port. The advertised endpoint port is the ADE service port @@ -368,7 +390,7 @@ export function accountMachineRouteIdentities( ): Set { const identities = new Set(); for (const endpoint of machine.reachableEndpoints) { - const host = endpoint.host ?? endpoint.url ?? null; + const host = accountEndpointHost(endpoint); const identity = routeIdentity(host, null); if (identity) identities.add(identity); } @@ -378,7 +400,7 @@ export function accountMachineRouteIdentities( /** True when an account machine advertises a directly-connectable (non-relay) endpoint. */ export function accountMachineHasDirectRoute(machine: AdeAccountMachine): boolean { return machine.reachableEndpoints.some( - (endpoint) => endpoint.kind !== "relay" && Boolean(endpoint.host ?? endpoint.url), + (endpoint) => endpoint.kind !== "relay" && accountEndpointHost(endpoint) != null, ); } From 2eb435f36a7f31ff1ac5cd150e33a70b3260e0c6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:01:29 -0400 Subject: [PATCH 7/8] =?UTF-8?q?ship:=20iter=206=20=E2=80=94=20address=20Co?= =?UTF-8?q?deRabbit=20(7)=20+=20Codex=20fallback-routes=20review=20finding?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit: - accountBridge parseTrustedDirectoryBaseUrl: reject credentials/query/fragment, return normalized origin+path (P: Minor). - registerIpc: redact account IPC payloads (session IDs, OAuth authorizeUrl, and account/machine PII) from trace logs (P: Major, security). - browserMock: persist login/sign-out transitions via the ade.mock.account flag (Minor). - AccountPage.handleSignOut: surface sign-out failures via inline error state instead of swallowing them (Minor). - ConnectionsPanel: reload account machines whenever the Machines tab opens, not just on mount (Minor). - account.ts fetchAccountStatus: request-serial guard so only the latest fetch emits/clears inFlight (Minor, race). - accountLogin: check cancelledRef after startLogin() resolves and cancel the orphaned session so unmount/cancel no longer opens an OAuth window or polls (P: Major, race). Codex: - RemoteTargetList.connectAccountMachine: carry all non-relay endpoints as target.routes (accountMachineSshRoutes) so a saved account machine keeps SSH fallback candidates like a discovered machine, instead of dropping them (P2). Tests: accountBridge trust cases (credentials/query/fragment/path), accountMachineSshRoutes ordering + relay exclusion. Whole-desktop tsc clean. Co-Authored-By: Claude Opus 4.8 --- .../account/accountBridge.trust.test.ts | 8 ++ .../main/services/account/accountBridge.ts | 4 +- .../src/main/services/ipc/registerIpc.ts | 73 +++++++++++++++++-- apps/desktop/src/renderer/browserMock.ts | 29 ++++++-- .../components/account/AccountPage.tsx | 11 +++ .../components/app/ConnectionsPanel.tsx | 7 +- .../remoteTargets/RemoteTargetList.tsx | 3 +- .../remoteMachineModel.account.test.ts | 36 +++++++++ .../remoteTargets/remoteMachineModel.ts | 35 +++++++++ apps/desktop/src/renderer/lib/account.ts | 6 +- apps/desktop/src/renderer/lib/accountLogin.ts | 4 + 11 files changed, 196 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/main/services/account/accountBridge.trust.test.ts b/apps/desktop/src/main/services/account/accountBridge.trust.test.ts index 1f59b965c..efcd16c7b 100644 --- a/apps/desktop/src/main/services/account/accountBridge.trust.test.ts +++ b/apps/desktop/src/main/services/account/accountBridge.trust.test.ts @@ -9,6 +9,8 @@ describe("parseTrustedDirectoryBaseUrl", () => { expect(parseTrustedDirectoryBaseUrl("https://directory.ade.dev/")).toBe( "https://directory.ade.dev", ); + expect(parseTrustedDirectoryBaseUrl("https://h/base/")).toBe("https://h/base"); + expect(parseTrustedDirectoryBaseUrl("https://h/")).toBe("https://h"); expect( parseTrustedDirectoryBaseUrl("https://directory.ade.dev/account//"), ).toBe("https://directory.ade.dev/account"); @@ -48,4 +50,10 @@ describe("parseTrustedDirectoryBaseUrl", () => { expect(parseTrustedDirectoryBaseUrl("ws://localhost:8787")).toBeNull(); expect(parseTrustedDirectoryBaseUrl("file:///etc/passwd")).toBeNull(); }); + + it("rejects credentials, query strings, and fragments", () => { + expect(parseTrustedDirectoryBaseUrl("https://h?x=1")).toBeNull(); + expect(parseTrustedDirectoryBaseUrl("https://h#f")).toBeNull(); + expect(parseTrustedDirectoryBaseUrl("https://user:pass@h")).toBeNull(); + }); }); diff --git a/apps/desktop/src/main/services/account/accountBridge.ts b/apps/desktop/src/main/services/account/accountBridge.ts index 4b726d7a6..405e80588 100644 --- a/apps/desktop/src/main/services/account/accountBridge.ts +++ b/apps/desktop/src/main/services/account/accountBridge.ts @@ -87,7 +87,9 @@ export function parseTrustedDirectoryBaseUrl( url.protocol === "https:" || (url.protocol === "http:" && isLoopbackHost(url.hostname)) ) { - return trimmed.replace(/\/+$/, ""); + if (url.username || url.password || url.search || url.hash) return null; + const pathname = url.pathname.replace(/\/+$/, ""); + return `${url.origin}${pathname}`; } return null; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index b0203a4c6..690e91884 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1788,6 +1788,8 @@ export function registerIpc({ [IPC.builtInBrowserCreateTab]: new Set(["url"]), [IPC.builtInBrowserShowPanel]: new Set(["url"]), [IPC.transcriptionTranscribe]: new Set(["pcm"]), + [IPC.accountPollLogin]: new Set(["sessionId"]), + [IPC.accountCancelLogin]: new Set(["sessionId"]), }; const redactIpcArgsForChannel = (channel: string, args: unknown[]): unknown[] => { @@ -1861,13 +1863,70 @@ export function registerIpc({ }); const redactIpcResultForChannel = (channel: string, result: unknown): unknown => { - if (channel !== IPC.transcriptionTranscribe) return result; - if (!result || typeof result !== "object" || Array.isArray(result)) return "[redacted]"; - return { - ...(result as Record), - raw: "[redacted]", - cleaned: "[redacted]", - }; + if (channel === IPC.transcriptionTranscribe) { + if (!result || typeof result !== "object" || Array.isArray(result)) return "[redacted]"; + return { + ...(result as Record), + raw: "[redacted]", + cleaned: "[redacted]", + }; + } + if (!result || typeof result !== "object" || Array.isArray(result)) return result; + const record = result as Record; + if (channel === IPC.accountStartLogin) { + return { + ...record, + sessionId: "[redacted]", + authorizeUrl: "[redacted]", + }; + } + if ( + channel === IPC.accountStatus + || channel === IPC.accountCancelLogin + || channel === IPC.accountSignOut + ) { + return { + ...record, + userId: "[redacted]", + email: "[redacted]", + name: "[redacted]", + imageUrl: "[redacted]", + }; + } + if (channel === IPC.accountPollLogin) { + const authStatus = record.authStatus; + return { + ...record, + authStatus: authStatus && typeof authStatus === "object" && !Array.isArray(authStatus) + ? { + ...(authStatus as Record), + userId: "[redacted]", + email: "[redacted]", + name: "[redacted]", + imageUrl: "[redacted]", + } + : authStatus, + }; + } + if (channel === IPC.accountListMachines) { + return { + ...record, + machines: Array.isArray(record.machines) + ? record.machines.map((machine) => ( + machine && typeof machine === "object" && !Array.isArray(machine) + ? { + ...(machine as Record), + machineKey: "[redacted]", + deviceId: "[redacted]", + name: "[redacted]", + reachableEndpoints: "[redacted]", + } + : machine + )) + : record.machines, + }; + } + return result; }; const getTraceLogger = (): Pick => { diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 14860734a..bbbb9d6ca 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3188,6 +3188,17 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { return false; } }; + const setSignedOut = (value: boolean) => { + try { + if (value) { + window.localStorage.setItem("ade.mock.account", "out"); + } else { + window.localStorage.removeItem("ade.mock.account"); + } + } catch { + // localStorage may be unavailable in hardened contexts. + } + }; const signedInStatus = { signedIn: true, userId: "user_2xMockAccount", @@ -3216,13 +3227,19 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { authorizeUrl: "https://accounts.ade.dev/oauth/authorize?mock=1", expiresAt: new Date(Date.now() + 300_000).toISOString(), }), - pollLogin: async () => ({ - status: "signed_in" as const, - message: null, - authStatus: signedInStatus, - }), + pollLogin: async () => { + setSignedOut(false); + return { + status: "signed_in" as const, + message: null, + authStatus: signedInStatus, + }; + }, cancelLogin: async () => status(), - signOut: async () => signedOutStatus, + signOut: async () => { + setSignedOut(true); + return signedOutStatus; + }, listMachines: async () => { if (signedOut()) { return { state: "signed_out" as const, machines: [], message: null }; diff --git a/apps/desktop/src/renderer/components/account/AccountPage.tsx b/apps/desktop/src/renderer/components/account/AccountPage.tsx index 200382b5e..1cb177546 100644 --- a/apps/desktop/src/renderer/components/account/AccountPage.tsx +++ b/apps/desktop/src/renderer/components/account/AccountPage.tsx @@ -456,6 +456,7 @@ export function AccountPage() { const { status, refresh } = useAccountStatus(); const [githubStatus, setGithubStatus] = useState(null); const [signingOut, setSigningOut] = useState(false); + const [signOutError, setSignOutError] = useState(null); const [justSignedIn, setJustSignedIn] = useState(false); const [repoBridgeDismissed, setRepoBridgeDismissed] = useState(() => readDismissed(REPO_BRIDGE_DISMISS_KEY)); @@ -490,10 +491,15 @@ export function AccountPage() { }).account; if (!api?.signOut) return; setSigningOut(true); + setSignOutError(null); try { const next = await api.signOut(); publishAccountStatus(next); setJustSignedIn(false); + } catch (err) { + setSignOutError( + err instanceof Error ? err.message : "Couldn't sign out of your ADE account.", + ); } finally { setSigningOut(false); } @@ -685,6 +691,11 @@ export function AccountPage() {
void handleSignOut()} signingOut={signingOut} /> + {signOutError ? ( +
+ {signOutError} +
+ ) : null} )}
diff --git a/apps/desktop/src/renderer/components/app/ConnectionsPanel.tsx b/apps/desktop/src/renderer/components/app/ConnectionsPanel.tsx index de2226fed..659b4eca8 100644 --- a/apps/desktop/src/renderer/components/app/ConnectionsPanel.tsx +++ b/apps/desktop/src/renderer/components/app/ConnectionsPanel.tsx @@ -177,11 +177,12 @@ export function ConnectionsPanel({ } }, []); - // Fetch account machines up front (and refresh when the Machines tab opens) so - // the merged list is ready without blocking the local saved/discovered rows. + // Fetch account machines whenever the Machines tab opens (including the first + // render) without blocking the local saved/discovered rows. useEffect(() => { + if (tab !== "machines") return; void loadAccountMachines(); - }, [loadAccountMachines]); + }, [loadAccountMachines, tab]); const goToAccount = useCallback(() => { onClose(); diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx index 7046d2e75..bf85b5a4e 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx @@ -26,6 +26,7 @@ import { import { ShareMachineCard } from "./ShareMachineCard"; import { PairMachineForm } from "./PairMachineForm"; import { + accountMachineSshRoutes, assignMachineSections, discoveredTargetInput, formatRemoteTargetError, @@ -450,7 +451,7 @@ export function RemoteTargetList({ // leave it null so the SSH transport falls back to its default (22). port: null, sshKeyPath: null, - routes: null, + routes: accountMachineSshRoutes(machine), }; setBusyId(`account:${machine.machineKey}`); setSelectedId(null); diff --git a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts index 3b0fc4a38..f92a07e86 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts @@ -6,6 +6,7 @@ import type { } from "../../../shared/types"; import { accountMachineMatchesTarget, + accountMachineSshRoutes, assignMachineSections, } from "./remoteMachineModel"; @@ -40,6 +41,41 @@ function savedTarget(overrides: Partial = {}): RemoteRuntim const NO_STATUS = new Map(); +describe("accountMachineSshRoutes", () => { + it("returns direct SSH routes tailnet-first and excludes relay-only machines", () => { + expect( + accountMachineSshRoutes( + accountMachine({ + reachableEndpoints: [ + { kind: "lan", host: "10.0.0.9", port: 8787 }, + { kind: "tailnet", host: "100.92.14.3", port: 8787 }, + ], + }), + ), + ).toEqual([ + { + hostname: "100.92.14.3", + port: null, + source: "tailscale", + lastSucceededAt: null, + }, + { + hostname: "10.0.0.9", + port: null, + source: "bonjour", + lastSucceededAt: null, + }, + ]); + expect( + accountMachineSshRoutes( + accountMachine({ + reachableEndpoints: [{ kind: "relay", url: "wss://relay/x" }], + }), + ), + ).toEqual([]); + }); +}); + describe("assignMachineSections — account machines", () => { it("buckets an online account machine into AVAILABLE and offline into UNAVAILABLE", () => { const sections = assignMachineSections({ diff --git a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts index 53f84d58e..285f4039b 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts @@ -404,6 +404,41 @@ export function accountMachineHasDirectRoute(machine: AdeAccountMachine): boolea ); } +function endpointRank(kind: AdeAccountMachineEndpoint["kind"]): number { + return kind === "tailnet" ? 0 : kind === "lan" ? 1 : 2; +} + +/** + * SSH fallback routes for an account machine: every non-relay endpoint as a + * host at the default SSH port (the advertised service port is not an SSH + * port). Ranked tailnet-first then lan, deduped by host, so the saved target + * keeps fallback candidates like a discovered machine does. + */ +export function accountMachineSshRoutes( + machine: AdeAccountMachine, +): RemoteRuntimeTargetRoute[] { + const ranked = [...machine.reachableEndpoints].sort( + (a, b) => endpointRank(a.kind) - endpointRank(b.kind), + ); + const routes: RemoteRuntimeTargetRoute[] = []; + const seen = new Set(); + for (const endpoint of ranked) { + if (endpoint.kind === "relay") continue; + const host = accountEndpointHost(endpoint); + if (!host) continue; + const key = host.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + routes.push({ + hostname: host, + port: null, + source: endpoint.kind === "tailnet" ? "tailscale" : "bonjour", + lastSucceededAt: null, + }); + } + return routes; +} + function intersects(a: Set, b: Set): boolean { for (const value of a) { if (b.has(value)) return true; diff --git a/apps/desktop/src/renderer/lib/account.ts b/apps/desktop/src/renderer/lib/account.ts index 6cd4dc75d..d824a5f0c 100644 --- a/apps/desktop/src/renderer/lib/account.ts +++ b/apps/desktop/src/renderer/lib/account.ts @@ -30,6 +30,7 @@ const STATUS_TTL_MS = 15_000; let cachedStatus: { value: AdeAccountStatus; fetchedAtMs: number } | null = null; let inFlight: Promise | null = null; +let fetchSerial = 0; const listeners = new Set<(status: AdeAccountStatus) => void>(); function accountApi() { @@ -71,15 +72,16 @@ export async function fetchAccountStatus(options?: { force?: boolean }): Promise return cachedStatus.value; } if (!options?.force && inFlight) return inFlight; + const serial = ++fetchSerial; inFlight = api .status() .then((status) => { - emit(status); + if (serial === fetchSerial) emit(status); return status; }) .catch(() => cachedStatus?.value ?? SIGNED_OUT_ACCOUNT) .finally(() => { - inFlight = null; + if (serial === fetchSerial) inFlight = null; }); return inFlight; } diff --git a/apps/desktop/src/renderer/lib/accountLogin.ts b/apps/desktop/src/renderer/lib/accountLogin.ts index bf43f2a7a..f66e505cb 100644 --- a/apps/desktop/src/renderer/lib/accountLogin.ts +++ b/apps/desktop/src/renderer/lib/accountLogin.ts @@ -72,6 +72,10 @@ export function useAccountLogin(options?: { let sessionId: string; try { const start = await api.startLogin(); + if (cancelledRef.current) { + void accountApi()?.cancelLogin({ sessionId: start.sessionId }).catch(() => {}); + return; + } sessionId = start.sessionId; sessionIdRef.current = sessionId; openExternalUrl(start.authorizeUrl); From 2376c78a315f11516c8f0507938334af70ec44e6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 15 Jul 2026 01:40:09 -0400 Subject: [PATCH 8/8] =?UTF-8?q?ship:=20iter=207=20=E2=80=94=20close=20logi?= =?UTF-8?q?n/status=20concurrency=20follow-ups=20(Greptile=20P1=20+=20Code?= =?UTF-8?q?x=20P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1 (account.ts): publishAccountStatus emitted the freshly signed-in/out status but did not advance fetchSerial, so a slow api.status() request started before the publish still matched serial===fetchSerial and re-emitted stale signed-out state over it. publishAccountStatus now bumps fetchSerial and drops the in-flight promise so any superseded fetch can no longer overwrite the published status. Adds a deterministic race regression test (account.test.ts). Codex P2 (accountLogin.ts): the cancellation used one shared cancelledRef boolean, so cancel-then-retry reset it to false and the old polling loop kept running its canceled session (eventually clearing sessionIdRef / publishing signed-out / setting phase=error under the new attempt). Replace it with a per-attempt id (attemptRef): beginLogin claims ++attemptRef, every guard checks attemptRef.current !== attemptId, and cancel/unmount/newer-attempt all bump the ref so a stale loop bails without touching shared UI state. Co-Authored-By: Claude Opus 4.8 --- apps/desktop/src/renderer/lib/account.test.ts | 86 +++++++++++++++++++ apps/desktop/src/renderer/lib/account.ts | 2 + apps/desktop/src/renderer/lib/accountLogin.ts | 20 ++--- 3 files changed, 98 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/account.test.ts diff --git a/apps/desktop/src/renderer/lib/account.test.ts b/apps/desktop/src/renderer/lib/account.test.ts new file mode 100644 index 000000000..7b9d52988 --- /dev/null +++ b/apps/desktop/src/renderer/lib/account.test.ts @@ -0,0 +1,86 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AdeAccountStatus } from "../../shared/types"; + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +const publishedSignedInStatus: AdeAccountStatus = { + signedIn: true, + userId: "user-1", + email: "person@example.com", + name: "Test Person", + expiresAt: "2026-07-16T00:00:00.000Z", + provider: "github", + imageUrl: null, + configured: true, +}; + +const staleSignedOutStatus: AdeAccountStatus = { + signedIn: false, + userId: null, + email: null, + name: null, + expiresAt: null, + provider: null, + imageUrl: null, + configured: true, +}; + +describe("account status cache", () => { + const originalAde = globalThis.window.ade; + + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + Object.defineProperty(globalThis.window, "ade", { + configurable: true, + writable: true, + value: originalAde, + }); + vi.restoreAllMocks(); + }); + + it("keeps a published status when an older status fetch resolves later", async () => { + const pendingStatus = deferred(); + const status = vi.fn(() => pendingStatus.promise); + Object.defineProperty(globalThis.window, "ade", { + configurable: true, + writable: true, + value: { account: { status } } as unknown as typeof window.ade, + }); + + const { fetchAccountStatus, publishAccountStatus, subscribeAccountStatus } = + await import("./account"); + const observed: AdeAccountStatus[] = []; + const unsubscribe = subscribeAccountStatus((accountStatus) => { + observed.push(accountStatus); + }); + + const staleFetch = fetchAccountStatus({ force: true }); + expect(status).toHaveBeenCalledTimes(1); + + publishAccountStatus(publishedSignedInStatus); + expect(observed).toEqual([publishedSignedInStatus]); + + pendingStatus.resolve(staleSignedOutStatus); + await expect(staleFetch).resolves.toBe(staleSignedOutStatus); + + expect(observed).toEqual([publishedSignedInStatus]); + await expect(fetchAccountStatus()).resolves.toBe(publishedSignedInStatus); + expect(status).toHaveBeenCalledTimes(1); + + unsubscribe(); + }); +}); diff --git a/apps/desktop/src/renderer/lib/account.ts b/apps/desktop/src/renderer/lib/account.ts index d824a5f0c..1c2e3e498 100644 --- a/apps/desktop/src/renderer/lib/account.ts +++ b/apps/desktop/src/renderer/lib/account.ts @@ -88,6 +88,8 @@ export async function fetchAccountStatus(options?: { force?: boolean }): Promise /** Push a freshly-known status to every subscriber (after sign-in / sign-out). */ export function publishAccountStatus(status: AdeAccountStatus): void { + fetchSerial += 1; // Supersede any in-flight status fetch so it cannot overwrite this. + inFlight = null; emit(status); } diff --git a/apps/desktop/src/renderer/lib/accountLogin.ts b/apps/desktop/src/renderer/lib/accountLogin.ts index f66e505cb..14dbc31c3 100644 --- a/apps/desktop/src/renderer/lib/accountLogin.ts +++ b/apps/desktop/src/renderer/lib/accountLogin.ts @@ -38,19 +38,19 @@ export function useAccountLogin(options?: { const [phase, setPhase] = useState("idle"); const [error, setError] = useState(null); const sessionIdRef = useRef(null); - const cancelledRef = useRef(false); + const attemptRef = useRef(0); const onSignedInRef = useRef(options?.onSignedIn); onSignedInRef.current = options?.onSignedIn; useEffect(() => { return () => { - cancelledRef.current = true; + attemptRef.current += 1; }; }, []); const cancel = useCallback(() => { - cancelledRef.current = true; const sessionId = sessionIdRef.current; + attemptRef.current += 1; if (sessionId) { void accountApi()?.cancelLogin({ sessionId }).catch(() => {}); } @@ -66,13 +66,13 @@ export function useAccountLogin(options?: { setError("Account sign-in isn't available on this build."); return; } - cancelledRef.current = false; + const attemptId = ++attemptRef.current; setError(null); setPhase("starting"); let sessionId: string; try { const start = await api.startLogin(); - if (cancelledRef.current) { + if (attemptRef.current !== attemptId) { void accountApi()?.cancelLogin({ sessionId: start.sessionId }).catch(() => {}); return; } @@ -80,7 +80,7 @@ export function useAccountLogin(options?: { sessionIdRef.current = sessionId; openExternalUrl(start.authorizeUrl); } catch (err) { - if (cancelledRef.current) return; + if (attemptRef.current !== attemptId) return; setPhase("error"); setError( err instanceof Error ? err.message : "Couldn't start ADE account sign-in.", @@ -90,16 +90,16 @@ export function useAccountLogin(options?: { setPhase("awaiting"); for (let attempt = 0; attempt < MAX_POLLS; attempt += 1) { - if (cancelledRef.current) return; + if (attemptRef.current !== attemptId) return; await new Promise((resolve) => window.setTimeout(resolve, POLL_INTERVAL_MS)); - if (cancelledRef.current) return; + if (attemptRef.current !== attemptId) return; let result: Awaited["pollLogin"]>>; try { result = await api.pollLogin({ sessionId }); } catch { continue; // Transient IPC hiccup — keep polling until the TTL. } - if (cancelledRef.current) return; + if (attemptRef.current !== attemptId) return; if (result.status === "signed_in") { sessionIdRef.current = null; publishAccountStatus(result.authStatus); @@ -120,7 +120,7 @@ export function useAccountLogin(options?: { return; } } - if (cancelledRef.current) return; + if (attemptRef.current !== attemptId) return; setPhase("error"); setError("Sign-in timed out. Try again."); }, []);