Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand All @@ -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);
});
});

Expand Down
15 changes: 3 additions & 12 deletions apps/desktop/src/main/services/remoteRuntime/remoteBootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
}),
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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();
});
});
17 changes: 8 additions & 9 deletions apps/desktop/src/renderer/components/app/ConnectionsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs";
import {
accountAvatarImage,
accountInitials,
accountSessionConnectionsAction,
accountSessionConnectionsActionAria,
accountSessionConnectionsSubtitle,
accountSessionState,
accountSessionTitle,
Expand Down Expand Up @@ -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)}
>
<span style={{ flexShrink: 0 }}>
{avatarImage && !imgBroken ? (
Expand Down Expand Up @@ -203,7 +199,7 @@ function AccountHeader({
cursor: "pointer",
}}
>
Manage account
{accountSessionConnectionsAction(sessionState)}
</button>
</div>
);
Expand Down Expand Up @@ -324,7 +320,10 @@ export function ConnectionsPanel({
<AccountHeader githubStatus={githubStatus} onNavigate={goToAccount} onClose={onClose} />

<div style={{ padding: "12px 12px 0" }}>
<ThisMacCard sync={sync} accountSignedIn={accountStatus.signedIn} />
<ThisMacCard
sync={sync}
sessionState={accountSessionState(accountStatus)}
/>
</div>

<div
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/components/app/TopBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,7 @@ describe("TopBar", () => {
});

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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,11 +194,31 @@ describe("RemoteTargetList", () => {
},
});

render(<RemoteTargetList />);
expect(await screen.findByText(/route publish failing for 5 min/)).toBeTruthy();
render(<RemoteTargetList accountSignedIn />);
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(<RemoteTargetList accountSignedIn={false} />);
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({
Expand Down Expand Up @@ -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],
});
Expand All @@ -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();

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(
<RemoteTargetList
accountMachines={[]}
accountMachinesState="unavailable"
accountSignedIn={false}
/>,
);

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({
Expand Down
Loading
Loading