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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user