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
+152 -25
View File
@@ -12,6 +12,9 @@
* dashboard's own Codex settings: CODEX_HOME (editable, so the overview's path
* list leaves it out), AgentHub's Codex hooks (`CodexHooksSettings`) and Model
* Pricing for OpenAI models (the shared `PricingSettings` table).
* Every card with action buttons also opens a right-click menu repeating
* them (same handlers — Delete still asks for confirmation) plus Copy
* name/path.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -52,6 +55,14 @@ import {
} from "../components/config/ConfirmDeleteModal";
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
import { ScopeBadge } from "../components/config/ScopeBadge";
import { FileItemMenu } from "../components/config/MdItemList";
import {
ContextMenu,
MenuItem,
MenuSeparator,
useContextMenu,
type MenuPoint,
} from "../components/ContextMenu";
import { ConfigTabBar } from "../components/config/ConfigTabBar";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import { HomeDirSetting } from "../components/settings/HomeDirSetting";
@@ -961,6 +972,8 @@ function ArtifactList({
return blob.toLowerCase().includes(q);
});
}, [items, search]);
const { t } = useTranslation("codexConfig");
const ctx = useContextMenu<NamedArtifactItem>();
if (!filtered) return <SkeletonRows n={6} />;
if (filtered.length === 0) return <Empty />;
@@ -975,21 +988,50 @@ function ArtifactList({
onOpen={onOpen}
onEdit={onEdit}
onDelete={onDelete}
onMenu={ctx.open}
/>
))}
{ctx.menu && (
<FileItemMenu
x={ctx.menu.x}
y={ctx.menu.y}
onClose={ctx.close}
path={ctx.menu.item.file}
labels={fileMenuLabels(t)}
onOpen={() => onOpen(ctx.menu!.item.file)}
onEdit={() => {
const it = ctx.menu!.item;
onEdit(kind, { scope: it.scope, name: it.name, filePath: it.file });
}}
onDelete={() => {
const it = ctx.menu!.item;
onDelete(kind, it.scope, it.name, it.file);
}}
/>
)}
</div>
);
}
/** The Codex page's labels for the shared file-card menu — its buttons' own. */
const fileMenuLabels = (t: (key: string) => string) => ({
open: t("list.viewButton"),
edit: t("list.editButton"),
delete: t("list.deleteButton"),
copyPath: t("list.copyPath"),
});
function ArtifactCard({
item,
kind,
onOpen,
onEdit,
onDelete,
onMenu,
}: {
item: NamedArtifactItem;
kind: "skills" | "agents" | "rules";
onMenu: (e: React.MouseEvent, item: NamedArtifactItem) => void;
onOpen: (path: string) => void;
onEdit: (
type: CodexArtifactType,
@@ -1011,7 +1053,10 @@ function ArtifactCard({
.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">
@@ -1081,6 +1126,7 @@ function ScopeFilePanel({
onCreate: (type: CodexArtifactType, scope: "user" | "project") => void;
}) {
const { t } = useTranslation("codexConfig");
const ctx = useContextMenu<CodexMemoryFile | CodexConfigFile>();
if (!items) return <SkeletonRows n={2} />;
const present = items.filter((m) => m.exists);
@@ -1090,10 +1136,32 @@ function ScopeFilePanel({
return (
<div className="space-y-3">
{ctx.menu && (
<FileItemMenu
x={ctx.menu.x}
y={ctx.menu.y}
onClose={ctx.close}
path={ctx.menu.item.file}
labels={fileMenuLabels(t)}
onOpen={() => onOpen(ctx.menu!.item.file)}
onEdit={() => {
const m = ctx.menu!.item;
onEdit(type, { scope: m.scope, name: "", filePath: m.file });
}}
onDelete={() => {
const m = ctx.menu!.item;
onDelete(type, m.scope, undefined, m.file);
}}
/>
)}
{present.map((m) => {
const preview = "preview" in m ? m.preview : "content" in m ? m.content : "";
return (
<div key={m.scope} className="rounded-lg border border-border bg-surface-2">
<div
key={m.scope}
onContextMenu={(e) => ctx.open(e, m)}
className="rounded-lg border border-border bg-surface-2"
>
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2 flex-wrap">
<ScopeBadge scope={m.scope} />
<span className="font-mono text-[11px] text-fg-muted truncate flex-1 min-w-0">
@@ -1211,6 +1279,50 @@ function CliUnavailable({ message }: { message: string }) {
);
}
/** A CLI-backed row as its right-click menu sees it: its name, and its
* Remove button's own action and label. */
type CliRow = { label: string; real: boolean; removeLabel: string; remove: () => void };
/** Copy the row's name, and run its Remove button's action — disabled exactly
* when the button is. */
function CliRowMenu({
x,
y,
item,
busy,
onClose,
}: MenuPoint & { item: CliRow; busy: string | null; onClose: () => void }) {
const { t } = useTranslation("codexConfig");
return (
<ContextMenu x={x} y={y} onClose={onClose} testId="codex-row-menu">
{item.real && (
<>
<MenuItem
onSelect={() => {
onClose();
void navigator.clipboard.writeText(item.label);
}}
>
{t("list.copyName")}
</MenuItem>
<MenuSeparator />
</>
)}
<MenuItem
danger
disabled={busy === item.label || !item.real}
testId="codex-row-menu-remove"
onSelect={() => {
onClose();
item.remove();
}}
>
{item.removeLabel}
</MenuItem>
</ContextMenu>
);
}
function KvRows({ item, skip }: { item: Record<string, unknown>; skip: string[] }) {
const entries = Object.entries(item).filter(
([k, v]) =>
@@ -1245,6 +1357,7 @@ function McpPanel({
}) {
const { t } = useTranslation("codexConfig");
const { busy, run } = useConfigAction(onToast, onChanged);
const ctx = useContextMenu<CliRow>();
const [mode, setMode] = useState<"command" | "url">("command");
const [name, setName] = useState("");
const [command, setCommand] = useState("");
@@ -1360,21 +1473,27 @@ function McpPanel({
) : (
filtered.map((s, i) => {
const { label, real } = itemLabel(s, i, t("tabs.mcp"));
const remove = () =>
void run(
label,
() => api.codexConfig.mcpRemove(label),
t("mcp.removed", { name: label })
);
return (
<div key={label + i} className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div
key={label + i}
onContextMenu={(e) =>
ctx.open(e, { label, real, remove, removeLabel: t("mcp.removeButton") })
}
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<span className="font-mono text-sm text-fg-primary truncate">{label}</span>
<KvRows item={s} skip={["name"]} />
</div>
<button
onClick={() =>
void run(
label,
() => api.codexConfig.mcpRemove(label),
t("mcp.removed", { name: label })
)
}
onClick={remove}
disabled={busy === label || !real}
title={real ? undefined : t("manage.noIdentifier")}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
@@ -1387,6 +1506,7 @@ function McpPanel({
);
})
)}
{ctx.menu && <CliRowMenu {...ctx.menu} busy={busy} onClose={ctx.close} />}
</div>
);
}
@@ -1408,6 +1528,7 @@ function PluginsPanel({
}) {
const { t } = useTranslation("codexConfig");
const { busy, run } = useConfigAction(onToast, onChanged);
const ctx = useContextMenu<CliRow>();
const [spec, setSpec] = useState("");
const [mktUrl, setMktUrl] = useState("");
@@ -1482,9 +1603,18 @@ function PluginsPanel({
) : (
filteredPlugins.map((p, i) => {
const { label, real } = itemLabel(p, i, t("tabs.plugins"));
const remove = () =>
void run(
label,
() => api.codexConfig.pluginAction({ spec: label, action: "remove" }),
t("plugins.removed", { name: label })
);
return (
<div
key={label + i}
onContextMenu={(e) =>
ctx.open(e, { label, real, remove, removeLabel: t("plugins.removeButton") })
}
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
>
<div className="flex items-start justify-between gap-3">
@@ -1493,13 +1623,7 @@ function PluginsPanel({
<KvRows item={p} skip={["name"]} />
</div>
<button
onClick={() =>
void run(
label,
() => api.codexConfig.pluginAction({ spec: label, action: "remove" }),
t("plugins.removed", { name: label })
)
}
onClick={remove}
disabled={busy === label || !real}
title={real ? undefined : t("manage.noIdentifier")}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
@@ -1560,9 +1684,18 @@ function PluginsPanel({
<div className="space-y-2">
{filteredMarketplaces.map((m, i) => {
const { label, real } = itemLabel(m, i, t("plugins.marketplacesTitle"));
const remove = () =>
void run(
label,
() => api.codexConfig.marketplaceAction({ name: label, action: "remove" }),
t("plugins.marketplaceRemoved", { name: label })
);
return (
<div
key={label + i}
onContextMenu={(e) =>
ctx.open(e, { label, real, remove, removeLabel: t("plugins.removeButton") })
}
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
>
<div className="flex items-start justify-between gap-3">
@@ -1571,14 +1704,7 @@ function PluginsPanel({
<KvRows item={m} skip={["name"]} />
</div>
<button
onClick={() =>
void run(
label,
() =>
api.codexConfig.marketplaceAction({ name: label, action: "remove" }),
t("plugins.marketplaceRemoved", { name: label })
)
}
onClick={remove}
disabled={busy === label || !real}
title={real ? undefined : t("manage.noIdentifier")}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
@@ -1600,6 +1726,7 @@ function PluginsPanel({
{t("plugins.noPlugins")}
</div>
)}
{ctx.menu && <CliRowMenu {...ctx.menu} busy={busy} onClose={ctx.close} />}
</div>
);
}