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
+354 -131
View File
@@ -10,6 +10,10 @@
* concurrent-write races with the live CLI. Its Overview also holds the
* dashboard's own Claude Code settings (CLAUDE_HOME — editable, so the path
* list leaves it out — hooks, pricing: `ClaudeAgentHubSettings`).
* Every card with action buttons (plugins, marketplaces, MCP servers, memory
* files, auth profiles, and the `MdItemList` artifacts) also opens a
* right-click menu that repeats those buttons — same handlers, same
* confirm path — plus Copy name/path.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -108,7 +112,8 @@ import {
type ConfirmDeleteState,
} from "../components/config/ConfirmDeleteModal";
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
import { MdItemList } from "../components/config/MdItemList";
import { FileItemMenu, MdItemList } from "../components/config/MdItemList";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import { ScopeBadge } from "../components/config/ScopeBadge";
import { ConfigTabBar } from "../components/config/ConfigTabBar";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
@@ -1499,6 +1504,13 @@ function PluginsPanel({
const [input, setInput] = useState("");
const [available, setAvailable] = useState<CcAvailablePlugin[] | null>(null);
const [availableError, setAvailableError] = useState<string | null>(null);
const availableCtx = useContextMenu<CcAvailablePlugin>();
const install = (a: CcAvailablePlugin) =>
void run(
a.pluginId,
() => api.ccConfig.installPlugin(a.pluginId),
t("manage.installed", { name: a.pluginId })
);
// The available inventory comes from the CLI, which may not be installed —
// a failure here is a normal state, not a page error.
@@ -1612,6 +1624,7 @@ function PluginsPanel({
{installable.map((a) => (
<div
key={a.pluginId}
onContextMenu={(e) => availableCtx.open(e, a)}
className="rounded-lg border border-border bg-surface-2 px-4 py-2.5 flex items-start gap-3"
>
<div className="min-w-0 flex-1">
@@ -1629,22 +1642,38 @@ function PluginsPanel({
</p>
)}
</div>
<ActionButton
icon={Plus}
disabled={busy === a.pluginId}
onClick={() =>
void run(
a.pluginId,
() => api.ccConfig.installPlugin(a.pluginId),
t("manage.installed", { name: a.pluginId })
)
}
>
<ActionButton icon={Plus} disabled={busy === a.pluginId} onClick={() => install(a)}>
{busy === a.pluginId ? t("manage.working") : t("manage.install")}
</ActionButton>
</div>
))}
</div>
{availableCtx.menu && (
<ContextMenu
x={availableCtx.menu.x}
y={availableCtx.menu.y}
onClose={availableCtx.close}
testId="available-plugin-menu"
>
<MenuItem
disabled={busy === availableCtx.menu.item.pluginId}
onSelect={() => {
availableCtx.close();
install(availableCtx.menu!.item);
}}
>
{t("manage.install")}
</MenuItem>
<MenuItem
onSelect={() => {
availableCtx.close();
void navigator.clipboard.writeText(availableCtx.menu!.item.pluginId);
}}
>
{t("common.copyName")}
</MenuItem>
</ContextMenu>
)}
</div>
)}
@@ -1674,6 +1703,33 @@ function PluginCard({
// Which contribution surface is expanded, if any. One at a time: the card
// sits in a list, and two open lists push everything below off the screen.
const [openSurface, setOpenSurface] = useState<string | null>(null);
const ctx = useContextMenu<true>();
const toggle = () =>
void run(
p.key,
() => api.ccConfig.pluginAction(p.key, p.enabled === false ? "enable" : "disable"),
t(p.enabled === false ? "manage.enabled" : "manage.disabled", { name: p.key })
);
const update = () =>
void run(
p.key,
// reload=true: updating one plugin reloads the open sessions
// right away. The batch reloads once at the end instead.
() => api.ccConfig.pluginAction(p.key, "update", true),
t("manage.updated", { name: p.key })
);
const uninstall = () => {
if (!confirmUninstall) {
setConfirmUninstall(true);
return;
}
setConfirmUninstall(false);
void run(
p.key,
() => api.ccConfig.pluginAction(p.key, "uninstall"),
t("manage.uninstalled", { name: p.key })
);
};
const meta = p.contributes?.pluginJson;
const description = meta?.description;
const contribCounts: { key: string; count: number; label: string; items?: CcPluginItem[] }[] = [];
@@ -1718,7 +1774,55 @@ function PluginCard({
}
const opened = contribCounts.find((c) => c.key === openSurface && c.items?.length);
return (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div
data-testid={`plugin-card-${p.key}`}
onContextMenu={(e) => ctx.open(e, true)}
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
>
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="plugin-menu">
<MenuItem
disabled={busy === p.key}
onSelect={() => {
ctx.close();
toggle();
}}
>
{p.enabled === false ? t("manage.enable") : t("manage.disable")}
</MenuItem>
<MenuItem
disabled={busy === p.key}
onSelect={() => {
ctx.close();
update();
}}
>
{t("manage.update")}
</MenuItem>
<MenuItem
onSelect={() => {
ctx.close();
void navigator.clipboard.writeText(p.key);
}}
>
{t("common.copyName")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
disabled={busy === p.key}
testId="plugin-menu-uninstall"
// Two clicks here too: the first arms (the card's button shows
// it), only the second uninstalls.
onSelect={() => {
if (confirmUninstall) ctx.close();
uninstall();
}}
>
{confirmUninstall ? t("manage.confirmUninstall") : t("manage.uninstall")}
</MenuItem>
</ContextMenu>
)}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
@@ -1867,66 +1971,18 @@ function PluginCard({
)}
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
{p.enabled === false ? (
<ActionButton
icon={CircleDot}
disabled={busy === p.key}
onClick={() =>
void run(
p.key,
() => api.ccConfig.pluginAction(p.key, "enable"),
t("manage.enabled", { name: p.key })
)
}
>
<ActionButton icon={CircleDot} disabled={busy === p.key} onClick={toggle}>
{t("manage.enable")}
</ActionButton>
) : (
<ActionButton
icon={CircleSlash}
disabled={busy === p.key}
onClick={() =>
void run(
p.key,
() => api.ccConfig.pluginAction(p.key, "disable"),
t("manage.disabled", { name: p.key })
)
}
>
<ActionButton icon={CircleSlash} disabled={busy === p.key} onClick={toggle}>
{t("manage.disable")}
</ActionButton>
)}
<ActionButton
icon={RefreshCw}
disabled={busy === p.key}
onClick={() =>
void run(
p.key,
// reload=true: updating one plugin reloads the open sessions
// right away. The batch reloads once at the end instead.
() => api.ccConfig.pluginAction(p.key, "update", true),
t("manage.updated", { name: p.key })
)
}
>
<ActionButton icon={RefreshCw} disabled={busy === p.key} onClick={update}>
{t("manage.update")}
</ActionButton>
<ActionButton
icon={Trash2}
danger
disabled={busy === p.key}
onClick={() => {
if (!confirmUninstall) {
setConfirmUninstall(true);
return;
}
setConfirmUninstall(false);
void run(
p.key,
() => api.ccConfig.pluginAction(p.key, "uninstall"),
t("manage.uninstalled", { name: p.key })
);
}}
>
<ActionButton icon={Trash2} danger disabled={busy === p.key} onClick={uninstall}>
{busy === p.key
? t("manage.working")
: confirmUninstall
@@ -2139,14 +2195,58 @@ function McpCard({
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
const [confirmRemove, setConfirmRemove] = useState(false);
const ctx = useContextMenu<true>();
const busyId = `${scope}:${server.name}`;
// The reader decides this: entries from ~/.claude/settings.json have no CLI
// scope that addresses them, so a Remove button would look live and never
// work. Reading it off a flag rather than matching `source` keeps a reworded
// display string from silently bringing that button back.
const removable = server.removable !== false;
const remove = () => {
if (!confirmRemove) {
setConfirmRemove(true);
return;
}
setConfirmRemove(false);
void run(
busyId,
() => api.ccConfig.mcpRemove(server.name, scope),
t("manage.mcpRemoved", { name: server.name })
);
};
return (
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div
onContextMenu={(e) => ctx.open(e, true)}
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
>
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="mcp-menu">
<MenuItem
onSelect={() => {
ctx.close();
void navigator.clipboard.writeText(server.name);
}}
>
{t("common.copyName")}
</MenuItem>
{removable && (
<>
<MenuSeparator />
<MenuItem
danger
disabled={busy === busyId}
// Two clicks, like the card's Remove button.
onSelect={() => {
if (confirmRemove) ctx.close();
remove();
}}
>
{confirmRemove ? t("manage.confirmRemove") : t("manage.remove")}
</MenuItem>
</>
)}
</ContextMenu>
)}
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm text-fg-primary">{server.name}</span>
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
@@ -2187,23 +2287,7 @@ function McpCard({
</div>
<div className="mt-3">
{removable ? (
<ActionButton
icon={Trash2}
danger
disabled={busy === busyId}
onClick={() => {
if (!confirmRemove) {
setConfirmRemove(true);
return;
}
setConfirmRemove(false);
void run(
busyId,
() => api.ccConfig.mcpRemove(server.name, scope),
t("manage.mcpRemoved", { name: server.name })
);
}}
>
<ActionButton icon={Trash2} danger disabled={busy === busyId} onClick={remove}>
{busy === busyId
? t("manage.working")
: confirmRemove
@@ -3270,6 +3354,7 @@ function MemoryPanel({
onCreateAuto,
}: MemoryPanelProps) {
const { t } = useTranslation("ccConfig");
const ctx = useContextMenu<CcMemoryItem & { scope: "user" | "project" }>();
const q = search.trim().toLowerCase();
@@ -3328,8 +3413,29 @@ function MemoryPanel({
return (
<div className="space-y-3">
{/* Primary CLAUDE.md memory (user + project) — editable */}
{ctx.menu && (
<FileItemMenu
x={ctx.menu.x}
y={ctx.menu.y}
onClose={ctx.close}
path={ctx.menu.item.file}
onOpen={() => onOpen(ctx.menu!.item.file)}
onEdit={() => {
const m = ctx.menu!.item;
onEdit("memory", { scope: m.scope, name: "", filePath: m.file });
}}
onDelete={() => {
const m = ctx.menu!.item;
onDelete("memory", m.scope, undefined, m.file);
}}
/>
)}
{primary.map((m) => (
<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">
@@ -3461,6 +3567,7 @@ function MemoryProjectGroup({
const indexFiles = files.filter((f) => f.isIndex);
const factFiles = files.filter((f) => !f.isIndex);
const ctx = useContextMenu<CcMemoryItem>();
// Wiring for "click an index entry → jump to its fact file". Fact rows
// register their DOM node keyed by filename; the index links look them up.
@@ -3497,6 +3604,17 @@ function MemoryProjectGroup({
return (
<div className="rounded-lg border border-border bg-surface-2 overflow-hidden">
{ctx.menu && (
<FileItemMenu
x={ctx.menu.x}
y={ctx.menu.y}
onClose={ctx.close}
path={ctx.menu.item.file}
onOpen={() => onOpen(ctx.menu!.item.file)}
onEdit={() => onEditAuto(ctx.menu!.item)}
onDelete={() => onDeleteAuto(ctx.menu!.item)}
/>
)}
<div className="flex items-center gap-1 pr-2 hover:bg-surface-3 transition-colors">
<button
onClick={() => setOpen((v) => !v)}
@@ -3541,6 +3659,7 @@ function MemoryProjectGroup({
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
onJump={handleJump}
onMenu={ctx.open}
/>
))}
</div>
@@ -3561,6 +3680,7 @@ function MemoryProjectGroup({
onOpen={onOpen}
onEditAuto={onEditAuto}
onDeleteAuto={onDeleteAuto}
onMenu={ctx.open}
highlighted={highlighted === key}
rowRef={(el) => {
if (el) rowRefs.current.set(key, el);
@@ -3621,10 +3741,17 @@ function MemoryIndexCard({
onEditAuto,
onDeleteAuto,
onJump,
}: MemoryAutoItemProps & { onJump: (target: string) => void }) {
onMenu,
}: MemoryAutoItemProps & {
onJump: (target: string) => void;
onMenu: (e: React.MouseEvent, item: CcMemoryItem) => void;
}) {
const { t } = useTranslation("ccConfig");
return (
<div className="rounded-md border border-teal-500/20 bg-teal-500/5">
<div
onContextMenu={(e) => onMenu(e, item)}
className="rounded-md border border-teal-500/20 bg-teal-500/5"
>
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-teal-500/15">
<BookOpen className="w-3 h-3 text-teal-700 dark:text-teal-300 flex-shrink-0" />
<span className="font-mono text-[11px] text-fg-secondary truncate flex-1 min-w-0">
@@ -3651,9 +3778,11 @@ function MemoryFactRow({
onOpen,
onEditAuto,
onDeleteAuto,
onMenu,
highlighted,
rowRef,
}: MemoryAutoItemProps & {
onMenu: (e: React.MouseEvent, item: CcMemoryItem) => void;
highlighted?: boolean;
rowRef?: (el: HTMLDivElement | null) => void;
}) {
@@ -3661,6 +3790,7 @@ function MemoryFactRow({
return (
<div
ref={rowRef}
onContextMenu={(e) => onMenu(e, item)}
className={`flex items-start gap-2.5 rounded-md border px-3 py-2 transition-colors ${
highlighted
? "border-teal-400/70 bg-teal-500/10 ring-1 ring-teal-400/50"
@@ -3706,6 +3836,25 @@ function MarketplacesPanel({
const { busy, run } = useConfigAction(onToast, onChanged);
const [source, setSource] = useState("");
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
const ctx = useContextMenu<CcMarketplacesResponse["items"][number]>();
const update = (name: string) =>
void run(
name,
() => api.ccConfig.marketplaceAction(name, "update"),
t("manage.marketplaceUpdated", { name })
);
const remove = (name: string) => {
if (confirmRemove !== name) {
setConfirmRemove(name);
return;
}
setConfirmRemove(null);
void run(
name,
() => api.ccConfig.marketplaceAction(name, "remove"),
t("manage.marketplaceRemoved", { name })
);
};
const add = useCallback(async () => {
const value = source.trim();
@@ -3772,7 +3921,11 @@ function MarketplacesPanel({
</div>
) : (
filtered.map((m) => (
<div key={m.name} className="rounded-lg border border-border bg-surface-2 px-4 py-3">
<div
key={m.name}
onContextMenu={(e) => ctx.open(e, m)}
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
>
<div className="flex items-center gap-2 flex-wrap">
<Store className="w-3.5 h-3.5 text-fg-muted" />
<span className="font-mono text-sm text-fg-primary">{m.name}</span>
@@ -3828,13 +3981,7 @@ function MarketplacesPanel({
<ActionButton
icon={RefreshCw}
disabled={busy === m.name}
onClick={() =>
void run(
m.name,
() => api.ccConfig.marketplaceAction(m.name, "update"),
t("manage.marketplaceUpdated", { name: m.name })
)
}
onClick={() => update(m.name)}
>
{t("manage.update")}
</ActionButton>
@@ -3842,18 +3989,7 @@ function MarketplacesPanel({
icon={Trash2}
danger
disabled={busy === m.name}
onClick={() => {
if (confirmRemove !== m.name) {
setConfirmRemove(m.name);
return;
}
setConfirmRemove(null);
void run(
m.name,
() => api.ccConfig.marketplaceAction(m.name, "remove"),
t("manage.marketplaceRemoved", { name: m.name })
);
}}
onClick={() => remove(m.name)}
>
{busy === m.name
? t("manage.working")
@@ -3865,6 +4001,49 @@ function MarketplacesPanel({
</div>
))
)}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="marketplace-menu">
<MenuItem
disabled={busy === ctx.menu.item.name}
onSelect={() => {
ctx.close();
update(ctx.menu!.item.name);
}}
>
{t("manage.update")}
</MenuItem>
<MenuItem
onSelect={() => {
ctx.close();
void navigator.clipboard.writeText(ctx.menu!.item.name);
}}
>
{t("common.copyName")}
</MenuItem>
{ctx.menu.item.installLocation && (
<MenuItem
onSelect={() => {
ctx.close();
void navigator.clipboard.writeText(ctx.menu!.item.installLocation!);
}}
>
{t("common.copyPath")}
</MenuItem>
)}
<MenuSeparator />
<MenuItem
danger
disabled={busy === ctx.menu.item.name}
// Two clicks, like the card's Remove button.
onSelect={() => {
if (confirmRemove === ctx.menu!.item.name) ctx.close();
remove(ctx.menu!.item.name);
}}
>
{confirmRemove === ctx.menu.item.name ? t("manage.confirmRemove") : t("manage.remove")}
</MenuItem>
</ContextMenu>
)}
</div>
);
}
@@ -4254,6 +4433,7 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
/** Names already auto-fetched, so the effect below fires once per profile. */
const autoFetched = useRef<Set<string>>(new Set());
const ctx = useContextMenu<CcProfile>();
const load = useCallback(async () => {
try {
@@ -4272,11 +4452,12 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
// An open right-click menu takes this Escape for itself.
if (e.key === "Escape" && !ctx.menu) onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
}, [onClose, ctx.menu]);
/** Run one profile mutation, then refresh the list. */
const run = useCallback(
@@ -4339,6 +4520,31 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
/** How many saved profiles hold the same account as `p`. */
const copies = (p: CcProfile) =>
items?.filter((q) => q.accountUuid && q.accountUuid === p.accountUuid).length ?? 1;
/** Deletable unless it is the ONLY saved copy of the live account — a
* redundant copy has to be removable, or an accidental duplicate could
* never be cleaned up. */
const deletable = (p: CcProfile) => !p.active || copies(p) > 1;
const startRename = (p: CcProfile) => {
setDraft(p.label);
setRenaming(p.name);
};
const switchTo = (p: CcProfile) =>
void run(
`switch:${p.name}`,
() => api.ccConfig.activateProfile(p.name),
t("profiles.switchSuccess", { name: p.label })
);
const remove = (p: CcProfile) =>
void run(
`del:${p.name}`,
() => api.ccConfig.deleteProfile(p.name),
t("profiles.deleteSuccess", { name: p.label })
);
const pick = (fn: (p: CcProfile) => void) => () => {
const p = ctx.menu!.item;
ctx.close();
fn(p);
};
return (
<div
@@ -4415,7 +4621,12 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
</div>
)}
{items?.map((p) => (
<div key={p.name} className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
<div
key={p.name}
data-testid={`profile-row-${p.name}`}
onContextMenu={(e) => ctx.open(e, p)}
className="rounded-lg border border-border bg-surface-2 px-3 py-2.5"
>
<div className="flex items-center gap-2 flex-wrap">
{renaming === p.name ? (
<input
@@ -4441,10 +4652,7 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
/>
) : (
<button
onClick={() => {
setDraft(p.label);
setRenaming(p.name);
}}
onClick={() => startRename(p)}
title={t("profiles.renameHint")}
className="text-xs font-medium text-fg-primary hover:text-accent inline-flex items-center gap-1 group"
>
@@ -4463,13 +4671,7 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
{!p.active && (
<button
disabled={busy !== null}
onClick={() =>
void run(
`switch:${p.name}`,
() => api.ccConfig.activateProfile(p.name),
t("profiles.switchSuccess", { name: p.label })
)
}
onClick={() => switchTo(p)}
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent disabled:opacity-50"
>
{busy === `switch:${p.name}`
@@ -4477,19 +4679,10 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
: t("profiles.switchButton")}
</button>
)}
{/* Deletable unless it is the ONLY saved copy of the live
account — a redundant copy has to be removable, or an
accidental duplicate could never be cleaned up. */}
{(!p.active || copies(p) > 1) && (
{deletable(p) && (
<button
disabled={busy !== null}
onClick={() =>
void run(
`del:${p.name}`,
() => api.ccConfig.deleteProfile(p.name),
t("profiles.deleteSuccess", { name: p.label })
)
}
onClick={() => remove(p)}
title={t("profiles.deleteButton")}
aria-label={t("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
@@ -4514,6 +4707,36 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
</div>
))}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
<MenuItem onSelect={pick(startRename)}>{t("profiles.rename")}</MenuItem>
{!ctx.menu.item.active && (
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
{t("profiles.switchButton")}
</MenuItem>
)}
<MenuItem
disabled={busy !== null || !!usageBusy[ctx.menu.item.name]}
onSelect={pick((p) => void checkUsage(p.name))}
>
{t("profiles.usageCheck")}
</MenuItem>
{deletable(ctx.menu.item) && (
<>
<MenuSeparator />
<MenuItem
danger
disabled={busy !== null}
testId="profile-menu-delete"
onSelect={pick(remove)}
>
{t("profiles.deleteButton")}
</MenuItem>
</>
)}
</ContextMenu>
)}
<div>
<div className="text-[10px] text-fg-muted mb-1">{t("profiles.loginHint")}</div>
<CommandSnippet command="claude auth login" />