From b17427b3caaafd4eadffd097fd788c65e60432d4 Mon Sep 17 00:00:00 2001 From: Eva H <63033505+hoyyeva@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:54:46 -0400 Subject: [PATCH] app: refresh Apps layout and command copy feedback (#18372) --- app/cmd/app/webview.go | 7 + .../src/components/CodexDesktopRow.test.tsx | 265 +++-- app/ui/app/src/components/CodexDesktopRow.tsx | 65 +- .../components/IntegrationConnectButton.tsx | 39 + app/ui/app/src/components/Onboarding.test.tsx | 987 ++++++++++++------ app/ui/app/src/components/Onboarding.tsx | 404 ++++--- .../src/components/OnboardingRoute.test.tsx | 182 ++++ app/ui/app/src/index.css | 49 +- app/ui/app/src/lib/onboarding.ts | 16 +- 9 files changed, 1311 insertions(+), 703 deletions(-) create mode 100644 app/ui/app/src/components/IntegrationConnectButton.tsx create mode 100644 app/ui/app/src/components/OnboardingRoute.test.tsx 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( - /]*aria-label="Connect Claude"[^>]*>/, - )?.[0]; - expect(claudeButton).toBeDefined(); - expect(claudeButton).not.toContain('disabled=""'); - }); - it("uses branded icons for the remaining launcher integrations", () => { const html = renderToStaticMarkup( { expect(html).toContain("Try again"); }); }); + +function appsIntegrations(claudeInstalled: boolean): IntegrationStatuses { + const launcher = (id: string, name: string) => ({ + id, + name, + description: `${name} description`, + installed: false, + command: `ollama launch ${id}`, + }); + return [ + { + id: "claude-desktop", + name: "Claude", + description: "Use Ollama models in Claude Desktop", + installed: claudeInstalled, + }, + launcher("claude", "Claude Code"), + launcher("codex", "Codex CLI"), + launcher("opencode", "OpenCode"), + launcher("pi", "Pi"), + launcher("hermes", "Hermes Agent"), + { + id: "terminal", + name: "Terminal", + description: "Run local models from your terminal", + command: "ollama", + }, + ]; +} + +function onboardingProps(onOpenApps: () => Promise) { + return { + isAuthenticated: true, + isSigningIn: false, + signInError: null, + completionError: null, + onOpenApps, + onSignIn: vi.fn(), + onSignUp: vi.fn(), + onRetryCompletion: vi.fn(), + onUseLocal: vi.fn(), + }; +} + +const DISCONNECTED_CLAUDE = { + supported: true, + used: false, + installed: true, + configured: false, + connected: false, + running: false, + startFailed: false, + portConflict: false, +}; + +async function settle(ticks = 8) { + for (let i = 0; i < ticks; i++) { + await Promise.resolve(); + } +} + +function stubOnboardingWindow(platform = "darwin") { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("navigator", { platform: "MacIntel" }); + vi.stubGlobal("window", { + OLLAMA_PLATFORM: platform, + setOnboardingWindow: vi.fn(), + }); +} + +describe("Onboarding handoff", () => { + it("preserves local setup without opening Apps", async () => { + stubOnboardingWindow(); + const props = { ...onboardingProps(vi.fn()), isAuthenticated: false }; + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { + renderer = create(); + }); + await act(async () => { + renderer!.root.findByType(IntroScreen).props.onContinue(); + }); + expect(props.onOpenApps).not.toHaveBeenCalled(); + await act(async () => { + renderer!.root.findByType(WelcomeScreen).props.onLocal(); + }); + expect(renderer!.root.findByType(RunOllamaScreen)).toBeTruthy(); + expect(props.onUseLocal).toHaveBeenCalledOnce(); + expect(props.onOpenApps).not.toHaveBeenCalled(); + } finally { + if (renderer) act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); +}); + +describe("ConnectAppsScreen interactions", () => { + function stubAppsWindow(overrides: Record = {}) { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("navigator", { platform: "MacIntel" }); + vi.stubGlobal("window", { + OLLAMA_PLATFORM: "darwin", + innerHeight: 660, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + setInterval: globalThis.setInterval, + clearInterval: globalThis.clearInterval, + ...overrides, + }); + } + + it.each([false, true])( + "honors the native Claude disconnect confirmation: %s", + async (confirmed) => { + const connected = { + ...DISCONNECTED_CLAUDE, + used: true, + running: true, + configured: true, + connected: true, + }; + const confirm = vi.fn(() => confirmed); + const disconnect = vi.fn().mockResolvedValue({ + status: { ...connected, configured: false, connected: false }, + }); + stubAppsWindow({ + getClaudeDesktopConnectionSummary: vi.fn().mockResolvedValue(connected), + setClaudeDesktopConnected: disconnect, + confirm, + }); + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { + renderer = create( + , + ); + await settle(); + }); + const toggle = claudeConnectionButton(renderer!); + await act(async () => { + await toggle.props.onClick(); + }); + expect(confirm).toHaveBeenCalledOnce(); + if (confirmed) { + expect(disconnect).toHaveBeenCalledExactlyOnceWith(false, true); + } else { + expect(disconnect).not.toHaveBeenCalled(); + expect(toggle.props.disabled).toBe(false); + expect(toggle.props["aria-pressed"]).toBe(true); + } + } finally { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }, + ); + + it("lets the user retry Connect after cancelling the native confirmation", async () => { + const running = { ...DISCONNECTED_CLAUDE, running: true, used: true }; + const confirm = vi.fn().mockReturnValueOnce(false).mockReturnValue(true); + const connect = vi.fn().mockResolvedValue({ + status: { ...running, configured: true, connected: true }, + }); + stubAppsWindow({ + getClaudeDesktopConnectionSummary: vi.fn().mockResolvedValue(running), + setClaudeDesktopConnected: connect, + confirm, + }); + let renderer: ReactTestRenderer | undefined; + const clickConnect = () => + claudeConnectionButton(renderer!).props.onClick(); + try { + await act(async () => { + renderer = create( + + + , + ); + await settle(); + }); + await act(async () => { + await clickConnect(); + }); + expect(confirm).toHaveBeenCalledTimes(1); + expect(connect).not.toHaveBeenCalled(); + await act(async () => { + await clickConnect(); + }); + expect(confirm).toHaveBeenCalledTimes(2); + expect(connect).toHaveBeenCalledExactlyOnceWith(true, true); + } finally { + if (renderer) act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); + + it.each(["darwin", "windows"])( + "copies every catalog command and limits desktop connections to macOS (%s)", + async (platform) => { + const copyCommand = vi + .spyOn(clipboard, "copyTextToClipboard") + .mockResolvedValue(true); + stubAppsWindow({ OLLAMA_PLATFORM: platform }); + const integrations = [ + ...appsIntegrations(true), + ...Array.from({ length: 20 }, (_, index) => ({ + id: `extra-${index}`, + name: `Extra app ${index}`, + description: "Another supported integration", + command: `ollama launch extra-${index}`, + })), + ]; + const launchers = integrations.filter((item) => item.command); + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { + renderer = create( + , + ); + }); + const cards = renderer!.root.findAll( + (node) => + node.type === "button" && node.props.id?.startsWith("integration-"), + ); + expect(new Set(cards.map((card) => card.props.id))).toEqual( + new Set(launchers.map((item) => `integration-${item.id}`)), + ); + expect( + renderer!.root.findAll( + (node) => + node.type === "button" && + typeof node.props["aria-pressed"] === "boolean", + ), + ).toHaveLength(platform === "darwin" ? 2 : 0); + for (const item of launchers) { + await act(async () => { + await renderer!.root + .findByProps({ id: `integration-${item.id}` }) + .props.onClick(); + }); + expect(copyCommand).toHaveBeenLastCalledWith(item.command); + } + expect(copyCommand).toHaveBeenCalledTimes(launchers.length); + } finally { + if (renderer) act(() => renderer?.unmount()); + copyCommand.mockRestore(); + vi.unstubAllGlobals(); + } + }, + ); + + it("renews the copy notification on each click and dismisses it after inactivity", async () => { + vi.useFakeTimers(); + const copyCommand = vi + .spyOn(clipboard, "copyTextToClipboard") + .mockResolvedValue(true); + stubAppsWindow(); + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { + renderer = create( + , + ); + await settle(); + }); + const card = () => + renderer!.root.findByProps({ id: "integration-codex" }); + await act(async () => { + await card().props.onClick(); + }); + expect(copyCommand).toHaveBeenCalledExactlyOnceWith( + "ollama launch codex", + ); + const notice = () => renderer!.root.findByProps({ role: "status" }); + expect(notice()).toBeTruthy(); + act(() => vi.advanceTimersByTime(5000)); + await act(async () => { + await card().props.onClick(); + }); + act(() => vi.advanceTimersByTime(1001)); + expect(notice()).toBeTruthy(); + act(() => vi.advanceTimersByTime(5000)); + expect(renderer!.root.findAllByProps({ role: "status" })).toHaveLength(0); + expect(copyCommand).toHaveBeenCalledTimes(2); + } finally { + if (renderer) act(() => renderer?.unmount()); + copyCommand.mockRestore(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }); + + it.each(["denied", "throws"])( + "offers manual copying instead of success when clipboard access %s", + async (outcome) => { + vi.useFakeTimers(); + const copyCommand = vi + .spyOn(clipboard, "copyTextToClipboard") + .mockImplementationOnce(() => + outcome === "throws" + ? Promise.reject(new Error("clipboard unavailable")) + : Promise.resolve(false), + ) + .mockResolvedValue(true); + stubAppsWindow(); + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { + renderer = create( + , + ); + await settle(); + }); + const card = () => + renderer!.root.findByProps({ id: "integration-codex" }); + await act(async () => { + await card().props.onClick(); + }); + act(() => vi.advanceTimersByTime(20_000)); + expect( + renderer!.root.findByProps({ role: "alert" }).findByType("code") + .children, + ).toEqual(["ollama launch codex"]); + expect(renderer!.root.findAllByProps({ role: "status" })).toHaveLength( + 0, + ); + await act(async () => { + await card().props.onClick(); + }); + expect(renderer!.root.findAllByProps({ role: "alert" })).toHaveLength( + 0, + ); + expect(renderer!.root.findByProps({ role: "status" })).toBeTruthy(); + } finally { + if (renderer) act(() => renderer?.unmount()); + copyCommand.mockRestore(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + } + }, + ); + + it.each(["Escape", "outside pointer"])( + "dismisses a copy error with %s while preserving manual copying", + async (dismissal) => { + const events = new EventTarget(); + const commandNode = {}; + const noticeNode = { + contains: (target: unknown) => target === commandNode, + }; + const copyCommand = vi + .spyOn(clipboard, "copyTextToClipboard") + .mockResolvedValueOnce(false) + .mockResolvedValue(true); + stubAppsWindow({ + addEventListener: events.addEventListener.bind(events), + removeEventListener: events.removeEventListener.bind(events), + }); + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { + renderer = create( + , + { + createNodeMock: (element) => + element.props.role === "alert" ? noticeNode : null, + }, + ); + await settle(); + }); + const card = () => + renderer!.root.findByProps({ id: "integration-codex" }); + await act(async () => { + await card().props.onClick(); + }); + + // Selecting the command and unrelated keys must keep it available. + const selection = new Event("pointerdown"); + Object.defineProperty(selection, "target", { value: commandNode }); + act(() => { + events.dispatchEvent(selection); + events.dispatchEvent( + Object.assign(new Event("keydown"), { key: "c" }), + ); + }); + expect( + renderer!.root.findByProps({ role: "alert" }).findByType("code") + .children, + ).toEqual(["ollama launch codex"]); + + act(() => { + events.dispatchEvent( + dismissal === "Escape" + ? Object.assign(new Event("keydown"), { key: "Escape" }) + : new Event("pointerdown"), + ); + }); + expect(renderer!.root.findAllByProps({ role: "alert" })).toHaveLength( + 0, + ); + + // A later successful copy keeps its usual notification lifetime. + await act(async () => { + await card().props.onClick(); + }); + act(() => { + events.dispatchEvent( + Object.assign(new Event("keydown"), { key: "Escape" }), + ); + events.dispatchEvent(new Event("pointerdown")); + }); + expect(renderer!.root.findByProps({ role: "status" })).toBeTruthy(); + } finally { + if (renderer) act(() => renderer?.unmount()); + copyCommand.mockRestore(); + vi.unstubAllGlobals(); + } + }, + ); + + it.each(["connected", "disconnected", "failed"])( + "lets users copy commands while Claude status is still loading (%s)", + async (outcome) => { + let resolveClaude!: (status: typeof DISCONNECTED_CLAUDE) => void; + let rejectClaude!: (error: Error) => void; + const claude = new Promise( + (resolve, reject) => { + resolveClaude = resolve; + rejectClaude = reject; + }, + ); + const copyCommand = vi + .spyOn(clipboard, "copyTextToClipboard") + .mockResolvedValue(true); + const connect = vi.fn(); + stubAppsWindow({ + getClaudeDesktopConnectionSummary: vi.fn().mockReturnValue(claude), + setClaudeDesktopConnected: connect, + }); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify(appsIntegrations(true))), + ), + ); + + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { + renderer = create(); + await settle(); + }); + + const claudeToggle = () => claudeConnectionButton(renderer!); + expect(claudeToggle().props.disabled).toBe(true); + expect(claudeToggle().props["aria-busy"]).toBe(true); + + await act(async () => { + await renderer!.root + .findByProps({ id: "integration-codex" }) + .props.onClick(); + }); + expect(copyCommand).toHaveBeenCalledWith("ollama launch codex"); + expect(connect).not.toHaveBeenCalled(); + + await act(async () => { + if (outcome === "failed") { + rejectClaude(new Error("Claude status unavailable")); + } else { + resolveClaude({ + ...DISCONNECTED_CLAUDE, + used: true, + configured: outcome === "connected", + connected: outcome === "connected", + }); + } + await settle(); + }); + expect(claudeToggle().props.disabled).toBe(false); + expect(claudeToggle().props["aria-pressed"]).toBe( + outcome === "connected", + ); + if (outcome === "failed") { + expect(renderer!.root.findByProps({ role: "alert" })).toBeTruthy(); + } + expect(connect).not.toHaveBeenCalled(); + } finally { + if (renderer) act(() => renderer?.unmount()); + copyCommand.mockRestore(); + vi.unstubAllGlobals(); + } + }, + ); + + it("shows an app-list error without waiting for Claude status", async () => { + stubAppsWindow({ + getClaudeDesktopConnectionSummary: vi + .fn() + .mockReturnValue(new Promise(() => {})), + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 500 })), + ); + + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { + renderer = create(); + await settle(); + }); + expect(renderer!.root.findByProps({ role: "alert" })).toBeTruthy(); + } finally { + if (renderer) act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); + + it("shows the first-use intro after the user connects Claude", async () => { + const connectedStatus = { + ...DISCONNECTED_CLAUDE, + configured: true, + connected: true, + }; + const setClaudeDesktopConnected = vi + .fn() + .mockResolvedValue({ status: connectedStatus }); + stubAppsWindow({ + getClaudeDesktopConnectionSummary: vi + .fn() + .mockResolvedValue(DISCONNECTED_CLAUDE), + setClaudeDesktopConnected, + activateOllama: vi.fn(), + }); + + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { + renderer = create( + + + , + ); + await settle(); + }); + expect(setClaudeDesktopConnected).not.toHaveBeenCalled(); + await act(async () => { + await claudeConnectionButton(renderer!).props.onClick(); + }); + + expect(setClaudeDesktopConnected).toHaveBeenCalledTimes(1); + expect(setClaudeDesktopConnected).toHaveBeenCalledWith(true, false); + expect(claudeConnectionButton(renderer!).props["aria-pressed"]).toBe( + true, + ); + expect(renderer!.root.findByType(ClaudeConnectedIntro)).toBeTruthy(); + } finally { + if (renderer) act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); +}); diff --git a/app/ui/app/src/components/Onboarding.tsx b/app/ui/app/src/components/Onboarding.tsx index 976d0672a..3f5af1b20 100644 --- a/app/ui/app/src/components/Onboarding.tsx +++ b/app/ui/app/src/components/Onboarding.tsx @@ -1,7 +1,9 @@ import CopyButton from "@/components/CopyButton"; import { CodexDesktopRow } from "@/components/CodexDesktopRow"; import Logo from "@/components/Logo"; -import { nextOnboardingStep, type OnboardingStep } from "@/lib/onboarding"; +import type { OnboardingStep } from "@/lib/onboarding"; +import { IntegrationConnectButton } from "@/components/IntegrationConnectButton"; +import { Transition } from "@headlessui/react"; import { getIntegrationStatuses, type IntegrationStatus, @@ -27,13 +29,10 @@ import type { } from "@/types/webview"; import { copyTextToClipboard } from "@/utils/clipboard"; import { - ArrowPathIcon, ArrowsRightLeftIcon, CommandLineIcon, ShieldCheckIcon, - Square2StackIcon, } from "@heroicons/react/24/outline"; -import { CheckIcon } from "@heroicons/react/20/solid"; import { useCallback, useEffect, @@ -84,6 +83,7 @@ interface ScreenProps { interface WelcomeScreenProps extends ScreenProps { isAuthenticated: boolean; + isLeaving?: boolean; completionError?: string | null; onRetryCompletion?: () => void; onLocal: () => void; @@ -170,8 +170,10 @@ export function IntroScreen({ completionError = null, onContinue, onRetryCompletion, + isLeaving = false, }: { completionError?: string | null; + isLeaving?: boolean; onContinue: () => void; onRetryCompletion?: () => void; }) { @@ -220,6 +222,8 @@ export function IntroScreen({ type="button" className="mt-8 flex h-11 w-full max-w-[240px] 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" onClick={onContinue} + disabled={isLeaving} + aria-busy={isLeaving || undefined} > Continue @@ -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 && ( +