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
51 changes: 49 additions & 2 deletions src/renderer/components/thread/RemoteHostUpdateDock.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useRemoteServersStore } from "@/renderer/state/remoteServersStore";
import { renderWithI18n as render } from "@/renderer/testUtils/i18n";
Expand All @@ -14,6 +14,7 @@ describe("RemoteHostUpdateDock", () => {
"desktop-1": { status: "online", projects: [], threads: [] },
},
hostUpdates: {},
hostUpdateRestarts: {},
installHostUpdate,
});
});
Expand Down Expand Up @@ -50,8 +51,54 @@ describe("RemoteHostUpdateDock", () => {
});

render(<RemoteHostUpdateDock desktopId="desktop-1" />);
fireEvent.click(screen.getByRole("button", { name: "Install and restart" }));
const button = screen.getByRole("button", { name: "Install and restart" });
expect(button).toHaveClass("button--ghost");
fireEvent.click(button);

await waitFor(() => expect(installHostUpdate).toHaveBeenCalledWith("desktop-1"));
});

it("shows the restart spinner while the install request is pending", async () => {
let rejectInstall: (error: Error) => void = () => {};
installHostUpdate.mockImplementationOnce(
() =>
new Promise((_, reject) => {
rejectInstall = reject;
}),
);
useRemoteServersStore.setState({
hostUpdates: {
"desktop-1": {
currentVersion: "1.0.0",
status: { type: "downloaded", version: "1.1.0" },
},
},
});

render(<RemoteHostUpdateDock desktopId="desktop-1" />);
fireEvent.click(screen.getByRole("button", { name: "Install and restart" }));

expect(screen.getByRole("status")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Install and restart" })).not.toBeInTheDocument();

await act(async () => rejectInstall(new Error("Install failed")));

expect(await screen.findByText("Install failed")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Install and restart" })).toBeInTheDocument();
});

it("replaces the install button with a restart spinner", () => {
useRemoteServersStore.setState({
runtime: {
"desktop-1": { status: "connecting", projects: [], threads: [] },
},
hostUpdateRestarts: { "desktop-1": "1.1.0" },
});

render(<RemoteHostUpdateDock desktopId="desktop-1" />);

expect(screen.getByText("The host is restarting to install the update.")).toBeInTheDocument();
expect(screen.getByRole("status")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Install and restart" })).not.toBeInTheDocument();
});
});
32 changes: 21 additions & 11 deletions src/renderer/components/thread/RemoteHostUpdateDock.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect } from "react";
import { Button, toast } from "@heroui/react";
import { Button, Spinner, toast } from "@heroui/react";
import { useLingui } from "@lingui/react/macro";
import { Download } from "lucide-react";
import { useAsyncOperation } from "@/renderer/hooks/useAsyncOperation";
Expand All @@ -9,6 +9,7 @@ import { ThreadDockHeader, ThreadDockSection } from "./ThreadDockUI";
export function RemoteHostUpdateDock({ desktopId }: { readonly desktopId: string }) {
const { t } = useLingui();
const update = useRemoteServersStore((state) => state.hostUpdates[desktopId]);
const restartingVersion = useRemoteServersStore((state) => state.hostUpdateRestarts[desktopId]);
const isOnline = useRemoteServersStore((state) => state.runtime[desktopId]?.status === "online");
const installHostUpdate = useRemoteServersStore((state) => state.installHostUpdate);
const getHostUpdateState = useRemoteServersStore((state) => state.getHostUpdateState);
Expand All @@ -28,20 +29,25 @@ export function RemoteHostUpdateDock({ desktopId }: { readonly desktopId: string
}, [desktopId, getHostUpdateState, isUpdating]);

if (
!status ||
(status.type !== "update-available" &&
status.type !== "downloading" &&
status.type !== "downloaded")
!restartingVersion &&
(!status ||
(status.type !== "update-available" &&
status.type !== "downloading" &&
status.type !== "downloaded"))
) {
return null;
}

const title =
status.type === "update-available"
const isInstalling = busy || restartingVersion !== undefined;
const title = isInstalling
? t`The host is restarting to install the update.`
: status?.type === "update-available"
? t`Remote host update v${status.version} is downloading…`
: status.type === "downloading"
: status?.type === "downloading"
? t`Remote host update is downloading… ${Math.round(status.percent)}%`
: t`Remote host update v${status.version} is ready.`;
: status?.type === "downloaded"
? t`Remote host update v${status.version} is ready.`
: "";

const install = () =>
run(async () => {
Expand All @@ -56,8 +62,12 @@ export function RemoteHostUpdateDock({ desktopId }: { readonly desktopId: string
iconClassName="text-accent"
title={title}
actions={
status.type === "downloaded" ? (
<Button size="sm" variant="secondary" isDisabled={busy || !isOnline} onPress={install}>
isInstalling ? (
<span role="status" aria-label={title}>
<Spinner size="sm" color="current" />
</span>
) : status?.type === "downloaded" ? (
<Button size="sm" variant="ghost" isDisabled={busy || !isOnline} onPress={install}>
{t`Install and restart`}
</Button>
) : null
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/components/thread/ThreadDraftComposerArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,9 @@ export function ThreadDraftComposerArea(props: {
// Remote-project attachments are stored on the paired desktop; resolve
// previews through its image endpoint instead of the local-file protocol.
const remoteDesktopId = props.project.remoteServerId;
const hostUpdateRestarting = useRemoteServersStore((state) =>
remoteDesktopId ? state.hostUpdateRestarts[remoteDesktopId] !== undefined : false,
);
const attachmentImageUrlForPath = remoteDesktopId
? (path: string) => useRemoteServersStore.getState().localImageUrl(remoteDesktopId, path)
: undefined;
Expand Down Expand Up @@ -676,6 +679,7 @@ export function ThreadDraftComposerArea(props: {
}

function submitSegments(allSegments: PromptSegment[], fallbackPrompt = "") {
if (hostUpdateRestarting) return;
if (experimentMode) {
void runExperiment(allSegments, fallbackPrompt);
return;
Expand Down Expand Up @@ -1163,6 +1167,7 @@ export function ThreadDraftComposerArea(props: {
submitDisabled={
authRequired ||
agentUpdating ||
hostUpdateRestarting ||
isSubmitting ||
!(hasContent || attachments.attachments.length > 0) ||
(experimentMode && experimentCandidates.length < 2)
Expand Down
90 changes: 89 additions & 1 deletion src/renderer/components/thread/ThreadDraftView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,12 @@ describe("ThreadDraftView", () => {
sharedSettingsHydrated: true,
});
useAppStore.setState({ pendingDraftWorktreeSelections: {} });
useRemoteServersStore.setState({ servers: [], runtime: {}, hostUpdates: {} });
useRemoteServersStore.setState({
servers: [],
runtime: {},
hostUpdates: {},
hostUpdateRestarts: {},
});
});

it("adds experiment candidates without a prompt and keeps the composer submit button", () => {
Expand Down Expand Up @@ -1020,6 +1025,89 @@ describe("ThreadDraftView", () => {
expect(screen.queryByText("No supported agents detected")).not.toBeInTheDocument();
});

it("shows the remote connection's specific error message", () => {
useRemoteServersStore.setState({
servers: [
{
desktopId: "desktop-1",
label: "Remote Mac",
endpoint: "http://remote/",
accessToken: "token",
scopes: [],
},
],
runtime: {
"desktop-1": {
status: "error",
message: "This app version is incompatible with that server.",
projects: [],
threads: [],
},
},
});

render(<ThreadDraftView project={remoteProject} agentStatuses={[]} onStart={() => {}} />);

expect(
screen.getByText("This app version is incompatible with that server."),
).toBeInTheDocument();
expect(screen.queryByText(/remote server is offline/i)).not.toBeInTheDocument();
});

it("shows the remote connecting state instead of the missing-agent message", () => {
useRemoteServersStore.setState({
servers: [
{
desktopId: "desktop-1",
label: "Remote Mac",
endpoint: "http://remote/",
accessToken: "token",
scopes: [],
},
],
runtime: {
"desktop-1": { status: "connecting", projects: [], threads: [] },
},
});

render(<ThreadDraftView project={remoteProject} agentStatuses={[]} onStart={() => {}} />);

expect(screen.getByText("Connecting…")).toBeInTheDocument();
expect(screen.queryByText("Connection error")).not.toBeInTheDocument();
expect(screen.queryByText("No supported agents detected")).not.toBeInTheDocument();
});

it("keeps the remote composer visible and disables submit during a host update restart", () => {
const onStart = vi.fn<(input: unknown) => void>();
useRemoteServersStore.setState({
servers: [
{
desktopId: "desktop-1",
label: "Remote Mac",
endpoint: "http://remote/",
accessToken: "token",
scopes: ["projects:manage"],
hostMode: "desktop",
},
],
runtime: {
"desktop-1": { status: "connecting", projects: [], threads: [] },
},
hostUpdateRestarts: { "desktop-1": "1.1.0" },
});

render(
<ThreadDraftView project={remoteProject} agentStatuses={[codexStatus]} onStart={onStart} />,
);

expect(screen.queryByText("Connecting…")).not.toBeInTheDocument();
const composer = composerSpy.mock.lastCall?.[0] as { submitDisabled?: boolean };
expect(composer.submitDisabled).toBe(true);
fireEvent.click(screen.getByText("set-prompt"));
fireEvent.click(screen.getByText("submit"));
expect(onStart).not.toHaveBeenCalled();
});

it("shows the discovery reveal for a WSL project while its distro is probing", () => {
const onStart = vi.fn<(input: unknown) => void>();
useAgentStatusesStore.getState().beginFirstLaunchDiscovery({ kind: "wsl", distro: "Ubuntu" });
Expand Down
14 changes: 11 additions & 3 deletions src/renderer/components/thread/ThreadDraftView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,12 @@ export function ThreadDraftView(props: {
if (!state.servers.some((server) => server.desktopId === remoteServerId)) return "missing";
return state.runtime[remoteServerId]?.status ?? "connecting";
});
const hostUpdateRestarting = useRemoteServersStore((state) =>
project.remoteServerId ? state.hostUpdateRestarts[project.remoteServerId] !== undefined : false,
);
const remoteConnectionMessage = useRemoteServersStore((state) =>
project.remoteServerId ? state.runtime[project.remoteServerId]?.message : undefined,
);

// Debugging showed config-only edits were rebuilding the provider/model
// payload. Keep the installed-agent list stable unless the source inputs
Expand Down Expand Up @@ -1042,7 +1048,7 @@ export function ThreadDraftView(props: {
spacerRef: anchorSpacerRef,
});

if (remoteConnection === "connecting") {
if (remoteConnection === "connecting" && !hostUpdateRestarting) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<PixelLoader size="md" />
Expand All @@ -1052,14 +1058,16 @@ export function ThreadDraftView(props: {
</div>
);
}
if (remoteConnection !== "local" && remoteConnection !== "online") {
if (!hostUpdateRestarting && remoteConnection !== "local" && remoteConnection !== "online") {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
<Trans>Connection error</Trans>
</h1>
<p className="text-muted">
<Trans>This project's remote server is offline. Reconnect it to start a thread.</Trans>
{remoteConnectionMessage ?? (
<Trans>This project's remote server is offline. Reconnect it to start a thread.</Trans>
)}
</p>
</div>
);
Expand Down
43 changes: 43 additions & 0 deletions src/renderer/state/remoteServers/hostUpdateReconnect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const RECONNECT_INTERVAL_MS = 1_000;
const RECONNECT_TIMEOUT_MS = 60_000;
const TIMED_OUT = Symbol("timed-out");

export type HostUpdateReconnectOutcome =
| { readonly type: "connected" }
| { readonly type: "cancelled" }
| { readonly type: "timeout" }
| { readonly type: "terminal-error"; readonly error: unknown };

export async function waitForHostUpdateReconnect(options: {
readonly isCurrent: () => boolean;
readonly attempt: () => Promise<boolean>;
readonly isTerminalError: (error: unknown) => boolean;
}): Promise<HostUpdateReconnectOutcome> {
const deadline = Date.now() + RECONNECT_TIMEOUT_MS;

while (options.isCurrent() && Date.now() < deadline) {
const remaining = deadline - Date.now();
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const connected = await Promise.race([
options.attempt(),
new Promise<typeof TIMED_OUT>((resolve) => {
timeout = setTimeout(() => resolve(TIMED_OUT), remaining);
}),
]).finally(() => {
if (timeout) clearTimeout(timeout);
});
if (connected === TIMED_OUT) return { type: "timeout" };
if (connected) return { type: "connected" };
} catch (error) {
if (options.isTerminalError(error)) return { type: "terminal-error", error };
}

const retryDelay = Math.min(RECONNECT_INTERVAL_MS, deadline - Date.now());
if (retryDelay > 0) {
await new Promise((resolve) => setTimeout(resolve, retryDelay));
}
}

return options.isCurrent() ? { type: "timeout" } : { type: "cancelled" };
}
2 changes: 2 additions & 0 deletions src/renderer/state/remoteServers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ export interface RemoteServersState {
servers: RemoteServerRecord[];
runtime: Record<string, RemoteServerRuntime>;
hostUpdates: Record<string, RemoteHostUpdateState>;
/** Expected version while a remotely installed desktop update restarts its host. */
hostUpdateRestarts: Record<string, string>;
/**
* Remote (server-side) project ids the user excluded from sync, keyed by
* desktopId. Local-only state, so a project can be dropped from — or restored
Expand Down
Loading