diff --git a/app/cmd/app/webview.go b/app/cmd/app/webview.go
index 0427601a7..89da90507 100644
--- a/app/cmd/app/webview.go
+++ b/app/cmd/app/webview.go
@@ -238,6 +238,13 @@ func (w *Webview) Run(path string) unsafe.Pointer {
return
}
+ if runtime.GOOS == "darwin" {
+ // Keep the current frame through the handoff. SetSize also
+ // recenters the macOS window and would jump before Apps paints.
+ setOnboardingWindowStyle(wv.Window(), false)
+ return
+ }
+
width, height := defaultWindowWidth, defaultWindowHeight
if w.Store != nil {
storedWidth, storedHeight, err := w.Store.WindowSize()
diff --git a/app/ui/app/src/components/CodexDesktopRow.test.tsx b/app/ui/app/src/components/CodexDesktopRow.test.tsx
index daf32b4e2..81748aed2 100644
--- a/app/ui/app/src/components/CodexDesktopRow.test.tsx
+++ b/app/ui/app/src/components/CodexDesktopRow.test.tsx
@@ -6,7 +6,7 @@ import {
notifyManager,
} from "@tanstack/react-query";
import { renderToStaticMarkup } from "react-dom/server";
-import { act, create } from "react-test-renderer";
+import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CodexConnectedIntro } from "./CodexConnectedIntro";
import {
@@ -64,18 +64,14 @@ function status(
};
}
+function connectionButton(renderer: ReactTestRenderer) {
+ return renderer.root.find(
+ (node) =>
+ node.type === "button" && typeof node.props["aria-pressed"] === "boolean",
+ );
+}
+
describe("CodexDesktopRow", () => {
- it("renders a disconnected ChatGPT toggle", () => {
- const html = renderToStaticMarkup(
- ,
- );
-
- expect(html).toContain(">ChatGPT (Desktop)
");
- expect(html).toContain("Use Ollama models in ChatGPT");
- expect(html).toContain('aria-label="Add Ollama models to ChatGPT"');
- expect(html).toContain('aria-checked="false"');
- });
-
it("matches Claude's connected copy before the first request", () => {
const html = renderToStaticMarkup(
{
expect(html).not.toContain("Codex + Ollama");
expect(html).not.toContain("3 Ollama models");
expect(html).toContain('aria-label="Remove Ollama models from ChatGPT"');
- expect(html).toContain('aria-checked="true"');
+ expect(html).toContain('aria-pressed="true"');
});
it.each([
@@ -119,21 +115,7 @@ describe("CodexDesktopRow", () => {
},
);
- it("offers installation when ChatGPT is not installed", () => {
- const html = renderToStaticMarkup(
- ,
- );
-
- expect(html).toContain("Use Ollama models in ChatGPT");
- expect(html).not.toContain('disabled=""');
- expect(html).toContain('title="Install ChatGPT and add Ollama models"');
- expect(html).toContain("Download & connect");
- });
-
- it("matches Claude's download and install progress states", async () => {
+ it("keeps the connection busy until ChatGPT installation is detected", async () => {
const notInstalled = status({ installed: false });
let finishInstall!: (result: "opened") => void;
const install = new Promise<"opened">((resolve) => {
@@ -162,26 +144,19 @@ describe("CodexDesktopRow", () => {
/>,
);
});
- const toggle = renderer!.root.findByProps({ role: "switch" });
+ const toggle = connectionButton(renderer!);
+ expect(toggle.props["aria-pressed"]).toBe(false);
+ expect(toggle.props.disabled).toBe(false);
await act(async () => {
toggle.props.onClick();
await Promise.resolve();
});
- expect(toggle.props["aria-checked"]).toBe(true);
+ expect(window.installCodexDesktop).toHaveBeenCalledOnce();
+ expect(toggle.props["aria-pressed"]).toBe(true);
expect(toggle.props["aria-busy"]).toBe(true);
expect(toggle.props.disabled).toBe(true);
- expect(toggle.props.className).toContain("disabled:cursor-wait");
- expect(renderer!.root.findByProps({ role: "status" }).children).toContain(
- "Downloading…",
- );
- expect(
- renderer!.root.findAll((node) =>
- node.children.includes(
- "Ollama is downloading the ChatGPT installer…",
- ),
- ),
- ).toHaveLength(1);
+ expect(toggle.findByProps({ role: "status" })).toBeTruthy();
await act(async () => {
finishInstall("opened");
@@ -189,25 +164,16 @@ describe("CodexDesktopRow", () => {
await Promise.resolve();
});
- expect(toggle.props["aria-checked"]).toBe(true);
+ expect(toggle.props["aria-pressed"]).toBe(true);
expect(toggle.props["aria-busy"]).toBe(true);
expect(toggle.props.disabled).toBe(true);
- expect(renderer!.root.findByProps({ role: "status" }).children).toContain(
- "Finish installing…",
- );
- expect(
- renderer!.root.findAll((node) =>
- node.children.includes(
- "Finish installing ChatGPT. Ollama will connect it automatically.",
- ),
- ),
- ).toHaveLength(1);
+ expect(toggle.findByProps({ role: "status" })).toBeTruthy();
} finally {
await act(async () => renderer?.unmount());
}
});
- it("matches Claude's connecting state", async () => {
+ it("disables the connection button while connecting ChatGPT", async () => {
let finishConnect!: (result: { status: CodexDesktopStatus }) => void;
const connect = new Promise<{ status: CodexDesktopStatus }>((resolve) => {
finishConnect = resolve;
@@ -229,23 +195,16 @@ describe("CodexDesktopRow", () => {
/>,
);
});
- const toggle = renderer!.root.findByProps({ role: "switch" });
+ const toggle = connectionButton(renderer!);
await act(async () => {
toggle.props.onClick();
await Promise.resolve();
});
- expect(toggle.props["aria-checked"]).toBe(true);
+ expect(toggle.props["aria-pressed"]).toBe(true);
expect(toggle.props["aria-busy"]).toBe(true);
expect(toggle.props.disabled).toBe(true);
- expect(renderer!.root.findByProps({ role: "status" }).children).toContain(
- "Connecting…",
- );
- expect(
- renderer!.root.findAll((node) =>
- node.children.includes("Connecting ChatGPT to Ollama…"),
- ),
- ).toHaveLength(1);
+ expect(toggle.findByProps({ role: "status" })).toBeTruthy();
await act(async () => {
finishConnect({ status: status({ connected: true }) });
@@ -304,7 +263,7 @@ describe("CodexDesktopRow", () => {
expect(
renderer!.root.findByProps({
"aria-label": "Remove Ollama models from ChatGPT",
- }).props["aria-checked"],
+ }).props["aria-pressed"],
).toBe(true);
expect(renderer!.root.findByProps({ role: "status" }).children).toContain(
"Ollama models added alongside Codex models",
@@ -357,7 +316,7 @@ describe("CodexDesktopRow", () => {
/>,
);
});
- const toggle = renderer!.root.findByProps({ role: "switch" });
+ const toggle = connectionButton(renderer!);
await act(async () => toggle.props.onClick());
await act(async () =>
vi.advanceTimersByTimeAsync(CODEX_DESKTOP_INSTALL_TIMEOUT_MS - 2000),
@@ -369,7 +328,7 @@ describe("CodexDesktopRow", () => {
if (!retry) settleOldCheck();
});
expect(toggle.props.disabled).toBe(false);
- expect(toggle.props["aria-checked"]).toBe(false);
+ expect(toggle.props["aria-pressed"]).toBe(false);
expect(
renderer!.root.findByProps({ role: "alert" }).children,
).toContain("ChatGPT installation wasn’t detected. Try again.");
@@ -391,7 +350,7 @@ describe("CodexDesktopRow", () => {
await act(async () => vi.advanceTimersByTimeAsync(1000));
expect(connect).toHaveBeenCalledOnce();
expect(toggle.props.disabled).toBe(false);
- expect(toggle.props["aria-checked"]).toBe(true);
+ expect(toggle.props["aria-pressed"]).toBe(true);
}
} finally {
await act(async () => renderer?.unmount());
@@ -426,7 +385,7 @@ describe("CodexDesktopRow", () => {
/>,
);
});
- const toggle = renderer!.root.findByProps({ role: "switch" });
+ const toggle = connectionButton(renderer!);
await act(async () => toggle.props.onClick());
if (step === "connection") {
await act(async () => check.resolve(status()));
@@ -491,15 +450,11 @@ describe("CodexDesktopRow", () => {
expect(renderer!.root.findAllByType(CodexConnectedIntro)).toHaveLength(
0,
);
- expect(
- renderer!.root.findByProps({ role: "alert" }).children,
- ).toContain(
- "ChatGPT is installed. Turn on the switch to restart it with Ollama models.",
- );
+ expect(renderer!.root.findByProps({ role: "alert" })).toBeTruthy();
expect(
renderer!.root.findByProps({
"aria-label": "Add Ollama models to ChatGPT",
- }).props["aria-checked"],
+ }).props["aria-pressed"],
).toBe(false);
} finally {
await act(async () => renderer?.unmount());
@@ -536,7 +491,7 @@ describe("CodexDesktopRow", () => {
});
expect(getStatus).not.toHaveBeenCalled();
- expect(toggle.props["aria-checked"]).toBe(false);
+ expect(toggle.props["aria-pressed"]).toBe(false);
expect(toggle.props.disabled).toBe(false);
expect(renderer!.root.findAllByProps({ role: "alert" })).toHaveLength(0);
} finally {
@@ -635,7 +590,7 @@ describe("CodexDesktopRow", () => {
expect(
renderer!.root.findByProps({
"aria-label": "Remove Ollama models from ChatGPT",
- }).props["aria-checked"],
+ }).props["aria-pressed"],
).toBe(true);
} finally {
await act(async () => renderer?.unmount());
@@ -692,12 +647,62 @@ describe("CodexDesktopRow", () => {
expect(renderer!.root.findByProps({ role: "alert" }).children).toContain(
"quit ChatGPT: timed out waiting for ChatGPT to exit",
);
- expect(toggle.props["aria-checked"]).toBe(false);
+ expect(toggle.props["aria-pressed"]).toBe(false);
} finally {
await act(async () => renderer?.unmount());
}
});
+ it.each([false, true])(
+ "honors the native ChatGPT disconnect confirmation: %s",
+ async (confirmed) => {
+ const connected = status({ connected: true, running: true });
+ const confirm = vi.fn(() => confirmed);
+ const disconnect = vi
+ .fn()
+ .mockResolvedValueOnce({
+ status: connected,
+ restartConfirmationRequired: true,
+ })
+ .mockResolvedValue({ status: status({ running: true }) });
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.stubGlobal("window", {
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ setCodexDesktopConnected: disconnect,
+ confirm,
+ });
+ let renderer;
+ try {
+ await act(async () => {
+ renderer = create(
+ ,
+ );
+ });
+ const toggle = connectionButton(renderer!);
+ await act(async () => {
+ toggle.props.onClick();
+ toggle.props.onClick();
+ });
+ expect(confirm).toHaveBeenCalledOnce();
+ expect(disconnect).toHaveBeenNthCalledWith(1, false, false);
+ if (confirmed) {
+ expect(disconnect).toHaveBeenCalledTimes(2);
+ expect(disconnect).toHaveBeenLastCalledWith(false, true);
+ } else {
+ expect(disconnect).toHaveBeenCalledOnce();
+ expect(toggle.props.disabled).toBe(false);
+ expect(toggle.props["aria-pressed"]).toBe(true);
+ }
+ } finally {
+ await act(async () => renderer?.unmount());
+ }
+ },
+ );
+
it("allows the normal profile to be restored if ChatGPT is removed", async () => {
const html = renderToStaticMarkup(
{
);
});
await act(async () => {
- renderer!.root.findByProps({ role: "switch" }).props.onClick();
+ connectionButton(renderer!).props.onClick();
});
expect(connect).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
expect(confirm).toHaveBeenCalledTimes(running ? 1 : 0);
- const toggle = renderer!.root.findByProps({ role: "switch" });
- expect(toggle.props["aria-checked"]).toBe(true);
+ const toggle = connectionButton(renderer!);
+ expect(toggle.props["aria-pressed"]).toBe(true);
expect(toggle.props.disabled).toBe(true);
const intro = renderer!.root.findByType(CodexConnectedIntro);
await act(async () => {
@@ -791,7 +796,7 @@ describe("ChatGPT first connection intro", () => {
expect(confirm).toHaveBeenCalledTimes(running ? 1 : 0);
expect(save).toHaveBeenCalledOnce();
expect(toggle.props.disabled).toBe(false);
- expect(toggle.props["aria-checked"]).toBe(true);
+ expect(toggle.props["aria-pressed"]).toBe(true);
expect(renderer!.root.findAllByType(CodexConnectedIntro)).toHaveLength(
0,
);
@@ -829,7 +834,7 @@ describe("ChatGPT first connection intro", () => {
);
});
await act(async () => {
- renderer!.root.findByProps({ role: "switch" }).props.onClick();
+ connectionButton(renderer!).props.onClick();
});
expect(connect).not.toHaveBeenCalled();
expect(renderer!.root.findAllByType(CodexConnectedIntro)).toHaveLength(1);
@@ -862,10 +867,10 @@ it("leaves the Apps page usable when the initial restart is cancelled", async ()
/>,
);
});
- const toggle = renderer!.root.findByProps({ role: "switch" });
+ const toggle = connectionButton(renderer!);
await act(async () => toggle.props.onClick());
expect(renderer!.root.findAllByType(CodexConnectedIntro)).toHaveLength(0);
- expect(toggle.props["aria-checked"]).toBe(false);
+ expect(toggle.props["aria-pressed"]).toBe(false);
expect(toggle.props.disabled).toBe(false);
expect(connect).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
@@ -903,18 +908,18 @@ it.each(["failed", "rejected", "save failed"])(
/>,
);
});
- const toggle = renderer!.root.findByProps({ role: "switch" });
+ const toggle = connectionButton(renderer!);
await act(async () => {
toggle.props.onClick();
});
- expect(toggle.props["aria-checked"]).toBe(true);
+ expect(toggle.props["aria-pressed"]).toBe(true);
expect(toggle.props.disabled).toBe(true);
const intro = renderer!.root.findByType(CodexConnectedIntro);
await act(async () => {
intro.props.onDone();
});
expect(renderer!.root.findAllByType(CodexConnectedIntro)).toHaveLength(0);
- expect(toggle.props["aria-checked"]).toBe(outcome === "save failed");
+ expect(toggle.props["aria-pressed"]).toBe(outcome === "save failed");
expect(toggle.props.disabled).toBe(false);
expect(renderer!.root.findByProps({ role: "alert" }).children).toContain(
outcome === "failed"
@@ -958,7 +963,7 @@ it("dismisses before launch and prevents duplicate Continue requests", async ()
/>,
);
});
- const toggle = renderer!.root.findByProps({ role: "switch" });
+ const toggle = connectionButton(renderer!);
await act(async () => toggle.props.onClick());
const intro = renderer!.root.findByType(CodexConnectedIntro);
await act(async () => {
@@ -1010,18 +1015,16 @@ it.each(["cancelled", "status failed"])(
/>,
);
});
- await act(async () =>
- renderer!.root.findByProps({ role: "switch" }).props.onClick(),
- );
+ await act(async () => connectionButton(renderer!).props.onClick());
await act(async () =>
renderer!.root.findByType(CodexConnectedIntro).props.onDone(),
);
expect(connect).not.toHaveBeenCalled();
expect(save).not.toHaveBeenCalled();
expect(renderer!.root.findAllByType(CodexConnectedIntro)).toHaveLength(0);
- const toggle = renderer!.root.findByProps({ role: "switch" });
+ const toggle = connectionButton(renderer!);
expect(toggle.props.disabled).toBe(false);
- expect(toggle.props["aria-checked"]).toBe(false);
+ expect(toggle.props["aria-pressed"]).toBe(false);
} finally {
await act(async () => renderer?.unmount());
}
@@ -1059,9 +1062,7 @@ it("does not open a restart prompt after leaving the Apps page", async () => {
,
);
});
- await act(async () =>
- renderer!.root.findByProps({ role: "switch" }).props.onClick(),
- );
+ await act(async () => connectionButton(renderer!).props.onClick());
await act(async () => renderer!.unmount());
await act(async () =>
action.resolve({
@@ -1105,9 +1106,7 @@ it("keeps the latest status when focus refreshes complete out of order", async (
});
await act(async () => newer.resolve(status({ connected: true })));
await act(async () => older.resolve(status()));
- expect(
- renderer!.root.findByProps({ role: "switch" }).props["aria-checked"],
- ).toBe(true);
+ expect(connectionButton(renderer!).props["aria-pressed"]).toBe(true);
} finally {
await act(async () => renderer?.unmount());
}
@@ -1134,7 +1133,7 @@ it("does not start overlapping installers before the switch rerenders", async ()
/>,
);
});
- const toggle = renderer!.root.findByProps({ role: "switch" });
+ const toggle = connectionButton(renderer!);
await act(async () => {
toggle.props.onClick();
toggle.props.onClick();
@@ -1142,7 +1141,7 @@ it("does not start overlapping installers before the switch rerenders", async ()
expect(install).toHaveBeenCalledOnce();
await act(async () => result.resolve("cancelled"));
expect(toggle.props.disabled).toBe(false);
- expect(toggle.props["aria-checked"]).toBe(false);
+ expect(toggle.props["aria-pressed"]).toBe(false);
} finally {
await act(async () => renderer?.unmount());
}
@@ -1179,7 +1178,7 @@ it("does not retry acknowledgment during a disconnect, or reconnect to save afte
/>,
);
});
- const toggle = renderer!.root.findByProps({ role: "switch" });
+ const toggle = connectionButton(renderer!);
await act(async () => toggle.props.onClick());
await act(async () =>
renderer!.root.findByType(CodexConnectedIntro).props.onDone(),
@@ -1198,7 +1197,7 @@ it("does not retry acknowledgment during a disconnect, or reconnect to save afte
await act(async () => retry.props.onClick());
expect(connect).toHaveBeenCalledTimes(2);
expect(save).toHaveBeenCalledTimes(2);
- expect(toggle.props["aria-checked"]).toBe(false);
+ expect(toggle.props["aria-pressed"]).toBe(false);
expect(
renderer!.root.findAllByProps({ "aria-label": "Retry saving progress" }),
).toHaveLength(0);
@@ -1239,9 +1238,7 @@ it.each(["returned", "rejected"])(
/>,
);
});
- await act(async () =>
- renderer!.root.findByProps({ role: "switch" }).props.onClick(),
- );
+ await act(async () => connectionButton(renderer!).props.onClick());
await act(async () =>
renderer!.root.findByType(CodexConnectedIntro).props.onDone(),
);
@@ -1257,7 +1254,7 @@ it.each(["returned", "rejected"])(
const retryButton = renderer!.root.findByProps({
"aria-label": "Retry saving progress",
});
- const toggle = renderer!.root.findByProps({ role: "switch" });
+ const toggle = connectionButton(renderer!);
await act(async () => {
retryButton.props.onClick();
retryButton.props.onClick();
@@ -1267,7 +1264,7 @@ it.each(["returned", "rejected"])(
expect(save).toHaveBeenCalledTimes(2);
expect(retryButton.props.disabled).toBe(true);
expect(toggle.props.disabled).toBe(true);
- expect(toggle.props["aria-checked"]).toBe(true);
+ expect(toggle.props["aria-pressed"]).toBe(true);
await act(async () => retry.resolve(""));
expect(
renderer!.root.findAllByProps({
@@ -1276,7 +1273,7 @@ it.each(["returned", "rejected"])(
).toHaveLength(0);
expect(renderer!.root.findAllByProps({ role: "alert" })).toHaveLength(0);
expect(toggle.props.disabled).toBe(false);
- expect(toggle.props["aria-checked"]).toBe(true);
+ expect(toggle.props["aria-pressed"]).toBe(true);
expect(connect).toHaveBeenCalledOnce();
expect(save).toHaveBeenCalledTimes(2);
} finally {
@@ -1323,9 +1320,7 @@ it.each([
await act(async () => {
renderer = create();
});
- await act(async () =>
- renderer!.root.findByProps({ role: "switch" }).props.onClick(),
- );
+ await act(async () => connectionButton(renderer!).props.onClick());
await act(async () =>
renderer!.root.findByType(CodexConnectedIntro).props.onDone(),
);
@@ -1337,7 +1332,7 @@ it.each([
await act(async () => {
renderer = create();
});
- const toggle = renderer!.root.findByProps({ role: "switch" });
+ const toggle = connectionButton(renderer!);
if (timing === "after returning") {
expect(toggle.props.disabled).toBe(true);
await act(async () => toggle.props.onClick());
@@ -1361,9 +1356,7 @@ it.each([
await act(async () => {
renderer = create();
});
- expect(
- renderer!.root.findByProps({ role: "switch" }).props.disabled,
- ).toBe(true);
+ expect(connectionButton(renderer!).props.disabled).toBe(true);
await act(async () =>
renderer!.root
.findByProps({ "aria-label": "Retry saving progress" })
@@ -1377,12 +1370,8 @@ it.each([
}),
).toHaveLength(0);
expect(renderer!.root.findAllByProps({ role: "alert" })).toHaveLength(0);
- expect(
- renderer!.root.findByProps({ role: "switch" }).props.disabled,
- ).toBe(false);
- expect(
- renderer!.root.findByProps({ role: "switch" }).props["aria-checked"],
- ).toBe(true);
+ expect(connectionButton(renderer!).props.disabled).toBe(false);
+ expect(connectionButton(renderer!).props["aria-pressed"]).toBe(true);
expect(connect).toHaveBeenCalledOnce();
expect(save).toHaveBeenCalledTimes(2);
} finally {
@@ -1415,9 +1404,7 @@ it("refreshes connection status when Retry overtakes the returning page's status
await act(async () => {
renderer = create();
});
- await act(async () =>
- renderer!.root.findByProps({ role: "switch" }).props.onClick(),
- );
+ await act(async () => connectionButton(renderer!).props.onClick());
await act(async () =>
renderer!.root.findByType(CodexConnectedIntro).props.onDone(),
);
@@ -1435,12 +1422,8 @@ it("refreshes connection status when Retry overtakes the returning page's status
);
await act(async () => stale.resolve(connectedStatus));
await act(async () => retrySave.resolve(""));
- expect(
- renderer!.root.findByProps({ role: "switch" }).props["aria-checked"],
- ).toBe(true);
- expect(renderer!.root.findByProps({ role: "switch" }).props.disabled).toBe(
- false,
- );
+ expect(connectionButton(renderer!).props["aria-pressed"]).toBe(true);
+ expect(connectionButton(renderer!).props.disabled).toBe(false);
expect(connect).toHaveBeenCalledOnce();
expect(save).toHaveBeenCalledTimes(2);
} finally {
@@ -1468,9 +1451,7 @@ it("observes a successful pending save after returning without saving again", as
await act(async () => {
renderer = create();
});
- await act(async () =>
- renderer!.root.findByProps({ role: "switch" }).props.onClick(),
- );
+ await act(async () => connectionButton(renderer!).props.onClick());
await act(async () =>
renderer!.root.findByType(CodexConnectedIntro).props.onDone(),
);
@@ -1479,17 +1460,13 @@ it("observes a successful pending save after returning without saving again", as
await act(async () => {
renderer = create();
});
- expect(renderer!.root.findByProps({ role: "switch" }).props.disabled).toBe(
- true,
- );
+ expect(connectionButton(renderer!).props.disabled).toBe(true);
await act(async () => pendingSave.resolve(""));
expect(
renderer!.root.findAllByProps({ "aria-label": "Retry saving progress" }),
).toHaveLength(0);
expect(renderer!.root.findAllByType(CodexConnectedIntro)).toHaveLength(0);
- expect(renderer!.root.findByProps({ role: "switch" }).props.disabled).toBe(
- false,
- );
+ expect(connectionButton(renderer!).props.disabled).toBe(false);
expect(connect).toHaveBeenCalledOnce();
expect(save).toHaveBeenCalledOnce();
} finally {
@@ -1562,9 +1539,7 @@ it.each(["resolved", "rejected"])(
);
});
await act(async () => onFocus?.());
- await act(async () =>
- renderer!.root.findByProps({ role: "switch" }).props.onClick(),
- );
+ await act(async () => connectionButton(renderer!).props.onClick());
await act(async () =>
renderer!.root.findByType(CodexConnectedIntro).props.onDone(),
);
@@ -1572,9 +1547,7 @@ it.each(["resolved", "rejected"])(
if (outcome === "resolved") stale.resolve(firstUseStatus);
else stale.reject(new Error("stale status failure"));
});
- expect(
- renderer!.root.findByProps({ role: "switch" }).props["aria-checked"],
- ).toBe(true);
+ expect(connectionButton(renderer!).props["aria-pressed"]).toBe(true);
expect(renderer!.root.findByProps({ role: "alert" }).children).toContain(
"Ollama couldn’t save your progress. Please try again.",
);
diff --git a/app/ui/app/src/components/CodexDesktopRow.tsx b/app/ui/app/src/components/CodexDesktopRow.tsx
index 12e8071ad..034ab0c08 100644
--- a/app/ui/app/src/components/CodexDesktopRow.tsx
+++ b/app/ui/app/src/components/CodexDesktopRow.tsx
@@ -5,7 +5,8 @@ import type {
CodexDesktopActionResult,
CodexDesktopStatus,
} from "@/types/webview";
-import { ArrowPathIcon, CommandLineIcon } from "@heroicons/react/24/outline";
+import { CommandLineIcon } from "@heroicons/react/24/outline";
+import { IntegrationConnectButton } from "@/components/IntegrationConnectButton";
import {
useMutation,
useMutationState,
@@ -254,7 +255,7 @@ export function CodexDesktopRow({
if (next.running) {
setError(
- "ChatGPT is installed. Turn on the switch to restart it with Ollama models.",
+ "ChatGPT is installed. Click Connect to restart it with Ollama models.",
);
return;
}
@@ -270,7 +271,7 @@ export function CodexDesktopRow({
setStatus(result.status);
if (result.restartConfirmationRequired) {
setError(
- "ChatGPT is installed. Turn on the switch to restart it with Ollama models.",
+ "ChatGPT is installed. Click Connect to restart it with Ollama models.",
);
} else if (result.error || !result.status.connected) {
setError(
@@ -316,8 +317,7 @@ export function CodexDesktopRow({
phase === "waiting-for-install" ||
phase === "connecting";
const progress = connectionProgress[savingAcknowledgment ? "saving" : phase];
- const statusLabel =
- progress?.label ?? (!connected && !installed ? "Download & connect" : null);
+ const statusLabel = progress?.label ?? null;
const actionError =
error ??
(acknowledgmentFailed
@@ -327,7 +327,12 @@ export function CodexDesktopRow({
actionError ??
notice ??
progress?.description ??
- codexDesktopDescription(status, integration.description);
+ codexDesktopDescription(
+ status,
+ installed
+ ? "Use Ollama models in Codex mode in ChatGPT."
+ : "We’ll download ChatGPT and connect it to Ollama.",
+ );
const saveAcknowledgment = async (): Promise => {
if (queryClient.isMutating({ mutationKey: acknowledgmentKey }))
@@ -413,9 +418,9 @@ export function CodexDesktopRow({
let result: CodexDesktopActionResult =
await window.setCodexDesktopConnected(enabled, restartConfirmed);
+ if (!mounted.current) return;
setStatus(result.status);
if (result.restartConfirmationRequired) {
- if (!mounted.current) return;
// Keep focus-driven status refreshes from discarding this operation
// while the native confirmation dialog temporarily owns focus.
if (
@@ -423,11 +428,13 @@ export function CodexDesktopRow({
enabled
? "Restart ChatGPT to add Ollama models? Any running task will stop."
: "Restart ChatGPT to remove Ollama models? Any running task will stop.",
- )
+ ) ||
+ !mounted.current
) {
return;
}
result = await window.setCodexDesktopConnected(enabled, true);
+ if (!mounted.current) return;
setStatus(result.status);
}
@@ -467,16 +474,19 @@ export function CodexDesktopRow({
};
return (
-
-
+
+
-
- ChatGPT (Desktop)
+
+ ChatGPT
{description}
@@ -494,22 +504,11 @@ export function CodexDesktopRow({
Retry
)}
- {statusLabel && (
-
- {pending && }
- {statusLabel}
-
- )}
-
+ />
{showIntro && (
void toggleConnection(true)} />
diff --git a/app/ui/app/src/components/IntegrationConnectButton.tsx b/app/ui/app/src/components/IntegrationConnectButton.tsx
new file mode 100644
index 000000000..a83b5dba8
--- /dev/null
+++ b/app/ui/app/src/components/IntegrationConnectButton.tsx
@@ -0,0 +1,39 @@
+import { ArrowPathIcon } from "@heroicons/react/24/outline";
+import type { ComponentProps } from "react";
+
+export function IntegrationConnectButton({
+ connected,
+ busy,
+ progress,
+ label,
+ ...props
+}: Pick, "onClick" | "disabled" | "title"> & {
+ connected: boolean;
+ label: string;
+ busy: boolean;
+ progress?: string | null;
+}) {
+ return (
+
+ );
+}
diff --git a/app/ui/app/src/components/Onboarding.test.tsx b/app/ui/app/src/components/Onboarding.test.tsx
index 17f414611..2f35aa93d 100644
--- a/app/ui/app/src/components/Onboarding.test.tsx
+++ b/app/ui/app/src/components/Onboarding.test.tsx
@@ -1,3 +1,4 @@
+import { StrictMode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient } from "@tanstack/react-query";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
@@ -18,13 +19,27 @@ import {
isClaudeConnectionComplete,
scheduleClaudeInstallTimeout,
} from "@/lib/claudeDesktop";
-import { isWindowsPlatform } from "@/lib/platform";
import {
authenticationTimeoutAction,
- nextOnboardingStep,
onboardingConnectUrl,
} from "@/lib/onboarding";
import type { IntegrationStatuses } from "@/api";
+import * as clipboard from "@/utils/clipboard";
+
+// Transition timing is checked in the browser; these tests cover copy-notice
+// lifetimes with the real component logic.
+vi.mock("@headlessui/react", async (importOriginal) => {
+ const original = await importOriginal();
+ return Object.assign({}, original, {
+ Transition: ({
+ show,
+ children,
+ }: {
+ show: boolean;
+ children: React.ReactNode;
+ }) => (show ? {children}
: null),
+ });
+});
let queryClient: QueryClient;
beforeEach(() => {
@@ -44,6 +59,16 @@ vi.mock("@tanstack/react-query", async (importOriginal) => {
});
});
+function claudeConnectionButton(renderer: ReactTestRenderer) {
+ return renderer.root
+ .findByProps({ id: "integration-claude-desktop" })
+ .find(
+ (node) =>
+ node.type === "button" &&
+ typeof node.props["aria-pressed"] === "boolean",
+ );
+}
+
describe("Onboarding", () => {
it("explains what Ollama is before asking the user to choose a path", () => {
const html = renderToStaticMarkup();
@@ -81,59 +106,6 @@ describe("Onboarding", () => {
}
});
- it("hides the Claude and ChatGPT desktop integrations on Windows", () => {
- vi.stubGlobal("window", {
- OLLAMA_PLATFORM: "windows",
- innerHeight: 660,
- });
- vi.stubGlobal("navigator", { platform: "MacIntel" });
- try {
- expect(isWindowsPlatform()).toBe(true);
- const html = renderToStaticMarkup(
- ,
- );
-
- expect(html).not.toContain('id="desktop-heading"');
- expect(html).not.toContain("Use Ollama models in Claude Desktop");
- expect(html).not.toContain("Use Ollama models in ChatGPT");
- expect(html).not.toContain("ollama launch chatgpt");
- expect(html).toContain('id="terminal-heading"');
- expect(html).toContain("ollama launch claude");
- } finally {
- vi.unstubAllGlobals();
- }
- });
-
- it("shows the account choice only to signed-out users", () => {
- expect(nextOnboardingStep("intro", "continue", false)).toBe("welcome");
- expect(nextOnboardingStep("intro", "continue", true)).toBe("apps");
- expect(nextOnboardingStep("welcome", "authenticated", true)).toBe("apps");
- expect(nextOnboardingStep("apps", "continue", true)).toBe("apps");
- expect(nextOnboardingStep("welcome", "local", false)).toBe("run");
- });
-
it("lets an in-flight authentication check finish before timing out", () => {
expect(authenticationTimeoutAction(false, true)).toBe("defer");
expect(authenticationTimeoutAction(false, false)).toBe("fail");
@@ -187,7 +159,7 @@ describe("Onboarding", () => {
}
});
- it("keeps the Claude switch on and busy through installer detection", async () => {
+ it("keeps the Claude connection busy through installer detection", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
const disconnectedStatus = {
@@ -242,10 +214,9 @@ describe("Onboarding", () => {
await Promise.resolve();
});
- const claudeSwitch = () =>
- renderer!.root
- .findAllByProps({ role: "switch" })
- .find((node) => String(node.props["aria-label"]).endsWith("Claude"))!;
+ const claudeSwitch = () => claudeConnectionButton(renderer!);
+ expect(claudeSwitch().props["aria-pressed"]).toBe(false);
+ expect(claudeSwitch().props.disabled).toBe(false);
let clickResult!: Promise;
await act(async () => {
clickResult = claudeSwitch().props.onClick();
@@ -253,22 +224,11 @@ describe("Onboarding", () => {
await Promise.resolve();
});
- expect(claudeSwitch().props["aria-checked"]).toBe(true);
+ expect(window.installClaudeDesktop).toHaveBeenCalledOnce();
+ expect(claudeSwitch().props["aria-pressed"]).toBe(true);
expect(claudeSwitch().props["aria-busy"]).toBe(true);
expect(claudeSwitch().props.disabled).toBe(true);
- expect(claudeSwitch().props.className).toContain("disabled:opacity-50");
- expect(
- renderer.root
- .findAllByProps({ role: "status" })
- .some((node) => node.children.includes("Downloading…")),
- ).toBe(true);
- expect(
- renderer.root.findAll(
- (node) =>
- typeof node.props.className === "string" &&
- node.props.className.includes("animate-spin"),
- ),
- ).not.toHaveLength(0);
+ expect(claudeSwitch().findByProps({ role: "status" })).toBeTruthy();
await act(async () => {
finishInstall("opened");
@@ -276,22 +236,10 @@ describe("Onboarding", () => {
await Promise.resolve();
});
- expect(claudeSwitch().props["aria-checked"]).toBe(true);
+ expect(claudeSwitch().props["aria-pressed"]).toBe(true);
expect(claudeSwitch().props["aria-busy"]).toBe(true);
expect(claudeSwitch().props.disabled).toBe(true);
- expect(claudeSwitch().props.className).toContain("disabled:opacity-50");
- expect(
- renderer.root
- .findAllByProps({ role: "status" })
- .some((node) => node.children.includes("Finish installing…")),
- ).toBe(true);
- expect(
- renderer.root.findAll(
- (node) =>
- typeof node.props.className === "string" &&
- node.props.className.includes("animate-spin"),
- ),
- ).not.toHaveLength(0);
+ expect(claudeSwitch().findByProps({ role: "status" })).toBeTruthy();
} finally {
if (renderer) {
act(() => renderer?.unmount());
@@ -368,11 +316,8 @@ describe("Onboarding", () => {
await Promise.resolve();
});
- const claudeSwitch = () =>
- renderer!.root
- .findAllByProps({ role: "switch" })
- .find((node) => String(node.props["aria-label"]).endsWith("Claude"))!;
- expect(claudeSwitch().props["aria-checked"]).toBe(false);
+ const claudeSwitch = () => claudeConnectionButton(renderer!);
+ expect(claudeSwitch().props["aria-pressed"]).toBe(false);
expect(claudeSwitch().props["aria-busy"]).toBeUndefined();
expect(claudeSwitch().props.disabled).toBe(false);
@@ -384,7 +329,7 @@ describe("Onboarding", () => {
});
expect(setClaudeConnected).toHaveBeenCalledWith(true, false);
- expect(claudeSwitch().props["aria-checked"]).toBe(true);
+ expect(claudeSwitch().props["aria-pressed"]).toBe(true);
expect(claudeSwitch().props["aria-busy"]).toBe(true);
expect(claudeSwitch().props.disabled).toBe(true);
@@ -393,7 +338,7 @@ describe("Onboarding", () => {
await clickResult;
});
- expect(claudeSwitch().props["aria-checked"]).toBe(false);
+ expect(claudeSwitch().props["aria-pressed"]).toBe(false);
expect(claudeSwitch().props["aria-busy"]).toBeUndefined();
expect(claudeSwitch().props.disabled).toBe(false);
expect(
@@ -410,7 +355,7 @@ describe("Onboarding", () => {
});
expect(getClaudeStatus).toHaveBeenCalledOnce();
- expect(claudeSwitch().props["aria-checked"]).toBe(true);
+ expect(claudeSwitch().props["aria-pressed"]).toBe(true);
expect(claudeSwitch().props["aria-busy"]).toBeUndefined();
expect(claudeSwitch().props.disabled).toBe(false);
expect(
@@ -504,224 +449,11 @@ describe("Onboarding", () => {
expect(html).not.toContain("Sign up");
});
- it("groups disconnected Claude with a scrollable terminal list", () => {
- const integrations: IntegrationStatuses = [
- {
- id: "claude-desktop",
- name: "Claude Code (Desktop)",
- description: "Use Ollama models in Claude Desktop",
- installed: true,
- action: "connect",
- },
- {
- id: "claude",
- name: "Claude Code",
- description: "Anthropic's coding tool with subagents",
- installed: true,
- action: "copy",
- command: "ollama launch claude",
- },
- {
- id: "codex",
- name: "Codex CLI",
- description: "OpenAI's open-source coding agent",
- installed: true,
- action: "copy",
- command: "ollama launch codex",
- },
- {
- id: "openclaw",
- name: "OpenClaw",
- description: "Personal AI with 100+ skills",
- installed: true,
- action: "copy",
- command: "ollama launch openclaw",
- },
- {
- id: "opencode",
- name: "OpenCode",
- description: "Anomaly's open-source coding agent",
- installed: false,
- action: "copy",
- command: "ollama launch opencode",
- },
- {
- id: "droid",
- name: "Droid",
- description: "AI software engineering agent",
- installed: false,
- action: "copy",
- command: "ollama launch droid",
- },
- {
- id: "dsh",
- name: "DeepSeek Harness",
- description: "DeepSeek's open-source agent harness",
- installed: false,
- action: "copy",
- command: "ollama launch dsh",
- },
- {
- id: "cline",
- name: "Cline",
- description: "Autonomous coding agent",
- installed: false,
- action: "copy",
- command: "ollama launch cline",
- },
- {
- id: "terminal",
- name: "Terminal",
- description: "Run local models from your terminal",
- action: "copy",
- command: "ollama",
- },
- ];
+ it("offers ChatGPT when the catalog has no desktop metadata", () => {
const html = renderToStaticMarkup(
- ,
+ ,
);
-
- expect(html).not.toContain(
- "Connect Claude, or copy a command to run in your terminal.",
- );
- expect(html).toContain("Claude Code (Desktop)");
- expect(html).toContain("Use Ollama models in Claude Desktop");
- expect(html).toContain("Claude Code");
- expect(html).toContain("Codex CLI");
- expect(html).not.toContain("Search apps");
- expect(html).not.toContain('type="search"');
- expect(html).toContain("Desktop");
- expect(html).toContain('id="desktop-heading"');
- expect(html).toContain('id="terminal-heading"');
- expect(html).not.toContain("Ready to launch");
- expect(html).not.toContain('id="claude-apps-heading"');
- expect(html.indexOf("Desktop")).toBeLessThan(
- html.indexOf("Use Ollama models in Claude Desktop"),
- );
- expect(html).not.toContain(">Command");
- expect(html).toContain("ollama launch claude");
- expect(html).not.toContain("Installed");
- expect(html).toContain("Use Ollama models in ChatGPT");
- expect(html).toContain('aria-label="Connect Claude"');
- expect(html).toContain('role="switch"');
- expect(html).toContain('aria-checked="false"');
- expect(html).not.toContain("Inactive");
- expect(html).toContain("Download & connect");
- expect(html).not.toContain("Active");
- expect(html).toContain("bg-transparent");
- expect(html).toContain('aria-label="Copy OpenCode command"');
- expect(html).toContain('aria-label="Copy Terminal command"');
- expect(html).not.toContain(">Copy command");
- expect(html).toContain("ChatGPT (Desktop)");
- expect(html).toContain("OpenCode");
- expect(html).toContain("Terminal");
- expect(html).toContain("overflow-y-auto");
- expect(html).not.toContain('aria-label="Show more apps"');
- expect(html).not.toContain("aria-expanded");
- expect(html).not.toContain("grid-rows-[0fr]");
- expect(html).not.toContain("inert");
- expect(html).toContain("/launch-icons/claude.svg");
- expect(html).toContain("/launch-icons/claude-code.svg");
- expect(html).toContain("/launch-icons/codex-color.svg");
- expect(html).toMatch(
- /src="\/launch-icons\/cline\.svg"[^>]*class="[^"]*dark:invert/,
- );
- expect(html).toContain("/launch-icons/deepseek-harness.svg");
- expect(html).not.toMatch(
- /src="\/launch-icons\/deepseek-harness\.svg"[^>]*class="[^"]*dark:invert/,
- );
- expect(html).not.toContain(" {
- const html = renderToStaticMarkup(
- ,
- );
-
- expect(html.indexOf("Use Ollama models in Claude Desktop")).toBeLessThan(
- html.indexOf(">ChatGPT (Desktop)"),
- );
- expect(html).toContain('aria-label="Add Ollama models to ChatGPT"');
- expect(html).not.toContain('aria-label="Copy ChatGPT command"');
- expect(html).toContain('aria-label="Copy Codex CLI command"');
- });
-
- it("keeps connected Claude in Desktop without an idle status", () => {
- const html = renderToStaticMarkup(
- ,
- );
-
- expect(html).toContain('id="desktop-heading"');
- expect(html).not.toContain('id="claude-apps-heading"');
- expect(html).not.toContain("Ready to launch");
- expect(html).not.toContain("Active");
- expect(html).not.toContain("Inactive");
- expect(html).toContain('aria-checked="true"');
- expect(html).toContain('aria-label="Disconnect Claude"');
- expect(html).toContain("Connected to Ollama · 12 requests this session");
+ expect(html).toContain('id="integration-chatgpt"');
});
it("shows initial Claude recovery guidance without error styling", () => {
@@ -757,7 +489,7 @@ describe("Onboarding", () => {
);
expect(html).toContain('role="alert"');
expect(html).not.toContain("text-red");
- expect(html).toContain('aria-checked="true"');
+ expect(html).toContain('aria-pressed="true"');
expect(html).toContain('aria-label="Disconnect Claude"');
});
@@ -810,34 +542,6 @@ describe("Onboarding", () => {
expect(html).not.toContain("Built-in defaults");
});
- it("keeps Claude available without a separate not-installed group", () => {
- const html = renderToStaticMarkup(
- ,
- );
-
- expect(html).toContain("Use Ollama models in Claude Desktop");
- expect(html).toContain('aria-label="Connect Claude"');
- expect(html).toContain("Download & connect");
- expect(html).not.toContain("Inactive");
- const claudeButton = html.match(
- /
@@ -258,6 +262,7 @@ function InlineError({
export function WelcomeScreen({
isAuthenticated,
isSigningIn,
+ isLeaving = false,
signInError,
completionError = null,
onSignIn,
@@ -285,15 +290,20 @@ export function WelcomeScreen({
type="button"
className="flex h-11 w-full cursor-pointer items-center justify-center rounded-full bg-neutral-900 px-5 font-sans text-sm font-normal text-white transition-colors hover:bg-neutral-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 disabled:cursor-wait disabled:opacity-70"
onClick={onSignUp}
- disabled={isSigningIn}
- aria-busy={isSigningIn}
+ disabled={isSigningIn || isLeaving}
+ aria-busy={isSigningIn || isLeaving}
>
- {isSigningIn ? "Finish in your browser…" : "Sign up"}
+ {isLeaving
+ ? "Opening apps…"
+ : isSigningIn
+ ? "Finish in your browser…"
+ : "Sign up"}
@@ -357,8 +367,8 @@ export function RunOllamaScreen({
);
}
-function LaunchCommandIcon({ item }: { item: IntegrationStatus }) {
- const icon = INTEGRATION_ICONS[item.id];
+function LaunchCommandIcon({ id }: { id: string }) {
+ const icon = INTEGRATION_ICONS[id];
return (
@@ -438,7 +448,20 @@ export function ConnectAppsScreen({
initialCodexStatus,
}: ConnectAppsScreenProps) {
const isWindows = isWindowsPlatform();
- const [copiedCommand, setCopiedCommand] = useState
(null);
+ const [copyNotice, setCopyNotice] = useState<{
+ sequence: number;
+ id: string;
+ name: string;
+ command: string;
+ copied: boolean;
+ visible: boolean;
+ } | null>(null);
+ const copyInFlight = useRef(false);
+ const copySequence = useRef(0);
+ const copyNoticeRef = useRef(null);
+ const [initialClaudeStatusSettled, setInitialClaudeStatusSettled] = useState(
+ Boolean(initialClaudeStatus),
+ );
const [claudeError, setClaudeError] = useState(null);
const [claudeStatus, setClaudeStatus] = useState(
initialClaudeStatus ?? null,
@@ -461,14 +484,34 @@ export function ConnectAppsScreen({
}, []);
useEffect(() => {
- if (!copiedCommand) return;
-
+ if (!copyNotice?.copied) return;
const timeout = window.setTimeout(() => {
- setCopiedCommand(null);
- }, 5000);
-
+ setCopyNotice((current) => current && { ...current, visible: false });
+ }, 6000);
return () => window.clearTimeout(timeout);
- }, [copiedCommand]);
+ }, [copyNotice?.sequence, copyNotice?.copied]);
+
+ useEffect(() => {
+ if (!copyNotice?.visible || copyNotice.copied) return;
+
+ const dismiss = () => {
+ setCopyNotice((current) =>
+ current && !current.copied ? { ...current, visible: false } : current,
+ );
+ };
+ const onPointerDown = (event: PointerEvent) => {
+ if (!copyNoticeRef.current?.contains(event.target as Node)) dismiss();
+ };
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape" && !event.defaultPrevented) dismiss();
+ };
+ window.addEventListener("pointerdown", onPointerDown);
+ window.addEventListener("keydown", onKeyDown);
+ return () => {
+ window.removeEventListener("pointerdown", onPointerDown);
+ window.removeEventListener("keydown", onKeyDown);
+ };
+ }, [copyNotice?.copied, copyNotice?.visible]);
const refreshClaudeStatus = useCallback(async () => {
if (isWindows) return null;
@@ -497,30 +540,46 @@ export function ConnectAppsScreen({
const integrations = initialIntegrations
? Promise.resolve(initialIntegrations)
: getIntegrationStatuses();
- const claude = initialClaudeStatus
- ? Promise.resolve(initialClaudeStatus)
- : getClaudeConnectionSummary();
- void Promise.allSettled([integrations, claude]).then(
- ([integrationResult, claudeResult]) => {
+ void integrations.then(
+ (statuses) => {
if (!active) return;
- if (integrationResult.status === "fulfilled") {
- setIntegrationStatuses(integrationResult.value);
- } else {
- setStatusError(true);
- }
- if (claudeResult.status === "fulfilled") {
- setClaudeStatus(claudeResult.value);
- } else {
- setClaudeError("Ollama could not read the Claude connection status.");
- }
+ setIntegrationStatuses(statuses);
+ },
+ () => {
+ if (!active) return;
+ setStatusError(true);
},
);
return () => {
active = false;
};
- }, [initialClaudeStatus, initialIntegrations]);
+ }, [initialIntegrations]);
+
+ useEffect(() => {
+ let active = true;
+ const claude = initialClaudeStatus
+ ? Promise.resolve(initialClaudeStatus)
+ : getClaudeConnectionSummary();
+
+ void claude.then(
+ (status) => {
+ if (!active) return;
+ setClaudeStatus(status);
+ setInitialClaudeStatusSettled(true);
+ },
+ () => {
+ if (!active) return;
+ setClaudeError("Ollama could not read the Claude connection status.");
+ setInitialClaudeStatusSettled(true);
+ },
+ );
+
+ return () => {
+ active = false;
+ };
+ }, [initialClaudeStatus]);
const openConnectedClaude = useCallback(
async (status: ClaudeDesktopStatus) => {
@@ -593,12 +652,13 @@ export function ConnectAppsScreen({
);
const dismissClaudeConnectedIntro = async () => {
- if (!window.setClaudeDesktopConnected) return;
+ if (!window.setClaudeDesktopConnected || claudePhase !== "idle") return;
setClaudePhase("launching");
try {
const liveStatus = await withClaudeConnectionTimeout(
getClaudeConnectionSummary(),
);
+ if (!screenMounted.current) return;
if (!liveStatus) {
throw new Error("Claude Desktop connection status is unavailable");
}
@@ -609,6 +669,7 @@ export function ConnectAppsScreen({
restartConfirmed = window.confirm(
"Restart Claude Desktop to use Ollama? Any running task will stop.",
);
+ if (!screenMounted.current) return;
if (!restartConfirmed) {
setClaudePhase("idle");
return;
@@ -755,10 +816,25 @@ export function ConnectAppsScreen({
}, [claudePhase, finishClaudeConnection, reconcileLateClaudeAction]);
const copyLaunchCommand = async (item: IntegrationStatus) => {
- if (item.command && (await copyTextToClipboard(item.command))) {
- setClaudeError(null);
- setCopiedCommand(item.command);
+ if (!item.command || copyInFlight.current) return;
+ copyInFlight.current = true;
+ let copied = false;
+ try {
+ copied = await copyTextToClipboard(item.command);
+ } catch {
+ // Keep the command available for manual copying when clipboard access fails.
+ } finally {
+ copyInFlight.current = false;
}
+ if (!screenMounted.current) return;
+ setCopyNotice({
+ sequence: ++copySequence.current,
+ id: item.id,
+ name: item.name,
+ command: item.command,
+ copied,
+ visible: true,
+ });
};
const connectClaude = async () => {
@@ -772,7 +848,7 @@ export function ConnectAppsScreen({
return;
}
- setCopiedCommand(null);
+ setCopyNotice((current) => current && { ...current, visible: false });
setClaudeError(null);
const enabling = claudeStatus ? !isClaudeConfigured(claudeStatus) : true;
setClaudePhase(enabling ? "connecting" : "disconnecting");
@@ -789,6 +865,7 @@ export function ConnectAppsScreen({
);
return;
}
+ if (!screenMounted.current) return;
if (!status) {
setClaudePhase("idle");
setClaudeError("Ollama could not read the Claude connection status.");
@@ -842,6 +919,7 @@ export function ConnectAppsScreen({
? "Restart Claude Desktop to use Ollama? Any running task will stop."
: "Restart Claude Desktop to remove Ollama? Any running task will stop.",
);
+ if (!screenMounted.current) return;
if (!restartConfirmed) {
setClaudePhase("idle");
return;
@@ -936,67 +1014,54 @@ export function ConnectAppsScreen({
? "Opening…"
: claudePhase === "disconnecting"
? "Disconnecting…"
- : !claudeConfigured && !claudeInstalled
- ? "Download & connect"
- : null;
+ : null;
const claudeGuidance = claudeDesktopRecoveryMessage(
claudeStatus?.error,
claudeError,
);
- const launchIntegrationRow = (item: IntegrationStatus) => {
- const copied = copiedCommand === item.command;
+ const launchIntegrationCard = (item: IntegrationStatus) => {
+ const copied =
+ copyNotice?.id === item.id && copyNotice.copied && copyNotice.visible;
return (
- copyLaunchCommand(item)}
+ aria-label={
+ copied ? `${item.name} command copied` : `Copy ${item.name} command`
+ }
+ title={item.description}
+ className="relative isolate flex min-w-0 items-center gap-3 rounded-2xl border border-neutral-200 bg-white px-4 py-3 text-left transition-colors duration-700 hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 motion-reduce:transition-none dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800"
>
-
-
-
-
- {item.name}
-
-
- {item.description}
-
-
-
-
-
- {item.command}
-
-
-
-
+ {copied && (
+
+ )}
+
+
+ {item.name}
+
+
);
};
const claudeRow = claudeIntegration ? (
-
-
-
+
+
+
-
- {claudeIntegration.name}
+
+ Claude Code
{claudeGuidance ??
(claudeConnected
@@ -1011,63 +1076,44 @@ export function ConnectAppsScreen({
? "Opening Claude…"
: claudePhase === "disconnecting"
? "Restoring Claude’s usual connection…"
- : claudeIntegration.description)}
+ : !claudeInstalled
+ ? "We’ll download Claude and connect it to Ollama."
+ : "Use Ollama models in your Claude Code.")}
-
- {claudeStatusLabel && (
-
- {isConnectingClaude && (
-
- )}
- {claudeStatusLabel}
-
- )}
-
-
+
) : null;
return (
-
-
+
+
-
+
{integrationStatuses ? (
-
+
{(claudeIntegration || codexIntegration) && (
-
+
- Desktop
+ Recommended
{claudeRow}
@@ -1085,14 +1131,12 @@ export function ConnectAppsScreen({
- Terminal
+ {isWindows ? "Apps" : "Other apps"}
-
-
- {launchIntegrations.map(launchIntegrationRow)}
-
+
+ {launchIntegrations.map(launchIntegrationCard)}
)}
@@ -1117,6 +1161,43 @@ export function ConnectAppsScreen({
+ {copyNotice && (
+
+
+
+
+
+
+
+ {copyNotice.copied
+ ? "Launch command copied."
+ : `Couldn’t copy the ${copyNotice.name} command`}
+
+
+ {copyNotice.copied
+ ? "Paste it into your terminal"
+ : "Select and copy the command below, then paste it into your terminal."}
+
+ {!copyNotice.copied && (
+
+ {copyNotice.command}
+
+ )}
+
+
+
+ )}
{showClaudeConnectedIntro && (
void dismissClaudeConnectedIntro()}
@@ -1137,14 +1218,20 @@ interface OnboardingProps extends ScreenProps {
export default function Onboarding(props: OnboardingProps) {
const [step, setStep] = useState("intro");
- const appsOpeningRef = useRef(false);
+ const [isLeaving, setIsLeaving] = useState(false);
+ const leavingRef = useRef(false);
+ const authenticationHandoffStarted = useRef(false);
const { onOpenApps } = props;
- const openApps = useCallback(async () => {
- if (appsOpeningRef.current) return;
- appsOpeningRef.current = true;
+ const leave = useCallback(async () => {
+ if (leavingRef.current) return;
+ leavingRef.current = true;
+ setIsLeaving(true);
const opened = await onOpenApps();
- if (!opened) appsOpeningRef.current = false;
+ if (!opened) {
+ leavingRef.current = false;
+ setIsLeaving(false);
+ }
}, [onOpenApps]);
useEffect(() => {
@@ -1153,14 +1240,16 @@ export default function Onboarding(props: OnboardingProps) {
}, []);
useEffect(() => {
- if (!props.isAuthenticated) return;
- const nextStep = nextOnboardingStep(step, "authenticated", true);
- if (nextStep === "apps") {
- void openApps();
+ if (!props.isAuthenticated) {
+ authenticationHandoffStarted.current = false;
return;
}
- setStep(nextStep);
- }, [openApps, props.isAuthenticated, step]);
+ if (step !== "welcome" || authenticationHandoffStarted.current) return;
+ // A failed save waits for an explicit retry, even if query updates replace
+ // the callback. StrictMode must not start a second completion either.
+ authenticationHandoffStarted.current = true;
+ void leave();
+ }, [step, props.isAuthenticated, leave]);
if (step === "run") {
return (
@@ -1175,18 +1264,11 @@ export default function Onboarding(props: OnboardingProps) {
return (
void openApps()}
+ isLeaving={isLeaving}
+ onRetryCompletion={() => void leave()}
onContinue={() => {
- const nextStep = nextOnboardingStep(
- step,
- "continue",
- props.isAuthenticated,
- );
- if (nextStep === "apps") {
- void openApps();
- return;
- }
- setStep(nextStep);
+ if (props.isAuthenticated) void leave();
+ else setStep("welcome");
}}
/>
);
@@ -1195,12 +1277,12 @@ export default function Onboarding(props: OnboardingProps) {
return (
void openApps()}
+ isLeaving={isLeaving}
+ onRetryCompletion={() => void leave()}
onLocal={() => {
+ if (leavingRef.current) return;
props.onUseLocal();
- setStep((current) =>
- nextOnboardingStep(current, "local", props.isAuthenticated),
- );
+ setStep("run");
}}
/>
);
diff --git a/app/ui/app/src/components/OnboardingRoute.test.tsx b/app/ui/app/src/components/OnboardingRoute.test.tsx
new file mode 100644
index 000000000..2ced15ec4
--- /dev/null
+++ b/app/ui/app/src/components/OnboardingRoute.test.tsx
@@ -0,0 +1,182 @@
+import { StrictMode, type ComponentType } from "react";
+import { act, create, type ReactTestRenderer } from "react-test-renderer";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import * as api from "@/api";
+import { Settings } from "@/gotypes";
+import { CURRENT_ONBOARDING_VERSION } from "@/lib/onboarding";
+import { Route } from "@/routes/onboarding";
+import { WelcomeScreen } from "./Onboarding";
+
+const mocks = vi.hoisted(() => ({ navigate: vi.fn(), authenticated: true }));
+vi.mock("@tanstack/react-router", async (importOriginal) =>
+ Object.assign(
+ {},
+ await importOriginal(),
+ {
+ useNavigate: () => mocks.navigate,
+ },
+ ),
+);
+vi.mock("@/hooks/useUser", () => ({
+ useUser: () => ({
+ isAuthenticated: mocks.authenticated,
+ fetchConnectUrl: vi.fn(),
+ refetchUser: vi.fn(),
+ }),
+}));
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ mocks.navigate.mockReset();
+ mocks.authenticated = true;
+});
+
+// Use the real settings mutation: query notifications replace callbacks and
+// must not automatically retry a failed handoff after authentication.
+async function renderOnboarding(authenticated: boolean) {
+ mocks.authenticated = authenticated;
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.stubGlobal("navigator", { platform: "MacIntel" });
+ vi.stubGlobal("window", {
+ OLLAMA_PLATFORM: "darwin",
+ setOnboardingWindow: vi.fn(),
+ });
+ const settingsResponse = { settings: new Settings({ OnboardingVersion: 0 }) };
+ vi.spyOn(api, "getSettings").mockResolvedValue(settingsResponse);
+ const client = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: Infinity },
+ mutations: { retry: false },
+ },
+ });
+ client.setQueryData(["settings"], settingsResponse);
+ const OnboardingRoute = Route.options.component as ComponentType;
+ const element = () => (
+
+
+
+
+
+ );
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create(element());
+ });
+ const primaryAction = () =>
+ renderer.root.find(
+ (node) => node.type === "button" && "aria-busy" in node.props,
+ );
+ return {
+ get primaryAction() {
+ return primaryAction();
+ },
+ get root() {
+ return renderer.root;
+ },
+ async continue() {
+ await act(async () => {
+ const button = primaryAction();
+ button.props.onClick();
+ button.props.onClick();
+ });
+ },
+ async authenticate() {
+ mocks.authenticated = true;
+ await act(async () => {
+ renderer.update(element());
+ });
+ },
+ async unmount() {
+ await act(async () => renderer.unmount());
+ client.clear();
+ },
+ };
+}
+
+async function flushQueryNotifications() {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+}
+
+describe("Onboarding completion", () => {
+ it.each([true, false])(
+ "saves once before opening Apps directly (already signed in: %s)",
+ async (authenticated) => {
+ let resolveSave!: (value: { settings: Settings }) => void;
+ const save = vi.spyOn(api, "updateSettings").mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSave = resolve;
+ }),
+ );
+ const onboarding = await renderOnboarding(authenticated);
+ try {
+ expect(save).not.toHaveBeenCalled();
+ expect(mocks.navigate).not.toHaveBeenCalled();
+ await onboarding.continue();
+ if (!authenticated) {
+ expect(save).not.toHaveBeenCalled();
+ expect(onboarding.root.findByType(WelcomeScreen)).toBeTruthy();
+ await onboarding.authenticate();
+ }
+ await flushQueryNotifications();
+ expect(save).toHaveBeenCalledOnce();
+ expect(save).toHaveBeenCalledWith(
+ expect.objectContaining({
+ OnboardingVersion: CURRENT_ONBOARDING_VERSION,
+ }),
+ );
+ expect(mocks.navigate).not.toHaveBeenCalled();
+ expect(onboarding.primaryAction.props.disabled).toBe(true);
+ await act(async () => {
+ resolveSave({ settings: save.mock.calls[0][0] });
+ });
+ expect(mocks.navigate).toHaveBeenCalledExactlyOnceWith({
+ to: "/connect",
+ });
+ } finally {
+ await onboarding.unmount();
+ }
+ },
+ );
+
+ it.each([true, false])(
+ "keeps the current screen and waits for an explicit save retry (already signed in: %s)",
+ async (authenticated) => {
+ const save = vi
+ .spyOn(api, "updateSettings")
+ .mockRejectedValueOnce(new Error("disk full"))
+ .mockImplementation(async (settings) => ({ settings }));
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ const onboarding = await renderOnboarding(authenticated);
+ try {
+ await onboarding.continue();
+ if (!authenticated) await onboarding.authenticate();
+ await flushQueryNotifications();
+ await flushQueryNotifications();
+ expect(save).toHaveBeenCalledOnce();
+ expect(mocks.navigate).not.toHaveBeenCalled();
+ expect(onboarding.primaryAction.props.disabled).toBe(false);
+ expect(onboarding.root.findByProps({ role: "alert" })).toBeTruthy();
+ await act(async () => {
+ onboarding.root
+ .find(
+ (node) =>
+ node.type === "button" && node.props.children === "Try again",
+ )
+ .props.onClick();
+ });
+ expect(save).toHaveBeenCalledTimes(2);
+ expect(save.mock.calls[1][0]).toEqual(save.mock.calls[0][0]);
+ expect(mocks.navigate).toHaveBeenCalledExactlyOnceWith({
+ to: "/connect",
+ });
+ } finally {
+ await onboarding.unmount();
+ }
+ },
+ );
+});
diff --git a/app/ui/app/src/index.css b/app/ui/app/src/index.css
index adaadb49d..f41e0bc99 100644
--- a/app/ui/app/src/index.css
+++ b/app/ui/app/src/index.css
@@ -71,9 +71,50 @@
will-change: opacity, transform;
}
-@media (prefers-reduced-motion: reduce) {
- .claude-connected-backdrop,
- .claude-connected-dialog {
- animation: none;
+.apps-copy-hint {
+ transition:
+ opacity 320ms ease-in-out,
+ transform 320ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.apps-copy-hint[data-closed] {
+ opacity: 0;
+ transform: translateX(16px);
+}
+
+.apps-copy-hint[data-leave] {
+ transition-duration: 400ms;
+ transition-timing-function: ease-in-out;
+}
+
+@keyframes apps-copy-card-flash {
+ 0%,
+ 100% {
+ opacity: 0;
+ }
+ 25%,
+ 50% {
+ opacity: 1;
+ }
+}
+
+.apps-copy-card-feedback {
+ animation: apps-copy-card-flash 1800ms ease-in-out both;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .claude-connected-backdrop,
+ .claude-connected-dialog,
+ .apps-copy-card-feedback {
+ animation: none;
+ }
+
+ .apps-copy-hint,
+ .apps-copy-hint[data-leave] {
+ transition: none;
+ }
+
+ .apps-copy-hint[data-closed] {
+ transform: none;
}
}
diff --git a/app/ui/app/src/lib/onboarding.ts b/app/ui/app/src/lib/onboarding.ts
index 04d69ca9a..03d5b8a65 100644
--- a/app/ui/app/src/lib/onboarding.ts
+++ b/app/ui/app/src/lib/onboarding.ts
@@ -19,24 +19,10 @@ export function onboardingConnectUrl(
export const AUTHENTICATION_TIMEOUT_MS = 5 * 60 * 1000;
-export type OnboardingStep = "intro" | "welcome" | "apps" | "run";
+export type OnboardingStep = "intro" | "welcome" | "run";
-export type OnboardingAction = "continue" | "authenticated" | "local";
export type AuthenticationTimeoutAction = "ignore" | "defer" | "fail";
-export function nextOnboardingStep(
- step: OnboardingStep,
- action: OnboardingAction,
- isAuthenticated: boolean,
-): OnboardingStep {
- if (action === "local") return "run";
- if (step === "intro" && action === "continue") {
- return isAuthenticated ? "apps" : "welcome";
- }
- if (step === "welcome" && action === "authenticated") return "apps";
- return step;
-}
-
export function authenticationTimeoutAction(
settled: boolean,
checking: boolean,