diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts index 81278b928..08d9371b6 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.test.ts @@ -402,7 +402,7 @@ describe("validateRemoteRuntimeInitializeResult", () => { }); expect(info.version).toBe("0.9.0"); expect(info.capabilities.machineProjects.browseDirectories).toBe(false); - expect(info.compatibilityWarnings.join("\n")).toMatch(/local ADE is 1\.0\.0/i); + expect(info.compatibilityWarnings.join("\n")).not.toMatch(/This machine is on ADE/i); expect(info.compatibilityWarnings.join("\n")).toMatch(/missing project capabilities/i); }); @@ -424,7 +424,7 @@ describe("validateRemoteRuntimeInitializeResult", () => { }, }, }, - }).compatibilityWarnings.join("\n")).toMatch(/reported 0\.9\.0/i); + }).compatibilityWarnings.join("\n")).not.toMatch(/This machine is on ADE|Remote ADE service reported/i); }); }); diff --git a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts index 34c1a1869..6d3e31fb7 100644 --- a/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts +++ b/apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts @@ -151,18 +151,9 @@ export function validateRemoteRuntimeInitializeResult(args: { ? runtimeInfo.version.trim() : null; const compatibilityWarnings: string[] = []; - if (args.expectedVersion && version !== args.expectedVersion) { - const expected = args.expectedVersion; - const actual = version ?? "unknown"; - // A version difference alone is not a fault in either direction — the - // capability checks below are what decide whether anything is actually - // missing. Say so plainly instead of raising an alarm the user cannot act - // on; "remote is newer" in particular is the normal state for a machine on - // the release channel seen from an alpha desktop. - compatibilityWarnings.push( - `Remote ADE service reported ${actual}; local ADE is ${expected}. The versions differ but their RPC capabilities match, so ADE connected normally — nothing to do.`, - ); - } + // Version-only skew is not a fault. The Connections row already has both + // versions and formats a quiet note there. Do not push a string the UI then + // has to sniff back out of the yellow warning list. const missing = MACHINE_PROJECT_CAPABILITIES.filter((capability) => machineProjects[capability] !== true); if (missing.length) { compatibilityWarnings.push( diff --git a/apps/desktop/src/renderer/components/account/AccountPage.tsx b/apps/desktop/src/renderer/components/account/AccountPage.tsx index 3c19d307a..1c00693a8 100644 --- a/apps/desktop/src/renderer/components/account/AccountPage.tsx +++ b/apps/desktop/src/renderer/components/account/AccountPage.tsx @@ -154,7 +154,7 @@ export function SignInCard({ One source for the state's own words: `unreadable` and `expired` both take their title from the label table, so the header never drifts from the notice under it. `signed_out` keeps the call to - action -- the table's "Not signed in" describes the state, but + action -- the table's "Signed out" describes the state, but this card is where you act on it. */} {unreadable || expired ? accountSessionTitle(sessionState) : "Sign in to ADE"} diff --git a/apps/desktop/src/renderer/components/app/ConnectionsPanel.test.tsx b/apps/desktop/src/renderer/components/app/ConnectionsPanel.test.tsx index 11912693f..098e4f087 100644 --- a/apps/desktop/src/renderer/components/app/ConnectionsPanel.test.tsx +++ b/apps/desktop/src/renderer/components/app/ConnectionsPanel.test.tsx @@ -14,6 +14,13 @@ import type { // two data sources directly. const syncRef = { current: { devices: [] as SyncDeviceRuntimeState[] } }; const snapshotRef = { current: { connections: [], connectedCount: 0, updatedAt: 0 } as RemoteRuntimeConnectionSnapshot }; +const accountRef = { + current: { + signedIn: true, + email: "ada@example.com" as string | null, + sessionState: undefined as undefined | "signed_out" | "expired" | "unreadable" | "active", + }, +}; vi.mock("../settings/SyncDevicesSection", () => ({ useSyncConnections: () => syncRef.current, @@ -31,7 +38,12 @@ vi.mock("../../lib/account", async () => { return { ...actual, useAccountStatus: () => ({ - status: { ...actual.SIGNED_OUT_ACCOUNT, signedIn: true, email: "ada@example.com" }, + status: { + ...actual.SIGNED_OUT_ACCOUNT, + signedIn: accountRef.current.signedIn, + email: accountRef.current.email, + sessionState: accountRef.current.sessionState, + }, loading: false, refresh: vi.fn(), }), @@ -66,6 +78,7 @@ describe("ConnectionsPanel tab dots", () => { beforeEach(() => { syncRef.current = { devices: [] }; snapshotRef.current = { connections: [], connectedCount: 0, updatedAt: 0 }; + accountRef.current = { signedIn: true, email: "ada@example.com", sessionState: undefined }; window.ade = { github: { getStatus: vi.fn(async () => ({ connected: false })) }, remoteRuntime: { @@ -150,4 +163,21 @@ describe("ConnectionsPanel tab dots", () => { expect(within(screen.getByRole("tab", { name: /Phone/ })).queryByTitle("Active connection")).toBeNull(); }); + + it("offers Sign in when the account is signed out", async () => { + accountRef.current = { signedIn: false, email: null, sessionState: "signed_out" }; + renderPanel(); + expect(await screen.findByRole("button", { name: "Sign in" })).toBeTruthy(); + expect(screen.getByText("Signed out")).toBeTruthy(); + expect(screen.getByText("Sign in to easily connect your machines")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Manage account" })).toBeNull(); + }); + + it("keeps unreadable sessions distinct from signed out", async () => { + accountRef.current = { signedIn: false, email: null, sessionState: "unreadable" }; + renderPanel(); + expect(await screen.findByRole("button", { name: "Fix sign-in" })).toBeTruthy(); + expect(screen.getByText("Can't read your sign-in")).toBeTruthy(); + expect(screen.queryByText("Signed out")).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/components/app/ConnectionsPanel.tsx b/apps/desktop/src/renderer/components/app/ConnectionsPanel.tsx index 321bcceb1..e06aac562 100644 --- a/apps/desktop/src/renderer/components/app/ConnectionsPanel.tsx +++ b/apps/desktop/src/renderer/components/app/ConnectionsPanel.tsx @@ -27,6 +27,8 @@ import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import { accountAvatarImage, accountInitials, + accountSessionConnectionsAction, + accountSessionConnectionsActionAria, accountSessionConnectionsSubtitle, accountSessionState, accountSessionTitle, @@ -133,13 +135,7 @@ function AccountHeader({ cursor: "pointer", textAlign: "left", }} - aria-label={ - sessionState === "active" - ? "Manage account" - : sessionState === "unreadable" - ? "Fix your sign-in" - : "Sign in to ADE" - } + aria-label={accountSessionConnectionsActionAria(sessionState)} > {avatarImage && !imgBroken ? ( @@ -203,7 +199,7 @@ function AccountHeader({ cursor: "pointer", }} > - Manage account + {accountSessionConnectionsAction(sessionState)} ); @@ -324,7 +320,10 @@ export function ConnectionsPanel({
- +
{ }); expect(screen.getByRole("dialog", { name: "Connections" })).toBeTruthy(); - expect(screen.getByText("Sign in to connect your devices")).toBeTruthy(); + expect(screen.getByText("Sign in to easily connect your machines")).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"); diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx index 227242a59..6088a24e0 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.test.tsx @@ -194,11 +194,31 @@ describe("RemoteTargetList", () => { }, }); - render(); - expect(await screen.findByText(/route publish failing for 5 min/)).toBeTruthy(); + render(); + expect(await screen.findByText(/couldn't publish it for 5 min/)).toBeTruthy(); expect(screen.queryByRole("button", { name: "Repair" })).toBeNull(); }); + it("hides a publish-failing banner while signed out", async () => { + remoteRuntimeMock.listTargets.mockResolvedValue([]); + remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ machines: [], diagnostics: [] }); + installAdeMock(); + appMock.getInfo.mockResolvedValue({ + localRuntime: { + publishHealth: { + state: "http_error", + failingSinceMs: Date.now() - 5 * 60_000, + lastLegDurations: { snapshot: null, token: null, http: null }, + }, + }, + }); + + render(); + await screen.findByRole("button", { name: "Add machine" }); + expect(screen.queryByText(/couldn't publish it/)).toBeNull(); + expect(screen.queryByText(/route publish failing/)).toBeNull(); + }); + it("pairs a discovered ADE machine with its 6-digit code instead of creating an SSH target", async () => { remoteRuntimeMock.listTargets.mockResolvedValue([]); remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ @@ -405,9 +425,9 @@ describe("RemoteTargetList", () => { remoteRuntimeMock.connect.mockResolvedValue({ target, arch: "darwin-arm64", - version: "1.0.0", + version: "0.9.0", compatibilityWarnings: [ - "Remote ADE service reported 0.9.0; local ADE is 1.0.0. ADE will connect because the RPC capabilities are compatible.", + "This machine is on ADE 1.0.0. The other machine is on 0.9.0. They can still connect — update the other machine when you can.", ], projects: [project], }); @@ -427,9 +447,12 @@ describe("RemoteTargetList", () => { await waitFor(() => expect(remoteRuntimeMock.connect).toHaveBeenCalledWith("target-1"), ); - expect(screen.getByText("Connected")).toBeTruthy(); - expect(screen.getByText(/RPC capabilities are compatible/i)).toBeTruthy(); expect(screen.getByRole("button", { name: "Disconnect" })).toBeTruthy(); + expect(screen.queryByText(/Never connected/)).toBeNull(); + expect(screen.getByText(/Last connected/)).toBeTruthy(); + expect( + screen.getByText("This machine is on ADE 1.0.0. Mac Studio is on 0.9.0. They can still connect — update Mac Studio when you can."), + ).toBeTruthy(); expect(screen.queryByText("/remote/ADE")).toBeNull(); expect(screen.queryByRole("button", { name: "Open" })).toBeNull(); @@ -439,7 +462,7 @@ describe("RemoteTargetList", () => { manual: true, }), ); - expect(screen.getByText("Not connected")).toBeTruthy(); + expect(screen.getByText(/Not connected · Last connected/)).toBeTruthy(); }); it("does not let an older event overwrite a local connection-setting snapshot", async () => { @@ -571,10 +594,12 @@ describe("RemoteTargetList", () => { fireEvent.click(screen.getByRole("button", { name: "Disconnect" })); await waitFor(() => - expect(onDisconnectRequested).toHaveBeenCalledWith(target), + expect(onDisconnectRequested).toHaveBeenCalledWith( + expect.objectContaining({ id: "target-1", name: "Mac Studio" }), + ), ); expect(remoteRuntimeMock.disconnect).not.toHaveBeenCalled(); - expect(screen.getByText("Connected")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Disconnect" })).toBeTruthy(); }); it("toggles the saved machine edit details from the Edit button", async () => { @@ -1055,7 +1080,7 @@ describe("RemoteTargetList", () => { await waitFor(() => expect(screen.getAllByText("Connected Mac").length).toBeGreaterThan(0), ); - expect(screen.getByText("CONNECTED")).toBeTruthy(); + expect(screen.getByText("Connected")).toBeTruthy(); expect(screen.getByRole("button", { name: "Disconnect" })).toBeTruthy(); openAddMode("Find nearby computers"); @@ -1213,7 +1238,7 @@ describe("RemoteTargetList", () => { // A brand-new user's first read is the call to action, not an explanation // of optional software they never asked about. expect( - await screen.findByText("No computers yet. Choose Add machine to connect one."), + await screen.findByText("No computers yet. Add a machine to connect one."), ).toBeTruthy(); expect(screen.queryByText("Tailscale not installed — LAN discovery only.")).toBeNull(); @@ -1247,7 +1272,7 @@ describe("RemoteTargetList", () => { screen.getByText("Tailscale discovery failed; LAN discovery still ran."), ); expect(warning.querySelector("svg")).not.toBeNull(); - expect(screen.getByText("No computers yet. Choose Add machine to connect one.")).toBeTruthy(); + expect(screen.getByText("No computers yet. Add a machine to connect one.")).toBeTruthy(); }); it("adopts a desktop account machine as paired-only instead of saving a broken SSH target", async () => { @@ -1463,11 +1488,32 @@ describe("RemoteTargetList", () => { ); await waitFor(() => - expect(screen.getByText(/couldn't reach your ADE account/i)).toBeTruthy(), + expect(screen.getByText(/Couldn't load machines from your account/i)).toBeTruthy(), ); expect(screen.queryByText(/No computers yet/i)).toBeNull(); }); + it("does not repeat the account-load failure while signed out", async () => { + remoteRuntimeMock.listTargets.mockResolvedValue([]); + remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ + machines: [], + diagnostics: [], + }); + installAdeMock(); + + render( + , + ); + + await screen.findByRole("button", { name: "Add machine" }); + expect(screen.queryByText(/Couldn't load machines from your account/i)).toBeNull(); + expect(screen.queryByText(/couldn't reach your ADE account/i)).toBeNull(); + }); + it("shows an account connect failure only on the machine that failed", async () => { remoteRuntimeMock.listTargets.mockResolvedValue([]); remoteRuntimeMock.listDiscoveredMachines.mockResolvedValue({ diff --git a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx index 5d501f568..3802badbc 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/RemoteTargetList.tsx @@ -1,8 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + ArrowClockwise, CaretLeft, CaretRight, DesktopTower, + Plus, TerminalWindow, UserCircle, Warning, @@ -16,7 +18,6 @@ import { MONO_FONT, SANS_FONT, outlineButton, - primaryButton, } from "../lanes/laneDesignTokens"; import { isBrainAccountSessionFailure } from "../../../shared/types"; import type { @@ -57,6 +58,7 @@ import { DiscoveredMachineRow } from "./DiscoveredMachineRow"; import { AccountMachineRow } from "./AccountMachineRow"; import { helperTextStyle, + iconActionButtonStyle, inlineDetailStyle, panelStyle, sectionHeaderStyle, @@ -150,9 +152,9 @@ function joinDiagnosticMessages( } const SECTION_LABELS: Record = { - connected: "CONNECTED", - available: "AVAILABLE", - unavailable: "UNAVAILABLE", + connected: "Connected", + available: "Available", + unavailable: "Unavailable", }; export function RemoteTargetList({ @@ -562,10 +564,14 @@ export function RemoteTargetList({ if (!trusted) return null; } const result = await window.ade.remoteRuntime.connect(targetId); - setConnected(result); + const connectedTarget = { + ...result.target, + lastConnectedAt: result.target.lastConnectedAt ?? Date.now(), + }; + setConnected({ ...result, target: connectedTarget }); setTargets((current) => current.map((target) => - target.id === result.target.id ? result.target : target, + target.id === connectedTarget.id ? connectedTarget : target, ), ); setConnectionSnapshot((current) => { @@ -581,7 +587,7 @@ export function RemoteTargetList({ })); const existing = current?.connections ?? fallbackConnections; const connectedEntry: RemoteRuntimeConnectionStatus = { - target: result.target, + target: connectedTarget, state: "connected", arch: result.arch, version: result.version, @@ -591,7 +597,7 @@ export function RemoteTargetList({ projects: result.projects, lastError: null, lastAttemptedAt: Date.now(), - connectedAt: result.target.lastConnectedAt ?? Date.now(), + connectedAt: connectedTarget.lastConnectedAt, }; const connections = existing.some( (entry) => entry.target.id === result.target.id, @@ -912,20 +918,21 @@ export function RemoteTargetList({ } }, [nextLocalConnectionSnapshotUpdatedAt]); - const connectedCount = - connectionSnapshot?.connectedCount ?? (connected ? 1 : 0); - const totalRows = sections.connected.length + sections.available.length + sections.unavailable.length; // "The account list did not arrive" — distinct from "the account has none". + // Signed-out / expired already have a header line; don't repeat it here. const accountMachinesLoadFailed = Boolean( - accountMachinesState + accountSignedIn + && accountMachinesState && accountMachinesState !== "ok" && accountMachinesState !== "signed_out", ); + const showPublishFailure = publishHealthDisplay.kind === "failing" + && (accountSignedIn || isBrainAccountSessionFailure(localPublishHealth?.state)); const nearbyPairingByAccountMachineKey = useMemo(() => { const matches = new Map(); @@ -1054,6 +1061,7 @@ export function RemoteTargetList({ } updating={updatingTargetId === row.target.id} updateStatus={updateResultByTargetId[row.target.id] ?? null} + localAdeVersion={updateSnapshot.currentVersion} onUpdateAndRestart={(targetVersion) => void updateAndRestartTarget( row.target.id, @@ -1220,7 +1228,7 @@ export function RemoteTargetList({ gap: 12, }} > -
+
- + Machines
-
{connectedCount} connected
- {publishHealthDisplay.kind === "healthy" ? ( -
Routes fresh
- ) : null} - {publishHealthDisplay.kind === "failing" ? ( + {showPublishFailure && publishHealthDisplay.kind === "failing" ? (
- Other devices may not reach this computer — route publish failing for{" "} + Other machines may not find this one — ADE couldn't publish it for{" "} {publishHealthDisplay.minutes} min {showRepair ? : null}
) : null}
-
+
@@ -1427,7 +1429,7 @@ export function RemoteTargetList({
{accountMachinesState === "not_configured" ? "Account computers aren't available yet. Saved and nearby computers still work." - : "We couldn't reach your ADE account, so computers linked to it aren't listed. Saved and nearby computers still work — close and reopen this panel to try again."} + : "Couldn't load machines from your account. Saved and nearby machines still work."}
) : null} @@ -1440,7 +1442,7 @@ export function RemoteTargetList({ && !addMode && !loadingDiscovered ? (
- No computers yet. Choose Add machine to connect one. + No computers yet. Add a machine to connect one.
) : null} {loadingDiscovered ? ( diff --git a/apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx b/apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx index 0f5159620..c108fdea3 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx +++ b/apps/desktop/src/renderer/components/remoteTargets/SavedMachineRow.tsx @@ -1,9 +1,11 @@ +import { useEffect, useState } from "react"; import { ArrowClockwise, - CaretDown, - CaretUp, CheckCircle, + PencilSimple, + Plugs, PlugsConnected, + Pulse, Trash, Warning, } from "@phosphor-icons/react"; @@ -25,6 +27,8 @@ import { HostKeyTrustCard } from "./HostKeyTrustCard"; import { connectionStateLabel, formatLastSeen, + formatVersionSkewNote, + isVersionSkewWarning, selectMachineErrorCard, type MachineSection, type SavedMachineRow as SavedMachineRowModel, @@ -36,6 +40,7 @@ import { } from "./RemoteTargetForm"; import { helperTextStyle, + iconActionButtonStyle, inlineDetailStyle, inlineErrorTextStyle, inlineSuccessTextStyle, @@ -44,6 +49,27 @@ import { subTextStyle, } from "./remoteTargetListStyles"; +const CONNECTING_STALE_MS = 20_000; + +function connectButtonLabel(stale: boolean, connecting: boolean): string { + if (stale) return "Retry"; + if (connecting) return "Connecting…"; + return "Connect"; +} + +function useStaleConnecting(connecting: boolean): boolean { + const [stale, setStale] = useState(false); + useEffect(() => { + if (!connecting) { + setStale(false); + return; + } + const timer = window.setTimeout(() => setStale(true), CONNECTING_STALE_MS); + return () => window.clearTimeout(timer); + }, [connecting]); + return stale; +} + type SavedMachineRowProps = { row: SavedMachineRowModel; section: MachineSection; @@ -64,6 +90,8 @@ type SavedMachineRowProps = { updating?: boolean; /** Outcome of the last update run on this machine. */ updateStatus?: { ok: boolean; message: string } | null; + /** ADE version running on this computer, used for the quiet skew note. */ + localAdeVersion?: string | null; onUpdateAndRestart?: (targetVersion: string | null) => void; hostKeyTrust: RemoteRuntimeSshHostKeyTrustStatus | null; trustingHostKey: boolean; @@ -96,6 +124,7 @@ export function SavedMachineRow({ updateTargetVersion = null, updating = false, updateStatus = null, + localAdeVersion = null, onUpdateAndRestart, hostKeyTrust, trustingHostKey, @@ -113,17 +142,33 @@ export function SavedMachineRow({ const { target, status } = row; const targetConnecting = busyId === target.id || status?.state === "connecting"; - const statusLabel = connectionStateLabel( - status ?? null, - connected?.target.id === target.id, + // A live connect (busyId set) can take minutes for SSH bootstrap. Only treat + // snapshot-stuck "connecting" with no in-flight action as hung. + const connectingStale = useStaleConnecting( + status?.state === "connecting" && busyId !== target.id && !row.connected, ); - const warnings = selected + const statusLabel = connectingStale + ? "Can't reach" + : connectionStateLabel( + status ?? null, + connected?.target.id === target.id, + ); + const versionNote = formatVersionSkewNote({ + localVersion: localAdeVersion, + remoteVersion: row.version, + remoteName: target.name, + }); + const compatibilityWarnings = selected ? (status?.compatibilityWarnings ?? (connected?.target.id === target.id ? connected.compatibilityWarnings : []) ?? []) : (status?.compatibilityWarnings ?? []); + const warnings = compatibilityWarnings.filter((warning) => !isVersionSkewWarning(warning)); + const rawSkewWarning = compatibilityWarnings.find(isVersionSkewWarning) ?? null; + const displayedVersionNote = versionNote + ?? (localAdeVersion && row.version ? null : rawSkewWarning); const errorCard = selectMachineErrorCard({ errorInfo: status?.state === "error" ? status.lastErrorInfo : null, rawError: status?.state === "error" ? status.lastError : null, @@ -178,18 +223,22 @@ export function SavedMachineRow({
{section === "unavailable" && row.unavailableReason ? ( {row.unavailableReason} + ) : row.connected ? ( + {formatLastSeen(target.lastConnectedAt)} ) : ( - <> - {statusLabel} - {` · ${formatLastSeen(target.lastConnectedAt)}`} - + + {target.lastConnectedAt || statusLabel !== "Not connected" + ? `${statusLabel} · ${formatLastSeen(target.lastConnectedAt)}` + : formatLastSeen(null)} + )}
onUpdateAndRestart(updateTargetVersion)} style={outlineButton({ - height: 30, + height: 28, padding: "0 10px", fontSize: 11, })} @@ -212,15 +261,17 @@ export function SavedMachineRow({ {row.connected ? ( ) : section !== "unavailable" ? ( <> @@ -229,63 +280,63 @@ export function SavedMachineRow({ disabled={busyId != null} onClick={() => onConnect(target.id)} style={primaryButton({ - height: 30, + height: 28, padding: "0 10px", fontSize: 11, })} > - {targetConnecting ? "Connecting…" : "Connect"} + {connectButtonLabel(connectingStale, targetConnecting)} ) : null} {section !== "unavailable" ? ( ) : null}
@@ -328,6 +379,10 @@ export function SavedMachineRow({ omittedAttemptCount={omittedAttemptCount} /> + {displayedVersionNote ? ( +
{displayedVersionNote}
+ ) : null} + {warnings.length > 0 ? (
{ }); }); +describe("formatLastSeen", () => { + it("uses a relative last-connected phrase", () => { + expect(formatLastSeen(null)).toBe("Never connected"); + expect(formatLastSeen(Date.now() - 2 * 60 * 60_000)).toBe("Last connected 2h ago"); + }); +}); + +describe("formatVersionSkewNote", () => { + it("names both machines and points the update at the older one", () => { + expect( + formatVersionSkewNote({ + localVersion: "1.2.59", + remoteVersion: "1.2.57", + remoteName: "Arul's Mac Studio", + }), + ).toBe( + "This machine is on ADE 1.2.59. Arul's Mac Studio is on 1.2.57. They can still connect — update Arul's Mac Studio when you can.", + ); + expect( + formatVersionSkewNote({ + localVersion: "1.2.57", + remoteVersion: "1.2.59", + remoteName: "Arul's Mac Studio", + }), + ).toBe( + "This machine is on ADE 1.2.57. Arul's Mac Studio is on 1.2.59. They can still connect — update this machine when you can.", + ); + expect( + formatVersionSkewNote({ + localVersion: "1.2.59", + remoteVersion: "1.2.59", + remoteName: "Studio", + }), + ).toBeNull(); + }); + + it("recognizes both the old jargon and the new version-skew copy", () => { + expect( + isVersionSkewWarning( + "Remote ADE service reported 1.2.57; local ADE is 1.2.59. The versions differ but their RPC capabilities match, so ADE connected normally — nothing to do.", + ), + ).toBe(true); + expect( + isVersionSkewWarning( + "This machine is on ADE 1.2.59. The other machine is on 1.2.57. They can still connect — update the other machine when you can.", + ), + ).toBe(true); + expect(isVersionSkewWarning("Remote ADE service is missing project capabilities: create.")).toBe( + false, + ); + }); +}); + describe("formatRemoteTargetError", () => { it("does not blame sshd for a refused paired-sync port", () => { expect(formatRemoteTargetError("ECONNREFUSED")).toBe( diff --git a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts index c30751b52..7d17e2dce 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteMachineModel.ts @@ -271,7 +271,32 @@ export function formatLastSeen(value: number | null): string { if (!value) return "Never connected"; const date = new Date(value); if (!Number.isFinite(date.getTime())) return "Last connection unknown"; - return `Last connected ${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; + const phrase = relativeLastSeenPhrase(value); + return phrase ? `Last connected ${phrase}` : "Last connection unknown"; +} + +/** Quiet version-skew note. Yellow warnings are reserved for real breakage. */ +export function formatVersionSkewNote(args: { + localVersion: string | null | undefined; + remoteVersion: string | null | undefined; + remoteName: string; +}): string | null { + const local = args.localVersion?.trim(); + const remote = args.remoteVersion?.trim(); + if (!local || !remote || local === remote) return null; + let advice = "nothing to do"; + if (isMachineVersionOutdated(remote, local)) { + advice = `update ${args.remoteName} when you can`; + } else if (isMachineVersionOutdated(local, remote)) { + advice = "update this machine when you can"; + } + return `This machine is on ADE ${local}. ${args.remoteName} is on ${remote}. They can still connect — ${advice}.`; +} + +export function isVersionSkewWarning(warning: string): boolean { + return /Remote ADE service reported|RPC capabilities (?:are compatible|match)|versions differ but their RPC|They can still connect — update the other machine/i.test( + warning, + ); } /** diff --git a/apps/desktop/src/renderer/components/remoteTargets/remoteTargetListStyles.ts b/apps/desktop/src/renderer/components/remoteTargets/remoteTargetListStyles.ts index b5d3a1331..f5f8d788c 100644 --- a/apps/desktop/src/renderer/components/remoteTargets/remoteTargetListStyles.ts +++ b/apps/desktop/src/renderer/components/remoteTargets/remoteTargetListStyles.ts @@ -1,5 +1,5 @@ import type { CSSProperties } from "react"; -import { COLORS, MONO_FONT, SANS_FONT } from "../lanes/laneDesignTokens"; +import { COLORS, SANS_FONT } from "../lanes/laneDesignTokens"; export const panelStyle: CSSProperties = { display: "grid", @@ -49,14 +49,13 @@ export const inlineSuccessTextStyle: CSSProperties = { export const sectionHeaderStyle: CSSProperties = { color: COLORS.textMuted, fontFamily: SANS_FONT, - fontSize: 10.5, + fontSize: 11, fontWeight: 600, - letterSpacing: "0.08em", }; export const nameStyle: CSSProperties = { color: COLORS.textPrimary, - fontFamily: MONO_FONT, + fontFamily: SANS_FONT, fontSize: 13, fontWeight: 700, overflow: "hidden", @@ -66,9 +65,23 @@ export const nameStyle: CSSProperties = { export const subTextStyle: CSSProperties = { color: COLORS.textMuted, - fontFamily: MONO_FONT, + fontFamily: SANS_FONT, fontSize: 12, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", }; + +export const iconActionButtonStyle: CSSProperties = { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 28, + height: 28, + padding: 0, + border: "none", + borderRadius: 7, + background: "transparent", + color: COLORS.textSecondary, + cursor: "pointer", +}; diff --git a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.test.tsx b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.test.tsx index ad7a44414..3f8490f9c 100644 --- a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.test.tsx @@ -159,7 +159,7 @@ const autoConfirm = async () => true; * every test that touches them has to open it first. */ function openPairing() { - fireEvent.click(screen.getByRole("button", { name: /^Pairing code/ })); + fireEvent.click(screen.getByRole("button", { name: /^Manual pairing code/ })); } describe("ThisMacCard", () => { @@ -171,13 +171,13 @@ describe("ThisMacCard", () => { }); it("shows the account state line for a signed-in Mac", () => { - render(); + render(); expect(screen.getByText("Studio")).toBeTruthy(); expect(screen.getByText("Connected to your ADE account")).toBeTruthy(); }); it("says nothing about readiness or routes when this computer is healthy", () => { - render(); + render(); // A healthy host has no actionable status, so it gets no status line at all. expect(screen.queryByText("Ready to accept connections")).toBeNull(); expect(screen.queryByText(/Reachable via/)).toBeNull(); @@ -187,13 +187,12 @@ describe("ThisMacCard", () => { render( , ); // Connecting runs through the ADE account now; no code is a normal state. expect(screen.queryByText(/Set a pairing code/)).toBeNull(); - expect(screen.getByRole("button", { name: /^Pairing code/ }).textContent) - .toContain("Not set"); + expect(screen.getByRole("button", { name: /^Manual pairing code/ })).toBeTruthy(); }); it("renames this computer and mirrors the name into the account directory", async () => { @@ -205,7 +204,7 @@ describe("ThisMacCard", () => { renameMachine, }, }; - render(); + render(); fireEvent.click(screen.getByRole("button", { name: "Rename Studio" })); fireEvent.change(screen.getByLabelText("Machine name"), { target: { value: "Workshop" } }); @@ -218,7 +217,7 @@ describe("ThisMacCard", () => { }); it("keeps the pencil visible but inert while signed out", () => { - render(); + render(); const pencil = screen.getByRole("button", { name: "Rename Studio" }); expect((pencil as HTMLButtonElement).disabled).toBe(true); expect(pencil.getAttribute("title")).toBe("Sign in to rename this computer"); @@ -232,7 +231,7 @@ describe("ThisMacCard", () => { skipReason: "The ADE brain is signed out of the ADE account.", lastHttpStatus: null, }; - render(); + render(); // The brain's skipReason stays out of the card; the line says what is true. expect(screen.getByText( @@ -247,18 +246,33 @@ describe("ThisMacCard", () => { blockingStateText: "Phone sync is unavailable in this ADE installation.", }); - render(); + render(); expect(screen.getByRole("alert").textContent).toContain( "Phone sync is unavailable in this ADE installation.", ); // The alert above carries the runtime's full explanation; the status line // does not repeat a one-liner version of it. - expect(screen.queryByRole("button", { name: /^Pairing code/ })).toBeNull(); + expect(screen.queryByRole("button", { name: /^Manual pairing code/ })).toBeNull(); expect(screen.queryByRole("button", { name: /Generate code/i })).toBeNull(); expect(screen.queryByRole("button", { name: /Remove/i })).toBeNull(); }); + it("still offers Repair on a cold unreadable read while signed out", () => { + (globalThis.window as any).ade = { app: { restartBackgroundService: vi.fn() } }; + render( + , + ); + expect(screen.getByRole("button", { name: "Repair" })).toBeTruthy(); + expect( + screen.getByText("Your session is still there — open your account to fix it"), + ).toBeTruthy(); + expect(screen.queryByText(/Not signed in/)).toBeNull(); + }); + it("restarts the brain and re-reads health when repairing an unreadable session", async () => { // The main process resolves only once the replacement brain answers, so the // hook re-reads health the moment the call settles — no renderer-side sleep. @@ -273,7 +287,7 @@ describe("ThisMacCard", () => { render( , ); @@ -299,7 +313,7 @@ describe("ThisMacCard", () => { throw new Error("launchctl load failed."); }); (globalThis.window as any).ade = { app: { restartBackgroundService } }; - render(); + render(); fireEvent.click(screen.getByRole("button", { name: "Repair" })); const failure = await screen.findByText("Repair failed — quit and reopen ADE."); @@ -316,14 +330,14 @@ describe("ThisMacCard", () => { state: "http_error", skipReason: "The account directory rejected the publish.", }; - render(); + render(); expect(screen.queryByRole("button", { name: "Repair" })).toBeNull(); }); it("explains nearby fallback when signed out", () => { - render(); + render(); expect( - screen.getByText("Not signed in — nearby devices can still connect with the pairing code"), + screen.getByText("Not signed in — you can still connect to other machines manually"), ).toBeTruthy(); }); @@ -332,7 +346,7 @@ describe("ThisMacCard", () => { render( , ); // Closed on mount — nothing inside is reachable until it is opened. @@ -348,7 +362,7 @@ describe("ThisMacCard", () => { render( , ); openPairing(); @@ -362,7 +376,7 @@ describe("ThisMacCard", () => { render( , ); openPairing(); @@ -373,14 +387,14 @@ describe("ThisMacCard", () => { }); it("reopens the pairing disclosure closed every time, never remembering it", () => { - const { unmount } = render(); + const { unmount } = render(); openPairing(); - expect(screen.getByRole("button", { name: /^Pairing code/ }).getAttribute("aria-expanded")) + expect(screen.getByRole("button", { name: /^Manual pairing code/ }).getAttribute("aria-expanded")) .toBe("true"); unmount(); - render(); - expect(screen.getByRole("button", { name: /^Pairing code/ }).getAttribute("aria-expanded")) + render(); + expect(screen.getByRole("button", { name: /^Manual pairing code/ }).getAttribute("aria-expanded")) .toBe("false"); }); @@ -392,20 +406,18 @@ describe("ThisMacCard", () => { (globalThis.window as any).ade = { app: { getInfo: vi.fn(async () => ({ appVersion: "1.2.28", platform })) }, }; - render(); + render(); expect(await screen.findByRole("img", { name: accessibleName })).toBeTruthy(); // The logo is the platform statement, so the version line never repeats it. - expect(await screen.findByText("ADE 1.2.28")).toBeTruthy(); + expect(await screen.findByText("This machine — ADE 1.2.28")).toBeTruthy(); expect(screen.queryByText(new RegExp(`ADE 1\\.2\\.28.*${accessibleName}`))).toBeNull(); }); - it("labels this computer with a chip", () => { - render(); - // Composed from THIS_MACHINE_NAME, never spelled out: this badge was - // macOS-only copy ("This Mac") until ADE shipped on Windows, and pinning the - // literal here is what let Settings and Account miss the rename. - expect(screen.getByText(THIS_MACHINE_NAME)).toBeTruthy(); + it("labels this pane as this machine", () => { + render(); + expect(screen.getByText("This machine")).toBeTruthy(); + expect(screen.queryByText(THIS_MACHINE_NAME)).toBeNull(); }); it("swaps the version line for the fault while the listener is down", async () => { @@ -418,15 +430,14 @@ describe("ThisMacCard", () => { listenerBound: false, reason: "Port 8787 is already in use.", }; - render(); + render(); expect(await screen.findByText("Port 8787 is already in use.")).toBeTruthy(); - // One slot, so the card never grows a line in the unhappy path. - expect(screen.queryByText("ADE 1.2.28")).toBeNull(); + expect(screen.getByText("This machine — ADE 1.2.28")).toBeTruthy(); }); it("no longer embeds a Connect-a-phone disclosure — the Phone tab owns pairing", () => { - render(); + render(); expect(screen.queryByText("Connect a phone")).toBeNull(); expect(screen.queryByText("Scan to pair")).toBeNull(); }); @@ -435,7 +446,7 @@ describe("ThisMacCard", () => { render( , ); // The card names the local machine, never the machine it routes to. @@ -453,7 +464,7 @@ describe("ThisMacCard", () => { boundMachineName: "Mac Studio", generatePin, })} - accountSignedIn + sessionState="active" />, ); openPairing(); @@ -768,18 +779,27 @@ describe("useSyncConnections local scoping", () => { }); describe("accountDirectorySummary", () => { - it("reflects whether signed-out nearby pairing has a configured code", () => { + it("keeps an unreadable session distinct from signed out", () => { + const status = { pairingPinConfigured: true } as SyncRoleSnapshot; + expect(accountDirectorySummary(status, "unreadable")).toEqual({ + label: "Your session is still there — open your account to fix it", + healthy: false, + }); + }); + + it("uses one signed-out line whether a pairing code is set or not", () => { const status = { pairingPinConfigured: false } as SyncRoleSnapshot; - expect(accountDirectorySummary(status, false)).toEqual({ - label: "Not signed in — set a pairing code so nearby devices can connect", + expect(accountDirectorySummary(status, "signed_out")).toEqual({ + label: "Not signed in — you can still connect to other machines manually", healthy: false, }); status.pairingPinConfigured = true; - expect(accountDirectorySummary(status, false).label).toContain( - "nearby devices can still connect with the pairing code", - ); + expect(accountDirectorySummary(status, "signed_out")).toEqual({ + label: "Not signed in — you can still connect to other machines manually", + healthy: false, + }); }); const summaryForState = ( @@ -792,7 +812,7 @@ describe("accountDirectorySummary", () => { accountDirectory: { state, skipReason, reachableEndpointCount: 0 }, }, } as SyncRoleSnapshot, - true, + "active", ); it("never leaks the publisher's internal skipReason into user copy", () => { diff --git a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx index 2a4e1bbe4..181fc6af6 100644 --- a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx @@ -16,12 +16,13 @@ import { import { accountDirectorySummary } from "./accountDirectorySummary"; import { QRCodeSVG } from "qrcode.react"; import { createPortal } from "react-dom"; -import { isBrainAccountSessionFailure } from "../../../shared/types"; -import type { - SyncDeviceRuntimeState, - SyncPeerDeviceType, - SyncPairingConnectInfo, - SyncRoleSnapshot, +import { + isBrainAccountSessionFailure, + type AdeAccountSessionState, + type SyncDeviceRuntimeState, + type SyncPeerDeviceType, + type SyncPairingConnectInfo, + type SyncRoleSnapshot, } from "../../../shared/types"; import { buildPairingQrPayload, encodePairingQrUrl } from "../../../shared/pairingQr"; import { THIS_MACHINE_NAME } from "../../../shared/machineIdentity"; @@ -42,7 +43,6 @@ import { SANS_FONT, cardStyle, dangerButton, - inlineBadge, outlineButton, primaryButton, } from "../lanes/laneDesignTokens"; @@ -140,7 +140,7 @@ function isCrdtSyncUnavailable(status: SyncRoleSnapshot): boolean { // laptop glyph *and* the "· Windows" suffix that used to trail the version — the // logo is the platform statement, so spelling it out again is pure duplication. function PlatformGlyph({ platform }: { platform: string | undefined }) { - const props = { size: 17, weight: "duotone" as const, color: COLORS.accent }; + const props = { size: 18, weight: "regular" as const, color: COLORS.textSecondary }; switch (platform) { case "darwin": return ; @@ -189,11 +189,12 @@ function useAppInfoLine(): { version: string; platform: string } | null { export function ThisMacCard({ sync, - accountSignedIn, + sessionState, }: { sync: SyncConnections; - accountSignedIn: boolean; + sessionState: AdeAccountSessionState; }) { + const accountSignedIn = sessionState === "active"; const { status, busy, error, notice, isRemoteBound, boundMachineName } = sync; const appInfo = useAppInfoLine(); // Restarting the brain is the fix when it cannot read the stored account @@ -254,25 +255,26 @@ export function ThisMacCard({ // the reader nothing they could act on, and a missing pairing code is a // normal state now that the account is the primary way to connect. const problem = connectionProblem(status, host); - const directorySummary = accountDirectorySummary(status, accountSignedIn); + const directorySummary = accountDirectorySummary(status, sessionState); // A brain-side unreadable account session is the one directory failure a // restart clears — same test RemoteTargetList runs on its publish health. - const showRepair = accountSignedIn - && isBrainAccountSessionFailure(status.routeHealth?.accountDirectory?.state) + // Repair stays available on a cold unreadable read even when signedIn is false. + const showRepair = isBrainAccountSessionFailure(status.routeHealth?.accountDirectory?.state) && repair.available; - return ( + return (
- - {THIS_MACHINE_NAME} - + {appInfo ? `This machine — ADE ${appInfo.version}` : "This machine"} +
@@ -323,8 +322,6 @@ export function ThisMacCard({ {showRepair ? : null}
- {/* Third line is one slot: the fault takes the version's place while - something is wrong, so the card never grows a line. */} {problem ? (
- ) : appInfo ? ( -
ADE {appInfo.version}
) : null}
@@ -365,7 +360,7 @@ export function ThisMacCard({ ) : null} {host && !crdtUnavailable ? ( - + {isRemoteBound ? ( - Pairing code - - · {pinConfigured ? "Set" : "Not set"} - + Manual pairing code {open ? (
diff --git a/apps/desktop/src/renderer/components/settings/accountDirectorySummary.ts b/apps/desktop/src/renderer/components/settings/accountDirectorySummary.ts index 6f6278a67..59a94e63d 100644 --- a/apps/desktop/src/renderer/components/settings/accountDirectorySummary.ts +++ b/apps/desktop/src/renderer/components/settings/accountDirectorySummary.ts @@ -1,8 +1,10 @@ import { describeUnpublishedAccountDirectory, + type AdeAccountSessionState, type SyncAccountDirectoryState, type SyncRoleSnapshot, } from "../../../shared/types"; +import { accountSessionConnectionsSubtitle } from "../../lib/account"; export type AccountDirectorySummary = { label: string; @@ -28,13 +30,17 @@ function unpublishedMachineLabel(state: SyncAccountDirectoryState): string { export function accountDirectorySummary( status: SyncRoleSnapshot, - accountSignedIn: boolean, + sessionState: AdeAccountSessionState, ): AccountDirectorySummary { - if (!accountSignedIn) { + if (sessionState === "unreadable") { return { - label: status.pairingPinConfigured - ? "Not signed in — nearby devices can still connect with the pairing code" - : "Not signed in — set a pairing code so nearby devices can connect", + label: accountSessionConnectionsSubtitle("unreadable"), + healthy: false, + }; + } + if (sessionState !== "active") { + return { + label: "Not signed in — you can still connect to other machines manually", healthy: false, }; } diff --git a/apps/desktop/src/renderer/lib/account.test.ts b/apps/desktop/src/renderer/lib/account.test.ts index cfa6b3ba2..87b665c8f 100644 --- a/apps/desktop/src/renderer/lib/account.test.ts +++ b/apps/desktop/src/renderer/lib/account.test.ts @@ -198,6 +198,33 @@ describe("account session state", () => { ).toBe("unreadable"); }); + it("uses one signed-out Connections header for expired and signed-out", async () => { + const { accountSessionTitle, accountSessionConnectionsSubtitle } = await import("./account"); + + expect(accountSessionTitle("signed_out")).toBe("Signed out"); + expect(accountSessionTitle("expired")).toBe("Signed out"); + expect(accountSessionTitle("unreadable")).toBe("Can't read your sign-in"); + expect(accountSessionConnectionsSubtitle("signed_out")).toBe( + "Sign in to easily connect your machines", + ); + expect(accountSessionConnectionsSubtitle("expired")).toBe( + "Sign in to easily connect your machines", + ); + }); + + it("keeps Connections header actions in the session label table", async () => { + const { + accountSessionConnectionsAction, + accountSessionConnectionsActionAria, + } = await import("./account"); + + expect(accountSessionConnectionsAction("signed_out")).toBe("Sign in"); + expect(accountSessionConnectionsAction("expired")).toBe("Sign in"); + expect(accountSessionConnectionsAction("unreadable")).toBe("Fix sign-in"); + expect(accountSessionConnectionsAction("active")).toBe("Manage account"); + expect(accountSessionConnectionsActionAria("unreadable")).toBe("Fix your sign-in"); + }); + it("only prompts a fresh sign-in for the state where it is safe", async () => { const { accountSessionNotice } = await import("./account"); diff --git a/apps/desktop/src/renderer/lib/account.ts b/apps/desktop/src/renderer/lib/account.ts index 085f528cf..9eb2b4832 100644 --- a/apps/desktop/src/renderer/lib/account.ts +++ b/apps/desktop/src/renderer/lib/account.ts @@ -106,6 +106,8 @@ const ACCOUNT_SESSION_LABELS: Record< title: string; shortLabel: string; connectionsSubtitle: string; + connectionsAction: string; + connectionsActionAria: string; } > = { active: { @@ -113,18 +115,24 @@ const ACCOUNT_SESSION_LABELS: Record< title: "Signed in to ADE", shortLabel: "Account", connectionsSubtitle: "Manage your account", + connectionsAction: "Manage account", + connectionsActionAria: "Manage account", }, signed_out: { notice: null, - title: "Not signed in", + title: "Signed out", shortLabel: "Signed out", - connectionsSubtitle: "Sign in to connect your devices", + connectionsSubtitle: "Sign in to easily connect your machines", + connectionsAction: "Sign in", + connectionsActionAria: "Sign in to ADE", }, expired: { notice: "Your ADE sign-in expired — sign in again.", - title: "Sign-in expired", - shortLabel: "Sign-in expired", - connectionsSubtitle: "Sign in again to connect your devices", + title: "Signed out", + shortLabel: "Signed out", + connectionsSubtitle: "Sign in to easily connect your machines", + connectionsAction: "Sign in", + connectionsActionAria: "Sign in to ADE", }, unreadable: { notice: @@ -132,6 +140,8 @@ const ACCOUNT_SESSION_LABELS: Record< title: "Can't read your sign-in", shortLabel: "Sign-in unavailable", connectionsSubtitle: "Your session is still there — open your account to fix it", + connectionsAction: "Fix sign-in", + connectionsActionAria: "Fix your sign-in", }, }; @@ -151,6 +161,14 @@ export function accountSessionConnectionsSubtitle(state: AdeAccountSessionState) return ACCOUNT_SESSION_LABELS[state].connectionsSubtitle; } +export function accountSessionConnectionsAction(state: AdeAccountSessionState): string { + return ACCOUNT_SESSION_LABELS[state].connectionsAction; +} + +export function accountSessionConnectionsActionAria(state: AdeAccountSessionState): string { + return ACCOUNT_SESSION_LABELS[state].connectionsActionAria; +} + export async function fetchAccountStatus(options?: { force?: boolean }): Promise { const api = accountApi(); if (!api?.status) return SIGNED_OUT_ACCOUNT; diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/connectionsPane.test.tsx b/apps/desktop/src/renderer/webclient/adapter/__tests__/connectionsPane.test.tsx index 10073ec27..4e7c05d7e 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/connectionsPane.test.tsx +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/connectionsPane.test.tsx @@ -62,7 +62,7 @@ describe("hosted Connections pane", () => { , ); - expect(await screen.findByText("No computers yet. Choose Add machine to connect one.")).toBeTruthy(); + expect(await screen.findByText("No computers yet. Add a machine to connect one.")).toBeTruthy(); await expect(window.ade.remoteRuntime.getConnectionSnapshot()).resolves.toEqual({ connections: [], connectedCount: 0, diff --git a/docs/features/remote-runtime/README.md b/docs/features/remote-runtime/README.md index 309f77e14..d3c6f8c87 100644 --- a/docs/features/remote-runtime/README.md +++ b/docs/features/remote-runtime/README.md @@ -760,7 +760,7 @@ won the same connection attempt; nothing is wrong. Version skew and capability skew no longer fail the connect outright. The bootstrap performs the JSON-RPC `ade/initialize` handshake, normalizes the `capabilities.machineProjects` flags returned by the remote runtime, and reports the result as `RemoteRuntimeCapabilities` plus a `compatibilityWarnings` array on the `RemoteRuntimeConnectResult`. The renderer's remote target panel displays each warning inline under the connection chip. Warnings cover: -- Runtime version mismatch (`Remote ADE service reported X; local ADE is Y. ADE will connect because the RPC capabilities are compatible.`). +- Runtime version mismatch, shown as a quiet Connections-row note from the two versions (`This machine is on ADE X. {name} is on Y. They can still connect — update the older machine when you can.`). - Remote package channel mismatch (e.g. desktop is `beta`, remote runtime advertises `stable`). - Missing `machineProjects` capabilities — `browseDirectories`, `getDetail`, `getWorkSummary`, `getDefaultParentDir`, `create`, `clone`, `listMyGitHubRepos`. These map to the `projects.*` RPCs the renderer uses for the project picker / new-project / clone flows. Missing capabilities do not block connect, but the connection pool refuses the matching call with a self-describing error when the renderer attempts it (e.g. `Remote ADE service 0.7.2 does not support cloning remote projects.`). - The bootstrap fell back to a different ADE home (`Using remote runtime home .ade-beta because .ade did not contain an ADE service for darwin-arm64.`).