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:
2026-10-05 09:37:48 +07:00
parent 65fe680d38
commit ad6e80192d
80 changed files with 2914 additions and 489 deletions
@@ -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");