feat: right-click menus everywhere, workspace/session cost, long-term DB safety
Right-click menus: one portal shell (components/ContextMenu.tsx) for every item with actions - sidebar/Recents/archived session rows (SessionMenu), console tabs, pane headers, the Changes panel's files/tree/commits, the workspace home, sessions outside a workspace (SessionRowMenu: Sessions table, Dashboard, Activity, Workflow runs), prompt images, every Settings list and every Claude Code/Codex config list (FileItemMenu). Buttons stay; a delete keeps its button's confirm; the shell stops portal clicks from reaching the card that opened it. Status bar: Workspace $ and Session $ on the Workspace page, from ?lane= on GET /api/pricing/cost and the focused/replayed session's cost. Database, no data dropped: synchronous=NORMAL, journal_size_limit 64 MB, hourly PASSIVE checkpoint + PRAGMA optimize, idx_events_session_created (replacing the redundant idx_events_session), watchdog reads sessions.transcript_path, transcript parsed before the hook's write transaction, GET /api/sessions/:id?events=0 for the web page (reloads only on its own session, debounced), streamed export, transactional clear-data and chunked cleanup, subagent import reads tool_use_ids once, runtime logs rotated past 5 MB.
This commit is contained in:
@@ -233,4 +233,25 @@ describe("AiRouterConfig — testing an endpoint before saving it", () => {
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "2");
|
||||
await waitFor(() => expect(screen.queryByTestId("router-test-result")).toBeNull());
|
||||
});
|
||||
|
||||
it("opens a profile's Edit and Remove from a right-click; the built-in row has no menu", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValueOnce(false);
|
||||
const user = userEvent.setup();
|
||||
render(<AiRouterConfig />);
|
||||
const row = (await screen.findByText("9Router")).closest("tr")!;
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByText(native.label) });
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
await user.click(screen.getByRole("menuitem", { name: "aiRouter.remove" }));
|
||||
expect(confirm).toHaveBeenCalledWith("aiRouter.removeConfirm:9Router");
|
||||
expect(api.aiRouters.remove).not.toHaveBeenCalled();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
await user.click(screen.getByRole("menuitem", { name: "aiRouter.edit" }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
confirm.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @file Covers the right-click menus on the Claude Code config page: a menu
|
||||
* item runs the SAME action as the card's button — a plugin's uninstall still
|
||||
* takes two clicks before it reaches the API, and a profile's delete calls the
|
||||
* same endpoint the trash button does.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const ccConfigMock = vi.hoisted(() => {
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
return {
|
||||
overview: r({
|
||||
roots: {
|
||||
claudeHome: "/home/.claude",
|
||||
projectClaudeDir: "/repo/.claude",
|
||||
projectRoot: "/repo",
|
||||
},
|
||||
counts: {
|
||||
skills: { user: 0, project: 0 },
|
||||
agents: { user: 0, project: 0 },
|
||||
commands: { user: 0, project: 0 },
|
||||
outputStyles: { user: 0, project: 0 },
|
||||
mcpServers: { user: 0, project: 0 },
|
||||
hooks: {},
|
||||
plugins: 1,
|
||||
marketplaces: 0,
|
||||
keybindings: 0,
|
||||
settingsFiles: 0,
|
||||
memory: 0,
|
||||
},
|
||||
}),
|
||||
skills: r({ items: [] }),
|
||||
agents: r({ items: [] }),
|
||||
commands: r({ items: [] }),
|
||||
outputStyles: r({ items: [] }),
|
||||
memory: r({ items: [] }),
|
||||
plugins: r({
|
||||
manifestPath: "/home/.claude/plugins/installed_plugins.json",
|
||||
manifestExists: true,
|
||||
plugins: [{ key: "ponytail@ponytail", name: "ponytail", enabled: true, version: "4.9.0" }],
|
||||
}),
|
||||
marketplaces: r({ items: [] }),
|
||||
mcp: r({ user: [], projectScoped: [] }),
|
||||
hooks: r({ hooks: [] }),
|
||||
settings: r({ sources: [] }),
|
||||
keybindings: r({ groups: [] }),
|
||||
statusline: r({}),
|
||||
hookScripts: r({ items: [] }),
|
||||
backups: r({ items: [] }),
|
||||
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
|
||||
pluginsAvailable: r({ installed: [], available: [] }),
|
||||
updateAllStatus: r({ batch: null }),
|
||||
pluginAction: r({ ok: true }),
|
||||
profiles: r({
|
||||
items: [
|
||||
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
|
||||
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
|
||||
],
|
||||
active: { accountUuid: "u1", email: "a@b.c" },
|
||||
}),
|
||||
profileUsage: r({ name: "x", refreshed: false, fiveHour: null, sevenDay: null }),
|
||||
deleteProfile: r({ ok: true }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
import { CcConfig } from "../CcConfig";
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("CcConfig — right-click menus", () => {
|
||||
it("uninstalls a plugin from its menu only on the second click, like the button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CcConfig />);
|
||||
await settle();
|
||||
await user.click(screen.getByRole("tab", { name: /plugins/i }));
|
||||
await settle();
|
||||
|
||||
await user.pointer({
|
||||
keys: "[MouseRight]",
|
||||
target: screen.getByTestId("plugin-card-ponytail@ponytail"),
|
||||
});
|
||||
const item = screen.getByTestId("plugin-menu-uninstall");
|
||||
await user.click(item);
|
||||
expect(ccConfigMock.pluginAction).not.toHaveBeenCalled();
|
||||
expect(item).toHaveTextContent(/click again/i);
|
||||
|
||||
await user.click(item);
|
||||
await settle();
|
||||
expect(ccConfigMock.pluginAction).toHaveBeenCalledWith("ponytail@ponytail", "uninstall");
|
||||
expect(screen.queryByTestId("plugin-menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes a saved profile from its menu through the same API as the trash button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CcConfig />);
|
||||
await settle();
|
||||
await user.click(screen.getByRole("button", { name: /profile/i }));
|
||||
await settle();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
|
||||
await user.click(screen.getByTestId("profile-menu-delete"));
|
||||
await settle();
|
||||
expect(ccConfigMock.deleteProfile).toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("offers no delete for the only saved copy of the live account", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CcConfig />);
|
||||
await settle();
|
||||
await user.click(screen.getByRole("button", { name: /profile/i }));
|
||||
await settle();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-work") });
|
||||
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file Tests for the Git credentials settings tab (issue #45): lists saved
|
||||
* logins without ever rendering a token, replaces the token for a host, adds
|
||||
* one from a URL, and deletes only after a confirm.
|
||||
* one from a URL, and deletes only after a confirm — from the button or the
|
||||
* row's right-click menu.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -98,4 +99,24 @@ describe("GitCredentialsConfig", () => {
|
||||
);
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
it("offers Replace and Remove on a right-click, Remove behind the same confirm", async () => {
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValueOnce(true);
|
||||
const user = userEvent.setup();
|
||||
render(<GitCredentialsConfig />);
|
||||
const row = (await screen.findByText("git.smartgift.io.vn")).closest("tr")!;
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
await user.click(screen.getByRole("menuitem", { name: "gitCreds.replace" }));
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
expect(screen.getByLabelText("gitCreds.fieldHost")).toHaveValue("https://git.smartgift.io.vn");
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
await user.click(screen.getByRole("menuitem", { name: "gitCreds.remove" }));
|
||||
expect(confirm).toHaveBeenCalledWith("gitCreds.removeConfirm");
|
||||
await waitFor(() =>
|
||||
expect(api.gitCredentials.remove).toHaveBeenCalledWith("https", "git.smartgift.io.vn")
|
||||
);
|
||||
confirm.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -763,6 +763,39 @@ describe("split panes", () => {
|
||||
expect(screen.getByTestId("pane-0").style.left).toBe("50%");
|
||||
});
|
||||
|
||||
it("right-click on a tab opens it in a split or closes it; on a pane header, swaps, splits or closes", async () => {
|
||||
withRuns([live(0), live(1, "shell"), live(2)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
const user = userEvent.setup();
|
||||
const right = (target: Element) => user.pointer({ keys: "[MouseRight]", target });
|
||||
|
||||
await right(screen.getByTestId("console-tab-1"));
|
||||
await user.click(within(screen.getByTestId("console-tab-menu-1")).getByText("Open in split"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
|
||||
await right(screen.getByTestId("pane-header-0"));
|
||||
await user.click(within(screen.getByTestId("pane-menu-0")).getByText(/Show Terminal here/));
|
||||
await settle();
|
||||
expect(screen.getByTestId("pane-1").style.left).toBe("0%");
|
||||
|
||||
await right(screen.getByTestId("pane-header-1"));
|
||||
await user.click(screen.getByTestId("pane-menu-split-bottom"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 3, shell: true });
|
||||
|
||||
await right(screen.getByTestId("pane-header-1"));
|
||||
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Close pane"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
expect(api.run.kill).not.toHaveBeenCalled();
|
||||
|
||||
await right(screen.getByTestId("console-tab-2"));
|
||||
await user.click(
|
||||
within(screen.getByTestId("console-tab-menu-2")).getByText("Close tab (stops its session)")
|
||||
);
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
|
||||
});
|
||||
|
||||
it("a split menu starts a new terminal in the first free slot", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
@@ -773,6 +806,23 @@ describe("split panes", () => {
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 2, shell: true });
|
||||
});
|
||||
|
||||
it("with one pane, the split menus sit beside Tools and split it", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
expect(screen.queryByTestId("pane-header-0")).toBeNull();
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("pane-split-right-0"));
|
||||
await user.click(screen.getAllByRole("menuitem")[0] as HTMLElement);
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
// Split: the tab bar's copy goes, each pane header has its own.
|
||||
expect(screen.getAllByTestId("pane-split-right-0")).toHaveLength(1);
|
||||
expect(screen.getByTestId("pane-header-0")).toContainElement(
|
||||
screen.getByTestId("pane-split-right-0")
|
||||
);
|
||||
});
|
||||
|
||||
it("a pane whose run exits closes, and the URL follows the pane that takes its place", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
|
||||
Reference in New Issue
Block a user