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
+95 -5
View File
@@ -3,13 +3,23 @@
* @description Generic list + card for markdown-frontmatter config artifacts
* (skills, agents, commands, output styles). Tool-agnostic: callers pass the
* items and the open/edit/delete handlers, nothing here reaches for an API.
* A right-click on a card opens `FileItemMenu` — the same handlers (Delete
* still goes through the caller's confirm) plus Copy path; the Claude Code and
* Codex config pages reuse that menu for their other file-backed cards.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useMemo } from "react";
import { useMemo, type MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Pencil, Trash2 } from "lucide-react";
import type { CcArtifactType, CcMdItem } from "../../lib/api";
import {
ContextMenu,
MenuItem,
MenuSeparator,
useContextMenu,
type MenuPoint,
} from "../ContextMenu";
import { ScopeBadge } from "./ScopeBadge";
import { Empty, SkeletonRows } from "./configPrimitives";
@@ -30,7 +40,65 @@ interface MdItemListProps {
kind: "skills" | "agents" | "commands" | "outputStyles";
}
const fileOf = (item: CcMdItem) => item.file || `${item.path}/SKILL.md`;
const typeOf = (kind: MdItemListProps["kind"]): CcArtifactType =>
kind === "outputStyles" ? "output-styles" : kind;
/** Right-click menu of a file-backed config card: the card's own Open / Edit /
* Delete handlers — Delete opens whatever confirm the button opens — plus a
* harmless Copy path. Labels default to the Claude Code page's. */
export function FileItemMenu({
x,
y,
onClose,
path,
onOpen,
onEdit,
onDelete,
labels,
}: MenuPoint & {
onClose: () => void;
path: string;
onOpen: () => void;
onEdit: () => void;
onDelete: () => void;
labels?: { open: string; edit: string; delete: string; copyPath: string };
}) {
const { t } = useTranslation("ccConfig");
const l = labels ?? {
open: t("common.viewSource"),
edit: t("edit.editButton"),
delete: t("edit.deleteButton"),
copyPath: t("common.copyPath"),
};
const pick = (fn: () => void) => () => {
onClose();
fn();
};
return (
<ContextMenu x={x} y={y} onClose={onClose} testId="config-item-menu">
<MenuItem testId="config-item-menu-open" onSelect={pick(onOpen)}>
{l.open}
</MenuItem>
<MenuItem testId="config-item-menu-edit" onSelect={pick(onEdit)}>
{l.edit}
</MenuItem>
<MenuItem
testId="config-item-menu-copy"
onSelect={pick(() => void navigator.clipboard.writeText(path))}
>
{l.copyPath}
</MenuItem>
<MenuSeparator />
<MenuItem danger testId="config-item-menu-delete" onSelect={pick(onDelete)}>
{l.delete}
</MenuItem>
</ContextMenu>
);
}
export function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: MdItemListProps) {
const ctx = useContextMenu<CcMdItem>();
const filtered = useMemo(() => {
if (!items) return null;
const q = search.toLowerCase();
@@ -56,9 +124,27 @@ export function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: Md
onOpen={onOpen}
onEdit={onEdit}
onDelete={onDelete}
onMenu={ctx.open}
kind={kind}
/>
))}
{ctx.menu && (
<FileItemMenu
x={ctx.menu.x}
y={ctx.menu.y}
onClose={ctx.close}
path={fileOf(ctx.menu.item)}
onOpen={() => onOpen(fileOf(ctx.menu!.item))}
onEdit={() => {
const it = ctx.menu!.item;
onEdit(typeOf(kind), { scope: it.scope, name: it.name, filePath: fileOf(it) });
}}
onDelete={() => {
const it = ctx.menu!.item;
onDelete(typeOf(kind), it.scope, it.name, fileOf(it));
}}
/>
)}
</div>
);
}
@@ -76,14 +162,15 @@ interface MdItemCardProps {
name: string | undefined,
path: string
) => void;
onMenu: (e: MouseEvent, item: CcMdItem) => void;
kind: "skills" | "agents" | "commands" | "outputStyles";
}
function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) {
function MdItemCard({ item, onOpen, onEdit, onDelete, onMenu, kind }: MdItemCardProps) {
const { t } = useTranslation("ccConfig");
const artifactType: CcArtifactType = kind === "outputStyles" ? "output-styles" : kind;
const artifactType = typeOf(kind);
const filePath = item.file || `${item.path}/SKILL.md`;
const filePath = fileOf(item);
const description =
item.frontmatter.description ||
item.preview
@@ -91,7 +178,10 @@ function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) {
.trim()
.slice(0, 200);
return (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3 hover:border-border/80 transition-colors">
<div
onContextMenu={(e) => onMenu(e, item)}
className="rounded-lg border border-border bg-surface-2 px-4 py-3 hover:border-border/80 transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
@@ -0,0 +1,72 @@
/**
* @file Covers MdItemList's right-click menu: each entry calls the same
* handler, with the same arguments, as the card button it repeats — Delete
* included, so the caller's confirm modal stays the only way to delete.
* @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 userEvent from "@testing-library/user-event";
import { MdItemList } from "../MdItemList";
import type { CcMdItem } from "../../../lib/api";
const item = {
name: "my-skill",
scope: "user",
path: "/home/.claude/skills/my-skill",
file: "",
frontmatter: { description: "Does things" },
preview: "",
} as unknown as CcMdItem;
function setup() {
const handlers = { onOpen: vi.fn(), onEdit: vi.fn(), onDelete: vi.fn() };
render(<MdItemList items={[item]} search="" kind="skills" {...handlers} />);
return handlers;
}
async function openMenu(user: ReturnType<typeof userEvent.setup>) {
await user.pointer({ keys: "[MouseRight]", target: screen.getByText("my-skill") });
expect(screen.getByTestId("config-item-menu")).toBeInTheDocument();
}
const path = "/home/.claude/skills/my-skill/SKILL.md";
describe("MdItemList — right-click menu", () => {
it("Open, Edit and Delete call the card buttons' handlers", async () => {
const user = userEvent.setup();
const h = setup();
await openMenu(user);
await user.click(screen.getByTestId("config-item-menu-open"));
expect(h.onOpen).toHaveBeenCalledWith(path);
expect(screen.queryByTestId("config-item-menu")).toBeNull();
await openMenu(user);
await user.click(screen.getByTestId("config-item-menu-edit"));
expect(h.onEdit).toHaveBeenCalledWith("skills", {
scope: "user",
name: "my-skill",
filePath: path,
});
await openMenu(user);
await user.click(screen.getByTestId("config-item-menu-delete"));
expect(h.onDelete).toHaveBeenCalledWith("skills", "user", "my-skill", path);
// The buttons still do the same thing.
await user.click(screen.getByRole("button", { name: /delete/i }));
expect(h.onDelete).toHaveBeenLastCalledWith("skills", "user", "my-skill", path);
});
it("Copy path writes the item's file path", async () => {
const user = userEvent.setup();
setup();
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue();
await openMenu(user);
await user.click(screen.getByTestId("config-item-menu-copy"));
expect(writeText).toHaveBeenCalledWith(path);
});
});