mirror of
https://github.com/ollama/ollama.git
synced 2026-09-21 13:38:14 -05:00
app: refresh Apps layout and command copy feedback (#18372)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 & 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(
|
||||
<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 couldn’t save your progress. Please try again.",
|
||||
);
|
||||
|
||||
@@ -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<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
@@ -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'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 Claude’s usual connection…"
|
||||
: claudeIntegration.description)}
|
||||
: !claudeInstalled
|
||||
? "We’ll 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."
|
||||
: `Couldn’t 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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user