fa156d6a59
Chat UI: - Each Claude/Codex session keeps its own chat/terminal view; Settings only sets how new sessions open. - Composer + menu: upload, project files and folders (#54, also dragged from the Files/Changes tree), context files, recent uploads, skills, plugins. - Activity side panel (progress, outputs, files read, uploads, memory, skills, connectors) with a file preview; edited-file counts per turn. - Permission-mode chip (Shift+Tab steps, read back from the pane: server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode). - Stop (Esc) while the main agent works; the status is checked per agent (with session_id /api/agents ignores its status filter) and re-read every 5 s while Stop shows. - Dropped images shown in the conversation (GET /api/run/drop/:name, the requester's own drop dir), full-size viewer. - A plain-text paste over 5,000 characters is attached as pasted-text.txt, so no prompt reaches tmux/argv limits (#68). Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the whole panel to a workspace's extra folder (#70). Server mode S3 (one Linux user per account) — experimental, verified with fake sudo plus a P6 managed-settings spike on a test account: - member tmux listing runs in the background (cached, 15 s => unknown); - transcript snapshot copy is async, chunked, temp file + rename; - Express Layer patch only on Express 4; - tmux capturePane/sendKey go to the member's own server. Docs: the multi-user design moved in as docs/plans/server-mode/README.md (the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
257 lines
9.9 KiB
TypeScript
257 lines
9.9 KiB
TypeScript
/**
|
|
* @file Onboarding.test.tsx
|
|
* @description The first-run setup (docs/plans/onboarding): the account's
|
|
* preferences are loaded into localStorage on sign-in and every later change
|
|
* is sent back; the starting choices never hold bypass; the wizard shows only
|
|
* for an account that is new to it, writes each pick like Settings does and
|
|
* ends in the tour; the tour follows the real UI step by step.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
|
|
const prefsMock = vi.fn();
|
|
const saveMock = vi.fn();
|
|
const onboardedMock = vi.fn();
|
|
const lanesMock = vi.fn();
|
|
|
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
return {
|
|
...actual,
|
|
api: {
|
|
me: {
|
|
prefs: () => prefsMock(),
|
|
savePrefs: (p: unknown) => saveMock(p),
|
|
onboarded: () => onboardedMock(),
|
|
},
|
|
lanes: { list: () => lanesMock() },
|
|
aiTools: {
|
|
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
|
list: async () => ({
|
|
default: "claude",
|
|
catalog: [],
|
|
tools: [
|
|
{
|
|
id: "claude",
|
|
label: "Claude Code",
|
|
command: "claude",
|
|
installed: true,
|
|
enabled: true,
|
|
},
|
|
{ id: "codex", label: "Codex", command: "codex", installed: true, enabled: true },
|
|
{ id: "kimi", label: "Kimi Code", command: "kimi", installed: false, enabled: true },
|
|
],
|
|
}),
|
|
},
|
|
},
|
|
};
|
|
});
|
|
vi.mock("../../settings/PetSettings", () => ({
|
|
PetSettings: () => <div data-testid="pet-settings" />,
|
|
}));
|
|
|
|
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
|
|
import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults";
|
|
import { OnboardingWizard } from "../OnboardingWizard";
|
|
import { OnboardingTour, startTour } from "../OnboardingTour";
|
|
import { __resetAiTools } from "../../../hooks/useAiTools";
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
__resetUserPrefs();
|
|
__resetAiTools();
|
|
prefsMock.mockReset();
|
|
saveMock.mockReset().mockResolvedValue({ prefs: {}, onboardedAt: null });
|
|
onboardedMock.mockReset().mockResolvedValue({ prefs: {}, onboardedAt: "2026-10-08T00:00:00Z" });
|
|
lanesMock.mockReset().mockResolvedValue({ lanes: [], counts: {} });
|
|
});
|
|
|
|
describe("account preferences", () => {
|
|
it("loads the account's values, sends up this browser's, then every change", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
localStorage.setItem("agenthub.terminal.font", "maple");
|
|
localStorage.setItem("theme", "dark");
|
|
prefsMock.mockResolvedValue({ prefs: { theme: "light" }, onboardedAt: null });
|
|
await hydrate();
|
|
expect(localStorage.getItem("theme")).toBe("light");
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(saveMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({ "agenthub.terminal.font": "maple" })
|
|
);
|
|
saveMock.mockClear();
|
|
localStorage.setItem("agenthub.theme.dark", "darcula");
|
|
localStorage.setItem("unrelated.key", "x");
|
|
localStorage.removeItem("agenthub.pet");
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(saveMock).toHaveBeenCalledTimes(1);
|
|
expect(saveMock).toHaveBeenCalledWith({
|
|
"agenthub.theme.dark": "darcula",
|
|
"agenthub.pet": null,
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("never keeps bypass as the starting permission mode", () => {
|
|
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
|
|
expect(readDefaultMode()).toBe("auto");
|
|
writeDefaultMode("bypassPermissions" as never);
|
|
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
|
|
writeDefaultMode("plan");
|
|
expect(readDefaultMode()).toBe("plan");
|
|
});
|
|
});
|
|
|
|
function Probe() {
|
|
const s = useOnboarding();
|
|
return <span data-testid="onboarded">{s.onboardedAt ?? "no"}</span>;
|
|
}
|
|
const renderWizard = () =>
|
|
render(
|
|
<MemoryRouter>
|
|
<OnboardingWizard />
|
|
<Probe />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
describe("the setup wizard", () => {
|
|
it("does not show before the account's preferences load, nor once done", async () => {
|
|
renderWizard();
|
|
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
|
|
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: "2026-01-01T00:00:00Z" });
|
|
await act(() => hydrate());
|
|
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
|
|
});
|
|
|
|
it("skips itself for an account that already has a workspace", async () => {
|
|
lanesMock.mockResolvedValue({ lanes: [{ id: 1 }], counts: {} });
|
|
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
|
|
renderWizard();
|
|
await act(() => hydrate());
|
|
await waitFor(() => expect(onboardedMock).toHaveBeenCalled());
|
|
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
|
|
});
|
|
|
|
it("walks four steps, saving each pick, and ends in the tour", async () => {
|
|
const user = userEvent.setup();
|
|
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
|
|
renderWizard();
|
|
await act(() => hydrate());
|
|
await screen.findByTestId("onboarding-wizard");
|
|
expect(screen.getByText("Pick your default agent")).toBeInTheDocument();
|
|
await user.click(await screen.findByTestId("onboarding-agent-codex"));
|
|
expect(localStorage.getItem("agenthub.defaultTool")).toBe("codex");
|
|
// A tool that is not installed is not offered until asked for, and never pickable.
|
|
expect(screen.queryByText("Kimi Code")).toBeNull();
|
|
await user.click(screen.getByText(/Show 1 more agents/));
|
|
expect(screen.getByText("Kimi Code").closest("button")).toBeNull();
|
|
await user.click(screen.getByRole("radio", { name: "Plan" }));
|
|
expect(localStorage.getItem("agenthub.permissionMode")).toBe("plan");
|
|
|
|
await user.click(screen.getByTestId("onboarding-next"));
|
|
expect(screen.getByText("Make it feel like home")).toBeInTheDocument();
|
|
await user.click(screen.getByTestId("onboarding-mode-light"));
|
|
expect(localStorage.getItem("theme")).toBe("light");
|
|
await user.click(screen.getByRole("button", { name: "Light theme: Canvas" }));
|
|
expect(localStorage.getItem("agenthub.theme.light")).toBe("canvas");
|
|
|
|
await user.click(screen.getByTestId("onboarding-next"));
|
|
expect(screen.getByTestId("pet-settings")).toBeInTheDocument();
|
|
await user.click(screen.getByTestId("onboarding-next"));
|
|
expect(screen.getByText("Set up notifications")).toBeInTheDocument();
|
|
await user.click(screen.getByRole("button", { name: /Add your first workspace/ }));
|
|
await waitFor(() => expect(onboardedMock).toHaveBeenCalled());
|
|
expect(screen.queryByTestId("onboarding-wizard")).toBeNull();
|
|
expect(localStorage.getItem("agenthub.tour")).toBe("add");
|
|
});
|
|
|
|
it("is skipped for good with Skip setup", async () => {
|
|
const user = userEvent.setup();
|
|
prefsMock.mockResolvedValue({ prefs: {}, onboardedAt: null });
|
|
renderWizard();
|
|
await act(() => hydrate());
|
|
await user.click(await screen.findByRole("button", { name: "Skip setup" }));
|
|
await waitFor(() => expect(screen.getByTestId("onboarded").textContent).not.toBe("no"));
|
|
expect(onboardedMock).toHaveBeenCalled();
|
|
expect(localStorage.getItem("agenthub.tour")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("the tour", () => {
|
|
const mount = (html: string) => {
|
|
const host = document.createElement("div");
|
|
host.innerHTML = html;
|
|
document.body.appendChild(host);
|
|
return host;
|
|
};
|
|
// jsdom has no layout: give the targets a box.
|
|
const boxed = () =>
|
|
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
|
|
x: 10,
|
|
y: 10,
|
|
left: 10,
|
|
top: 10,
|
|
width: 100,
|
|
height: 30,
|
|
right: 110,
|
|
bottom: 40,
|
|
toJSON: () => ({}),
|
|
} as DOMRect);
|
|
|
|
it("follows the real UI: add → folder → create → prompt", async () => {
|
|
const spy = boxed();
|
|
const user = userEvent.setup();
|
|
const page = mount('<button data-testid="workspace-tree-add">+</button>');
|
|
try {
|
|
startTour();
|
|
render(
|
|
<MemoryRouter initialEntries={["/workspace"]}>
|
|
<OnboardingTour />
|
|
</MemoryRouter>
|
|
);
|
|
expect(await screen.findByText("Create your first workspace")).toBeInTheDocument();
|
|
// The dialog opened: the tour moves on by itself.
|
|
const dialog = mount(
|
|
'<div data-tour="add-lane"><div data-tour="add-lane-folder"></div><button data-tour="add-lane-create"></button></div>'
|
|
);
|
|
expect(await screen.findByText("Choose the project folder")).toBeInTheDocument();
|
|
await user.click(screen.getByTestId("onboarding-tour-next"));
|
|
expect(screen.getByText("Create it")).toBeInTheDocument();
|
|
// Created: the dialog is gone and the workspace's prompt box is there.
|
|
dialog.remove();
|
|
page.innerHTML = '<div data-testid="workspace-home-composer"></div>';
|
|
expect(await screen.findByText("Start your first session")).toBeInTheDocument();
|
|
expect(localStorage.getItem("agenthub.tour")).toBe("prompt");
|
|
await user.click(screen.getByRole("button", { name: "Skip tour" }));
|
|
expect(screen.queryByTestId("onboarding-tour")).toBeNull();
|
|
expect(localStorage.getItem("agenthub.tour")).toBe("done");
|
|
} finally {
|
|
spy.mockRestore();
|
|
page.remove();
|
|
}
|
|
});
|
|
|
|
it("goes back to Add workspace when the dialog closes with nothing made", async () => {
|
|
const spy = boxed();
|
|
const page = mount('<button data-testid="workspace-tree-add">+</button>');
|
|
try {
|
|
localStorage.setItem("agenthub.tour", "create");
|
|
render(
|
|
<MemoryRouter initialEntries={["/workspace"]}>
|
|
<OnboardingTour />
|
|
</MemoryRouter>
|
|
);
|
|
expect(await screen.findByText("Create your first workspace")).toBeInTheDocument();
|
|
} finally {
|
|
spy.mockRestore();
|
|
page.remove();
|
|
}
|
|
});
|
|
});
|