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:
@@ -5,9 +5,10 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, within } from "@testing-library/react";
|
||||
// render is used inside renderCard helper
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AgentCard } from "../AgentCard";
|
||||
import type { Agent } from "../../lib/types";
|
||||
import { formatModelName, fmtCost } from "../../lib/format";
|
||||
@@ -325,4 +326,42 @@ describe("AgentCard", () => {
|
||||
);
|
||||
expect(screen.getByText(/ran 5m 30s/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("right-click opens the session menu; its items act without clicking the card", async () => {
|
||||
function Where() {
|
||||
const loc = useLocation();
|
||||
return <span data-testid="where">{loc.pathname + loc.search}</span>;
|
||||
}
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<>
|
||||
<AgentCard
|
||||
agent={makeAgent()}
|
||||
session={{ id: "sess-1", lane_id: 4, cwd: "/r/app" } as never}
|
||||
onClick={onClick}
|
||||
/>
|
||||
<Where />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByText("Main Agent") });
|
||||
const menu = screen.getByTestId("session-row-menu-sess-1");
|
||||
await user.click(within(menu).getByText("Copy folder path"));
|
||||
expect(await navigator.clipboard.readText()).toBe("/r/app");
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByText("Main Agent") });
|
||||
await user.click(screen.getByText("Open in its workspace"));
|
||||
expect(screen.getByTestId("where")).toHaveTextContent("/workspace?lane=4&session=sess-1");
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
* @file StatusBar.test.tsx
|
||||
* @description Tests for the shell's bottom status bar: the live/disconnected
|
||||
* state with the version (click opens the connection details), the running and
|
||||
* needs-you counts from the shared lane list, and today's spend from the
|
||||
* pricing cost call's `daily_costs`.
|
||||
* needs-you counts from the shared lane list, today's spend from the
|
||||
* pricing cost call's `daily_costs`, and on the Workspace page the open
|
||||
* workspace's and the shown session's totals.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
function today() {
|
||||
@@ -30,25 +32,35 @@ vi.mock("../../lib/api", () => ({
|
||||
}),
|
||||
},
|
||||
pricing: {
|
||||
totalCost: vi.fn().mockImplementation(async () => ({
|
||||
total_cost: 10,
|
||||
totalCost: vi.fn().mockImplementation(async (opts?: { lane?: number }) => ({
|
||||
total_cost: opts?.lane ? 123.456 : 10,
|
||||
breakdown: [],
|
||||
daily_costs: [
|
||||
{ date: "2000-01-01", cost: 9 },
|
||||
{ date: today(), cost: 1.234 },
|
||||
],
|
||||
})),
|
||||
sessionCost: vi.fn().mockImplementation(async (id: string) => ({
|
||||
total_cost: id === "s-live" ? 4.5 : 7,
|
||||
breakdown: [],
|
||||
daily_costs: [],
|
||||
})),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../../lib/api";
|
||||
import { StatusBar } from "../StatusBar";
|
||||
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
||||
|
||||
const at = (url: string, ui: JSX.Element) =>
|
||||
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
|
||||
|
||||
describe("StatusBar", () => {
|
||||
it("shows the connection with the version, the counts and today's spend", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConnectionClick = vi.fn();
|
||||
render(<StatusBar wsConnected left="15rem" onConnectionClick={onConnectionClick} />);
|
||||
at("/", <StatusBar wsConnected left="15rem" onConnectionClick={onConnectionClick} />);
|
||||
expect(screen.getByText("Live")).toBeInTheDocument();
|
||||
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument();
|
||||
expect(await screen.findByText("Today $1.23")).toBeInTheDocument();
|
||||
@@ -58,7 +70,41 @@ describe("StatusBar", () => {
|
||||
});
|
||||
|
||||
it("says Disconnected when the socket is down", () => {
|
||||
render(<StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
||||
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
||||
expect(screen.getByText("Disconnected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("off the Workspace page shows no workspace or session cost", async () => {
|
||||
at("/sessions", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
||||
await screen.findByText("Today $1.23");
|
||||
expect(screen.queryByTestId("status-bar-workspace-cost")).toBeNull();
|
||||
expect(screen.queryByTestId("status-bar-session-cost")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the open workspace's total and the replayed session's", async () => {
|
||||
at(
|
||||
"/workspace?lane=3&session=s-old",
|
||||
<StatusBar wsConnected left="0" onConnectionClick={() => {}} />
|
||||
);
|
||||
expect(await screen.findByText("Workspace $123.46")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Session $7.00")).toBeInTheDocument();
|
||||
expect(api.pricing.totalCost).toHaveBeenCalledWith({ lane: 3 });
|
||||
});
|
||||
|
||||
it("takes a focused pane's session from the page's run poll", async () => {
|
||||
at(
|
||||
"/workspace?lane=3&run=agenthub-lane-3-1",
|
||||
<StatusBar wsConnected left="0" onConnectionClick={() => {}} />
|
||||
);
|
||||
await screen.findByText("Workspace $123.46");
|
||||
expect(screen.queryByTestId("status-bar-session-cost")).toBeNull();
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(LIVE_RUNS_EVENT, {
|
||||
detail: [{ id: "agenthub-lane-3-1", laneId: 3, status: "running", sessionId: "s-live" }],
|
||||
})
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("Session $4.50")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -285,4 +285,51 @@ describe("GlobalTooltip", () => {
|
||||
fireEvent.scroll(document);
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("silences an ancestor's native title while a child's bubble is up", async () => {
|
||||
// Parking only the child's title let the browser fall back to the nearest
|
||||
// ancestor's — a native black box over the designed bubble.
|
||||
render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<div data-testid="header" title="Drag to move this pane">
|
||||
<button title="Close pane">x</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
const header = screen.getByTestId("header");
|
||||
const button = screen.getByRole("button", { name: "x" });
|
||||
hover(button);
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Close pane");
|
||||
expect(header).not.toHaveAttribute("title");
|
||||
|
||||
unhover(button);
|
||||
expect(header).toHaveAttribute("title", "Drag to move this pane");
|
||||
expect(button).toHaveAttribute("title", "Close pane");
|
||||
});
|
||||
|
||||
it("does not bring back an ancestor title React removed while it was parked", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<div data-testid="header" title="Drag">
|
||||
<button title="Close">x</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "x" });
|
||||
hover(button);
|
||||
await settle();
|
||||
rerender(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<div data-testid="header">
|
||||
<button title="Close">x</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
unhover(button);
|
||||
expect(screen.getByTestId("header")).not.toHaveAttribute("title");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
items: [{ id: "agenthub-lane-1-2", laneId: 1, status: "running", sessionId: "s-live" }],
|
||||
})),
|
||||
cwds: vi.fn(async () => ({ items: [] })),
|
||||
kill: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
aiTools: {
|
||||
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
||||
@@ -163,6 +164,40 @@ describe("WorkspaceTree", () => {
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||
});
|
||||
|
||||
it("right-click on a session opens its menu: split, rename, archive, close its tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
const right = (target: Element) => user.pointer({ keys: "[MouseRight]", target });
|
||||
|
||||
await right(await screen.findByTestId("workspace-session-s-old"));
|
||||
let menu = screen.getByTestId("session-menu-s-old");
|
||||
// Ended: no pane to split or close.
|
||||
expect(within(menu).queryByText("Open in split")).toBeNull();
|
||||
expect(within(menu).queryByText("Close tab (stops its session)")).toBeNull();
|
||||
await user.click(within(menu).getByText("Rename"));
|
||||
expect(screen.queryByTestId("session-menu-s-old")).toBeNull();
|
||||
expect(screen.getByTestId("workspace-session-rename-s-old")).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await right(screen.getByTestId("workspace-session-s-live"));
|
||||
menu = screen.getByTestId("session-menu-s-live");
|
||||
await user.click(within(menu).getByText("Open in split"));
|
||||
expect(screen.getByTestId("where")).toHaveTextContent(
|
||||
"/workspace?lane=1&run=agenthub-lane-1-2"
|
||||
);
|
||||
|
||||
await right(screen.getByTestId("workspace-session-s-live"));
|
||||
await user.click(within(screen.getByTestId("session-menu-s-live")).getByText("Archive"));
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||
|
||||
await right(screen.getByTestId("workspace-session-s-live"));
|
||||
await user.click(
|
||||
within(screen.getByTestId("session-menu-s-live")).getByText("Close tab (stops its session)")
|
||||
);
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
|
||||
});
|
||||
|
||||
it("archiving a worktree session removes its worktree first, after asking", async () => {
|
||||
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||
sessions: [
|
||||
|
||||
Reference in New Issue
Block a user