app: refresh Apps layout and command copy feedback (#18372)

This commit is contained in:
Eva H
2026-09-14 08:54:46 -07:00
committed by GitHub
parent 53fed26112
commit b17427b3ca
9 changed files with 1308 additions and 700 deletions
+7
View File
@@ -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()
+119 -146
View File
@@ -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(
<CodexDesktopRow integration={integration} initialStatus={status()} />,
);
expect(html).toContain(">ChatGPT (Desktop)</p>");
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(
<CodexDesktopRow
@@ -92,7 +88,7 @@ describe("CodexDesktopRow", () => {
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(
<CodexDesktopRow
integration={{ ...integration, installed: false }}
initialStatus={status({ installed: false })}
/>,
);
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 &amp; 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 wasnt 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(
<CodexDesktopRow
integration={integration}
initialStatus={connected}
/>,
);
});
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(
<CodexDesktopRow
@@ -774,13 +779,13 @@ describe("ChatGPT first connection intro", () => {
);
});
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 () => {
<CodexDesktopRow integration={integration} initialStatus={status()} />,
);
});
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(<CodexDesktopRow integration={integration} />);
});
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(<CodexDesktopRow integration={integration} />);
});
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(<CodexDesktopRow integration={integration} />);
});
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(<CodexDesktopRow integration={integration} />);
});
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(<CodexDesktopRow integration={integration} />);
});
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(<CodexDesktopRow integration={integration} />);
});
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 couldnt save your progress. Please try again.",
);
+29 -36
View File
@@ -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."
: "Well download ChatGPT and connect it to Ollama.",
);
const saveAcknowledgment = async (): Promise<boolean> => {
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 (
<div className="flex min-h-18 items-center justify-between gap-4 bg-white px-4 py-3 dark:bg-neutral-900">
<div className="flex min-w-0 items-center gap-3">
<div
id="integration-chatgpt"
className="flex items-center gap-4 rounded-2xl border border-neutral-200 bg-neutral-50 px-5 py-3 dark:border-neutral-700 dark:bg-neutral-800/50"
>
<div className="flex min-w-0 flex-1 items-center gap-4">
<CodexIcon integration={integration} />
<div className="min-w-0">
<p className="text-sm font-medium text-neutral-950 dark:text-neutral-100">
ChatGPT (Desktop)
<p className="text-base font-medium text-neutral-950 dark:text-neutral-100">
ChatGPT
</p>
<p
role={actionError ? "alert" : notice ? "status" : undefined}
className="truncate text-xs leading-5 text-neutral-500 dark:text-neutral-400"
className="mt-1 text-[13px] leading-5 text-neutral-500 dark:text-neutral-400"
>
{description}
</p>
@@ -494,22 +504,11 @@ export function CodexDesktopRow({
Retry
</button>
)}
{statusLabel && (
<span
role="status"
aria-live="polite"
className="inline-flex items-center gap-1.5 whitespace-nowrap text-xs text-neutral-500 dark:text-neutral-400"
>
{pending && <ArrowPathIcon className="h-3.5 w-3.5 animate-spin" />}
{statusLabel}
</span>
)}
<button
type="button"
role="switch"
aria-checked={displayedConnected}
aria-busy={pending || undefined}
aria-label={
<IntegrationConnectButton
connected={displayedConnected}
busy={pending}
progress={statusLabel}
label={
showIntro
? "Finish connecting ChatGPT"
: connected
@@ -527,13 +526,7 @@ export function CodexDesktopRow({
}
disabled={pending || showIntro}
onClick={() => void toggleConnection()}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 disabled:cursor-wait disabled:opacity-50 ${displayedConnected ? "bg-neutral-950 dark:bg-white" : "bg-neutral-300 dark:bg-neutral-700"}`}
>
<span
aria-hidden="true"
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${pending ? "animate-pulse" : ""} ${displayedConnected ? "translate-x-4.5 dark:bg-neutral-900" : "translate-x-0.5"}`}
/>
</button>
/>
</div>
{showIntro && (
<CodexConnectedIntro onDone={() => void toggleConnection(true)} />
@@ -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<ComponentProps<"button">, "onClick" | "disabled" | "title"> & {
connected: boolean;
label: string;
busy: boolean;
progress?: string | null;
}) {
return (
<button
{...props}
type="button"
aria-label={label}
aria-pressed={connected}
aria-busy={busy || undefined}
className="inline-flex min-h-10 shrink-0 items-center justify-center gap-2 rounded-full bg-neutral-900 px-6 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-60 dark:bg-white dark:text-neutral-950 dark:hover:bg-neutral-200"
>
{busy ? (
<>
<ArrowPathIcon className="h-3.5 w-3.5 animate-spin" />
<span role="status" aria-live="polite">
{progress ?? "Checking…"}
</span>
</>
) : connected ? (
"Disconnect"
) : (
"Connect"
)}
</button>
);
}
File diff suppressed because it is too large Load Diff
+243 -161
View File
@@ -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
</button>
@@ -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"}
</button>
<button
type="button"
className="mt-2 cursor-pointer rounded-md px-3 py-2 text-sm font-normal text-neutral-600 underline decoration-neutral-300 underline-offset-4 hover:text-neutral-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500"
onClick={onLocal}
disabled={isLeaving}
>
No thanks, I&apos;ll use Ollama locally
</button>
@@ -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 (
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-transparent">
@@ -438,7 +448,20 @@ export function ConnectAppsScreen({
initialCodexStatus,
}: ConnectAppsScreenProps) {
const isWindows = isWindowsPlatform();
const [copiedCommand, setCopiedCommand] = useState<string | null>(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<HTMLDivElement>(null);
const [initialClaudeStatusSettled, setInitialClaudeStatusSettled] = useState(
Boolean(initialClaudeStatus),
);
const [claudeError, setClaudeError] = useState<string | null>(null);
const [claudeStatus, setClaudeStatus] = useState<ClaudeDesktopStatus | null>(
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 (
<div
<button
key={item.id}
className="flex min-h-18 items-center justify-between gap-4 px-4 py-3"
id={`integration-${item.id}`}
type="button"
onClick={() => 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"
>
<div className="flex min-w-0 items-center gap-3">
<LaunchCommandIcon item={item} />
<div className="min-w-0">
<p className="text-sm font-medium text-neutral-950 dark:text-neutral-100">
{item.name}
</p>
<p className="truncate text-xs leading-5 text-neutral-500 dark:text-neutral-400">
{item.description}
</p>
</div>
</div>
<div className="ml-auto flex min-w-0 shrink-0 items-center overflow-hidden rounded-lg bg-neutral-100 pl-3 dark:bg-neutral-800">
<code className="block flex-1 whitespace-nowrap py-2 pr-2 font-mono text-[13px] text-neutral-500 dark:text-neutral-400">
{item.command}
</code>
<button
type="button"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-neutral-500 transition-colors hover:text-neutral-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 dark:text-neutral-400 dark:hover:text-neutral-100"
onClick={() => copyLaunchCommand(item)}
aria-label={
copied
? `${item.name} command copied`
: `Copy ${item.name} command`
}
title={copied ? "Copied" : "Copy command"}
>
{copied ? (
<CheckIcon className="h-4 w-4 text-green-600" />
) : (
<Square2StackIcon className="h-4 w-4" />
)}
</button>
</div>
</div>
{copied && (
<span
key={copyNotice?.sequence}
aria-hidden="true"
className="apps-copy-card-feedback pointer-events-none absolute inset-0 -z-10 rounded-[inherit] bg-neutral-100 ring-1 ring-inset ring-neutral-300/70 dark:bg-neutral-700/60 dark:ring-neutral-500/50"
/>
)}
<LaunchCommandIcon id={item.id} />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-neutral-950 dark:text-neutral-100">
{item.name}
</span>
</button>
);
};
const claudeRow = claudeIntegration ? (
<div className="flex min-h-18 items-center justify-between gap-4 bg-white px-4 py-3 dark:bg-neutral-900">
<div className="flex min-w-0 items-center gap-3">
<LaunchCommandIcon item={claudeIntegration} />
<div
id={`integration-${claudeIntegration.id}`}
className="flex items-center gap-4 rounded-2xl border border-neutral-200 bg-neutral-50 px-5 py-3 transition-colors duration-700 motion-reduce:transition-none dark:border-neutral-700 dark:bg-neutral-800/50"
>
<div className="flex min-w-0 flex-1 items-center gap-4">
<LaunchCommandIcon id={claudeIntegration.id} />
<div className="min-w-0">
<p className="text-sm font-medium text-neutral-950 dark:text-neutral-100">
{claudeIntegration.name}
<p className="text-base font-medium text-neutral-950 dark:text-neutral-100">
Claude Code
</p>
<p
role={claudeGuidance ? "alert" : undefined}
className="truncate text-xs leading-5 text-neutral-500 dark:text-neutral-400"
className="mt-1 text-[13px] leading-5 text-neutral-500 dark:text-neutral-400"
>
{claudeGuidance ??
(claudeConnected
@@ -1011,63 +1076,44 @@ export function ConnectAppsScreen({
? "Opening Claude…"
: claudePhase === "disconnecting"
? "Restoring Claudes usual connection…"
: claudeIntegration.description)}
: !claudeInstalled
? "Well download Claude and connect it to Ollama."
: "Use Ollama models in your Claude Code.")}
</p>
</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-2.5">
{claudeStatusLabel && (
<span
role="status"
aria-live="polite"
className="inline-flex items-center gap-1.5 whitespace-nowrap text-xs text-neutral-500 dark:text-neutral-400"
>
{isConnectingClaude && (
<ArrowPathIcon className="h-3.5 w-3.5 animate-spin" />
)}
{claudeStatusLabel}
</span>
)}
<button
type="button"
role="switch"
aria-checked={claudeToggleConfigured}
aria-busy={isConnectingClaude || undefined}
aria-label={
claudeConfigured
? "Disconnect Claude"
: isConnectingClaude
? "Connecting Claude"
: "Connect Claude"
}
title={claudeConfigured ? "Disconnect" : "Connect"}
disabled={isConnectingClaude}
onClick={connectClaude}
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500 disabled:cursor-wait disabled:opacity-50 ${claudeToggleConfigured ? "bg-neutral-950 dark:bg-white" : "bg-neutral-300 dark:bg-neutral-700"}`}
>
<span
aria-hidden="true"
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${isConnectingClaude ? "animate-pulse" : ""} ${claudeToggleConfigured ? "translate-x-4.5 dark:bg-neutral-900" : "translate-x-0.5"}`}
/>
</button>
</div>
<IntegrationConnectButton
connected={claudeToggleConfigured}
busy={!initialClaudeStatusSettled || isConnectingClaude}
progress={isConnectingClaude ? claudeStatusLabel : null}
label={
claudeConfigured
? "Disconnect Claude"
: isConnectingClaude
? "Connecting Claude"
: "Connect Claude"
}
title={claudeConfigured ? "Disconnect" : "Connect"}
disabled={!initialClaudeStatusSettled || isConnectingClaude}
onClick={connectClaude}
/>
</div>
) : null;
return (
<main className="flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-white text-neutral-950 dark:bg-neutral-900 dark:text-neutral-100">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain p-6">
<main className="relative flex min-h-0 w-full flex-1 flex-col overflow-hidden bg-white text-neutral-950 dark:bg-neutral-900 dark:text-neutral-100">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain px-6 pb-6 pt-4">
<section className="min-h-0 flex-1">
<div className="mx-auto w-full max-w-4xl text-left">
<div className="mx-auto w-full max-w-[620px] text-left">
{integrationStatuses ? (
<div className="space-y-7 pb-4 pt-2">
<div className="space-y-8 pb-4 pt-2">
{(claudeIntegration || codexIntegration) && (
<section aria-labelledby="desktop-heading">
<section aria-labelledby="recommended-heading">
<h2
id="desktop-heading"
className="px-4 text-xs font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
id="recommended-heading"
className="text-xs font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
>
Desktop
Recommended
</h2>
<div className="mt-2 space-y-2 bg-white dark:bg-neutral-900">
{claudeRow}
@@ -1085,14 +1131,12 @@ export function ConnectAppsScreen({
<section aria-labelledby="terminal-heading">
<h2
id="terminal-heading"
className="px-4 text-xs font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
className="text-xs font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500"
>
Terminal
{isWindows ? "Apps" : "Other apps"}
</h2>
<div className="mt-2 bg-white dark:bg-neutral-900">
<div className="space-y-2">
{launchIntegrations.map(launchIntegrationRow)}
</div>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
{launchIntegrations.map(launchIntegrationCard)}
</div>
</section>
)}
@@ -1117,6 +1161,43 @@ export function ConnectAppsScreen({
</div>
</section>
</div>
{copyNotice && (
<Transition
appear
show={copyNotice.visible}
as="div"
className="apps-copy-hint pointer-events-none absolute right-4 top-4 z-30 w-[360px] max-w-[calc(100%-2rem)]"
>
<div
ref={copyNoticeRef}
role={copyNotice.copied ? "status" : "alert"}
aria-live={copyNotice.copied ? "polite" : "assertive"}
aria-atomic="true"
className={`flex items-start gap-3 rounded-2xl border border-neutral-200/80 bg-neutral-100/95 p-4 text-[13px] shadow-lg shadow-black/10 backdrop-blur-xl dark:border-white/10 dark:bg-neutral-700/90 dark:shadow-black/30 ${!copyNotice.copied && copyNotice.visible ? "pointer-events-auto" : ""}`}
>
<div aria-hidden="true" className="shrink-0">
<LaunchCommandIcon id={copyNotice.id} />
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold leading-5 text-neutral-900 dark:text-neutral-100">
{copyNotice.copied
? "Launch command copied."
: `Couldnt copy the ${copyNotice.name} command`}
</p>
<p className="mt-0.5 leading-5 text-neutral-600 dark:text-neutral-200">
{copyNotice.copied
? "Paste it into your terminal"
: "Select and copy the command below, then paste it into your terminal."}
</p>
{!copyNotice.copied && (
<code className="mt-2 block select-all break-all rounded-md bg-white/70 px-3 py-2 text-xs dark:bg-neutral-900/60">
{copyNotice.command}
</code>
)}
</div>
</div>
</Transition>
)}
{showClaudeConnectedIntro && (
<ClaudeConnectedIntro
onDone={() => void dismissClaudeConnectedIntro()}
@@ -1137,14 +1218,20 @@ interface OnboardingProps extends ScreenProps {
export default function Onboarding(props: OnboardingProps) {
const [step, setStep] = useState<OnboardingStep>("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 (
<IntroScreen
completionError={props.completionError}
onRetryCompletion={() => 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 (
<WelcomeScreen
{...props}
onRetryCompletion={() => void openApps()}
isLeaving={isLeaving}
onRetryCompletion={() => void leave()}
onLocal={() => {
if (leavingRef.current) return;
props.onUseLocal();
setStep((current) =>
nextOnboardingStep(current, "local", props.isAuthenticated),
);
setStep("run");
}}
/>
);
@@ -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<typeof import("@tanstack/react-router")>(),
{
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 = () => (
<StrictMode>
<QueryClientProvider client={client}>
<OnboardingRoute />
</QueryClientProvider>
</StrictMode>
);
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();
}
},
);
});
+42 -1
View File
@@ -71,9 +71,50 @@
will-change: opacity, transform;
}
.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 {
.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;
}
}
+1 -15
View File
@@ -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,