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..efcd16c7b --- /dev/null +++ b/apps/desktop/src/main/services/account/accountBridge.trust.test.ts @@ -0,0 +1,59 @@ +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://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"); + }); + + 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(); + }); + + 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 new file mode 100644 index 000000000..405e80588 --- /dev/null +++ b/apps/desktop/src/main/services/account/accountBridge.ts @@ -0,0 +1,266 @@ +// 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 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 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)) + ) { + if (url.username || url.password || url.search || url.hash) return null; + const pathname = url.pathname.replace(/\/+$/, ""); + return `${url.origin}${pathname}`; + } + 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 + * 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(): string | null { + return parseTrustedDirectoryBaseUrl(process.env.ADE_ACCOUNT_DIRECTORY_URL); +} + +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 baseUrl = resolveDirectoryBaseUrl(); + if (!baseUrl) { + return { + state: "not_configured", + machines: [], + message: + "Machine directory isn't configured — set a trusted https directory URL on this machine.", + }; + } + 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 ab1baceba..4da3d08b0 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"; @@ -1790,6 +1795,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[] => { @@ -1863,13 +1870,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 => { @@ -8477,6 +8541,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..bbbb9d6ca 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -3178,6 +3178,105 @@ 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 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", + 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 () => { + setSignedOut(false); + return { + status: "signed_in" as const, + message: null, + authStatus: signedInStatus, + }; + }, + cancelLogin: async () => status(), + signOut: async () => { + setSignedOut(true); + return 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..1cb177546 --- /dev/null +++ b/apps/desktop/src/renderer/components/account/AccountPage.tsx @@ -0,0 +1,706 @@ +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 [signOutError, setSignOutError] = useState(null); + 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); + 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); + } + }, []); + + 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} /> + {signOutError ? ( +
+ {signOutError} +
+ ) : null} + + )} +
+
+ ); +} + +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/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/ConnectionsPanel.tsx b/apps/desktop/src/renderer/components/app/ConnectionsPanel.tsx new file mode 100644 index 000000000..659b4eca8 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/ConnectionsPanel.tsx @@ -0,0 +1,231 @@ +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 whenever the Machines tab opens (including the first + // render) without blocking the local saved/discovered rows. + useEffect(() => { + if (tab !== "machines") return; + void loadAccountMachines(); + }, [loadAccountMachines, tab]); + + 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.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/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.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(); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index f01374a82..13c3ae9a3 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, @@ -876,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); @@ -915,8 +864,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 +885,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 +936,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 +1157,7 @@ export function TopBar({ let disposeSyncEvents: (() => void) | null = null; if (!showSyncControl) { setSyncSnapshot(null); - setSyncPanelOpen(null); + setConnectionsOpen(false); return () => { cancelled = true; }; @@ -1252,7 +1199,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 +1214,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 => { @@ -1339,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 @@ -1361,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; @@ -1376,7 +1331,7 @@ export function TopBar({ [ cancelNewTab, isProjectBusy, - leavePersonalChatsRoute, + leaveMachineRoute, project?.rootPath, remoteBinding, switchProjectToPath, @@ -1386,7 +1341,7 @@ export function TopBar({ const handleSwitchRemoteProject = useCallback( (binding: RemoteProjectTab) => { if (isProjectBusy) return; - leavePersonalChatsRoute(); + leaveMachineRoute(); if (remoteBinding?.key === binding.key) { cancelNewTab(); return; @@ -1396,7 +1351,7 @@ export function TopBar({ [ cancelNewTab, isProjectBusy, - leavePersonalChatsRoute, + leaveMachineRoute, remoteBinding?.key, switchRemoteProject, ], @@ -1781,16 +1736,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 +1746,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 +1777,7 @@ export function TopBar({ onMenuActivate={options?.onActivate} deferInitialRead={Boolean(remoteBinding)} /> - {remoteChip} - {mobileChip} - {webChip} + {connectionsChip}
); } @@ -1903,24 +1785,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 +2374,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..dd6ced3a0 --- /dev/null +++ b/apps/desktop/src/renderer/components/remoteTargets/AccountMachineRow.tsx @@ -0,0 +1,164 @@ +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 { + accountEndpointHost, + type AccountMachineRow as AccountMachineRowModel, + type 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 = accountEndpointHost(endpoint); + 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"; + const relayOnly = machine.online && !connectHost; + const trulyOffline = !machine.online; + + return ( +
+
+
+
+
+ + {machine.name ?? "Unnamed machine"} + + + account + + {primaryEndpoint ? ( + + {endpointLabel(primaryEndpoint)} + + ) : null} +
+
+ {[machine.platform, machine.deviceType].filter(Boolean).join(" · ") || "Registered on your account"} +
+
+ {relayOnly + ? "Online · relay only — connect from a machine on its network" + : machine.online + ? "Online · reachable on your account" + : relativeLastSeen(machine.lastSeenAt)} +
+
+ +
+ {machine.online && connectHost ? ( + + ) : null} + {trulyOffline ? ( + + ) : null} +
+
+
+ + {trulyOffline && 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.test.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx index 4516645ff..478836402 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,71 @@ describe("RemoteTargetList", () => { ); expect(screen.getByText("No saved or detected machines yet.")).toBeTruthy(); }); + + it("parses an account endpoint URL without reusing its service 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", url: "http://100.92.14.3: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({ + // 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/RemoteTargetList.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx index 80bb97c1a..bf85b5a4e 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, @@ -24,6 +26,7 @@ import { import { ShareMachineCard } from "./ShareMachineCard"; import { PairMachineForm } from "./PairMachineForm"; import { + accountMachineSshRoutes, assignMachineSections, discoveredTargetInput, formatRemoteTargetError, @@ -31,6 +34,7 @@ import { } from "./remoteMachineModel"; import { SavedMachineRow } from "./SavedMachineRow"; import { DiscoveredMachineRow } from "./DiscoveredMachineRow"; +import { AccountMachineRow, accountMachineConnectHost } from "./AccountMachineRow"; import { helperTextStyle, inlineDetailStyle, @@ -46,6 +50,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 +89,9 @@ export function RemoteTargetList({ onConnected, onDisconnectRequested, onRemoveRequested, + accountMachines, + accountMachinesState, + accountMachinesMessage, }: RemoteTargetListProps) { const [targets, setTargets] = useState([]); const [connectionSnapshot, setConnectionSnapshot] = @@ -132,8 +143,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 +439,33 @@ 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, + // 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: accountMachineSshRoutes(machine), + }; + 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 +563,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 +783,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..f92a07e86 --- /dev/null +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.account.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from "vitest"; +import type { + AdeAccountMachine, + RemoteRuntimeConnectionStatus, + RemoteRuntimeTarget, +} from "../../../shared/types"; +import { + accountMachineMatchesTarget, + accountMachineSshRoutes, + 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("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({ + 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("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()], + 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("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( + accountMachineMatchesTarget( + accountMachine({ reachableEndpoints: [{ kind: "lan", host: "10.0.0.9", port: 22 }] }), + savedTarget(), + ), + ).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: [], + 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..285f4039b 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts @@ -1,5 +1,7 @@ import { extractError } from "../../lib/format"; import type { + AdeAccountMachine, + AdeAccountMachineEndpoint, RemoteRuntimeConnectErrorInfo, RemoteRuntimeConnectionRoute, RemoteRuntimeConnectionStatus, @@ -333,7 +335,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 +356,116 @@ 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 + * (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 = accountEndpointHost(endpoint); + const identity = routeIdentity(host, null); + if (identity) identities.add(identity); + } + 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" && accountEndpointHost(endpoint) != null, + ); +} + +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; + } + 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 +478,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 +567,40 @@ 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, + }; + // 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); + } + } + return sections; } 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 new file mode 100644 index 000000000..1c2e3e498 --- /dev/null +++ b/apps/desktop/src/renderer/lib/account.ts @@ -0,0 +1,235 @@ +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; +let fetchSerial = 0; +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; + const serial = ++fetchSerial; + inFlight = api + .status() + .then((status) => { + if (serial === fetchSerial) emit(status); + return status; + }) + .catch(() => cachedStatus?.value ?? SIGNED_OUT_ACCOUNT) + .finally(() => { + if (serial === fetchSerial) inFlight = null; + }); + return inFlight; +} + +/** 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); +} + +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..14dbc31c3 --- /dev/null +++ b/apps/desktop/src/renderer/lib/accountLogin.ts @@ -0,0 +1,129 @@ +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 attemptRef = useRef(0); + const onSignedInRef = useRef(options?.onSignedIn); + onSignedInRef.current = options?.onSignedIn; + + useEffect(() => { + return () => { + attemptRef.current += 1; + }; + }, []); + + const cancel = useCallback(() => { + const sessionId = sessionIdRef.current; + attemptRef.current += 1; + 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; + } + const attemptId = ++attemptRef.current; + setError(null); + setPhase("starting"); + let sessionId: string; + try { + const start = await api.startLogin(); + if (attemptRef.current !== attemptId) { + void accountApi()?.cancelLogin({ sessionId: start.sessionId }).catch(() => {}); + return; + } + sessionId = start.sessionId; + sessionIdRef.current = sessionId; + openExternalUrl(start.authorizeUrl); + } catch (err) { + if (attemptRef.current !== attemptId) 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 (attemptRef.current !== attemptId) return; + await new Promise((resolve) => window.setTimeout(resolve, POLL_INTERVAL_MS)); + 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 (attemptRef.current !== attemptId) 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 (attemptRef.current !== attemptId) 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";