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:
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file ActivityFeed.tsx
|
||||
* @description Real-time feed of agent events with server-driven filters and
|
||||
* a right-click session menu per row (`SessionRowMenu`),
|
||||
* batched pagination. Clicking a row toggles the inline EventDetail payload
|
||||
* view; the "View session" Link navigates to the session page. Live events
|
||||
* trigger a debounced, filter-aware refetch that preserves the user's
|
||||
@@ -71,6 +72,8 @@ import { useDataScope } from "../lib/dataScope";
|
||||
import { AgentStatusBadge } from "../components/StatusBadge";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { EventDetail } from "../components/EventDetail";
|
||||
import { useContextMenu } from "../components/ContextMenu";
|
||||
import { SessionRowMenu } from "../components/SessionRowMenu";
|
||||
import {
|
||||
EventFilters,
|
||||
EMPTY_FILTERS,
|
||||
@@ -101,6 +104,7 @@ const MAX_REFRESH = 500;
|
||||
const REFRESH_DEBOUNCE_MS = 500;
|
||||
|
||||
export function ActivityFeed() {
|
||||
const eventCtx = useContextMenu<string>();
|
||||
const { t } = useTranslation("activity");
|
||||
const [events, setEvents] = useState<DashboardEvent[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -318,6 +322,14 @@ export function ActivityFeed() {
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{eventCtx.menu && (
|
||||
<SessionRowMenu
|
||||
session={{ id: eventCtx.menu.item }}
|
||||
x={eventCtx.menu.x}
|
||||
y={eventCtx.menu.y}
|
||||
onClose={eventCtx.close}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
@@ -423,6 +435,7 @@ export function ActivityFeed() {
|
||||
}
|
||||
}}
|
||||
aria-expanded={isOpen}
|
||||
onContextMenu={(e) => eventCtx.open(e, event.session_id)}
|
||||
className="flex items-center px-5 py-3.5 gap-4 hover:bg-surface-4 transition-colors cursor-pointer select-none"
|
||||
>
|
||||
<ChevronRight
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
* An API key is write-only here by design — the server never returns one, so
|
||||
* the form shows whether a key is saved, never the key.
|
||||
*
|
||||
* A right-click on a profile row opens its Edit / Remove (plus Copy endpoint)
|
||||
* as a menu; the buttons stay.
|
||||
*
|
||||
* **Test** asks the endpoint itself before the profile is saved onto a lane.
|
||||
* "Saved" and "works" are different claims, and the gap between them is only
|
||||
* ever discovered inside a lane's session otherwise, where the error surfaces
|
||||
@@ -26,6 +29,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Pencil, Trash2, Lock } from "lucide-react";
|
||||
import { api, type AiRouter, type AiRouterInput, type AiRouterModels } from "../lib/api";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||
|
||||
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||
|
||||
@@ -55,6 +59,13 @@ export function AiRouterConfig() {
|
||||
// is a claim about a configuration that no longer exists.
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const ctx = useContextMenu<AiRouter>();
|
||||
// A menu item: close the menu, then act on the row it was opened on.
|
||||
const pick = (fn: (item: AiRouter) => unknown) => () => {
|
||||
const item = ctx.menu?.item;
|
||||
ctx.close();
|
||||
if (item) fn(item);
|
||||
};
|
||||
// Distinct from "no profiles": an empty table rendered before the first fetch
|
||||
// resolves tells the user their profiles are gone. CodexConfig draws the same
|
||||
// distinction for the same reason.
|
||||
@@ -212,7 +223,11 @@ export function AiRouterConfig() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.name} className="border-t border-border">
|
||||
<tr
|
||||
key={item.name}
|
||||
onContextMenu={item.builtin ? undefined : (e) => ctx.open(e, item)}
|
||||
className="border-t border-border"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex items-center gap-1.5 font-medium text-fg-primary">
|
||||
{item.builtin && <Lock className="h-3 w-3 text-fg-muted" />}
|
||||
@@ -269,6 +284,20 @@ export function AiRouterConfig() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-router-menu">
|
||||
<MenuItem onSelect={pick(startEdit)}>{t("aiRouter.edit")}</MenuItem>
|
||||
{ctx.menu.item.baseUrl && (
|
||||
<MenuItem onSelect={pick((item) => void navigator.clipboard.writeText(item.baseUrl))}>
|
||||
{t("rowMenu.copyUrl")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pick((item) => void remove(item))}>
|
||||
{t("aiRouter.remove")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div
|
||||
|
||||
+354
-131
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Dashboard.tsx
|
||||
* @description Main dashboard page showing real-time stats, active agents, recent activity feed, an Analytics tab, and a System Health tab for Claude Code sessions.
|
||||
* @description Main dashboard page showing real-time stats, active agents, recent activity feed, an Analytics tab, and a System Health tab for Claude Code sessions; a recent event's right-click opens its session's `SessionRowMenu`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -96,6 +96,8 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { useDataScope } from "../lib/dataScope";
|
||||
import { StatCard } from "../components/StatCard";
|
||||
import { AgentCard } from "../components/AgentCard";
|
||||
import { useContextMenu } from "../components/ContextMenu";
|
||||
import { SessionRowMenu } from "../components/SessionRowMenu";
|
||||
import { AgentStatusBadge } from "../components/StatusBadge";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { Tip } from "../components/Tip";
|
||||
@@ -2358,6 +2360,7 @@ function SystemHealthTab() {
|
||||
export function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation("dashboard");
|
||||
const eventCtx = useContextMenu<string>();
|
||||
|
||||
// Persistent Tab State
|
||||
const [tabParams] = useSearchParams();
|
||||
@@ -2865,11 +2868,20 @@ export function Dashboard() {
|
||||
/>
|
||||
) : (
|
||||
<div className="card divide-y divide-border">
|
||||
{eventCtx.menu && (
|
||||
<SessionRowMenu
|
||||
session={{ id: eventCtx.menu.item }}
|
||||
x={eventCtx.menu.x}
|
||||
y={eventCtx.menu.y}
|
||||
onClose={eventCtx.close}
|
||||
/>
|
||||
)}
|
||||
{recentEvents.slice(0, visibleActivityCount).map((event, i) => (
|
||||
<div
|
||||
key={event.id ?? i}
|
||||
className="px-4 py-3 flex items-center gap-3 hover:bg-surface-4 transition-colors cursor-pointer"
|
||||
onClick={() => navigate(`/sessions/${event.session_id}`)}
|
||||
onContextMenu={(e) => eventCtx.open(e, event.session_id)}
|
||||
>
|
||||
<AgentStatusBadge
|
||||
status={
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
* host, shared by everyone using this install. Lists them, replaces a token,
|
||||
* adds one, deletes one. A token is write-only here: the server never returns
|
||||
* one, so the form starts blank and the list shows only that one is saved.
|
||||
* The git bar's login form saves into the same store.
|
||||
* The git bar's login form saves into the same store. A right-click on a row
|
||||
* opens the same Replace / Remove (plus Copy host) as a menu.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { api, type GitCredential } from "../lib/api";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||
|
||||
const INPUT =
|
||||
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
|
||||
@@ -21,6 +23,13 @@ export function GitCredentialsConfig() {
|
||||
const [items, setItems] = useState<GitCredential[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<{ url: string; username: string; token: string } | null>(null);
|
||||
const ctx = useContextMenu<GitCredential>();
|
||||
// A menu item: close the menu, then act on the row it was opened on.
|
||||
const pick = (fn: (c: GitCredential) => unknown) => () => {
|
||||
const c = ctx.menu?.item;
|
||||
ctx.close();
|
||||
if (c) fn(c);
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -57,6 +66,9 @@ export function GitCredentialsConfig() {
|
||||
}
|
||||
};
|
||||
|
||||
const replace = (c: GitCredential) =>
|
||||
setForm({ url: `${c.protocol}://${c.host}`, username: c.username, token: "" });
|
||||
|
||||
const remove = async (c: GitCredential) => {
|
||||
if (!window.confirm(t("gitCreds.removeConfirm"))) return;
|
||||
try {
|
||||
@@ -150,7 +162,11 @@ export function GitCredentialsConfig() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((c) => (
|
||||
<tr key={`${c.protocol}://${c.host}`} className="border-t border-border">
|
||||
<tr
|
||||
key={`${c.protocol}://${c.host}`}
|
||||
onContextMenu={(e) => ctx.open(e, c)}
|
||||
className="border-t border-border"
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-[11px] text-fg-primary">{c.host}</td>
|
||||
<td className="px-3 py-2 text-fg-secondary">{c.username}</td>
|
||||
<td className="px-3 py-2 text-fg-secondary">
|
||||
@@ -162,13 +178,7 @@ export function GitCredentialsConfig() {
|
||||
type="button"
|
||||
aria-label={`${t("gitCreds.replace")} ${c.host}`}
|
||||
title={t("gitCreds.replace")}
|
||||
onClick={() =>
|
||||
setForm({
|
||||
url: `${c.protocol}://${c.host}`,
|
||||
username: c.username,
|
||||
token: "",
|
||||
})
|
||||
}
|
||||
onClick={() => replace(c)}
|
||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
@@ -196,6 +206,18 @@ export function GitCredentialsConfig() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="git-cred-menu">
|
||||
<MenuItem onSelect={pick(replace)}>{t("gitCreds.replace")}</MenuItem>
|
||||
<MenuItem onSelect={pick((c) => void navigator.clipboard.writeText(c.host))}>
|
||||
{t("rowMenu.copyHost")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pick((c) => void remove(c))}>
|
||||
{t("gitCreds.remove")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,6 +185,7 @@ export function SessionDetail() {
|
||||
const eventApiParamsRef = useRef<Record<string, unknown> | null>(null);
|
||||
const eventsLoadedCountRef = useRef(0);
|
||||
const eventsRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const loadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
eventsLoadedCountRef.current = events.length;
|
||||
const goBack = useCallback(() => {
|
||||
const historyState =
|
||||
@@ -240,7 +241,9 @@ export function SessionDetail() {
|
||||
if (!id) return;
|
||||
try {
|
||||
const [data, costData] = await Promise.all([
|
||||
api.sessions.get(id),
|
||||
// The page reads its timeline through the paginated events call; the
|
||||
// whole-session list (MBs on a long session) is not shipped here.
|
||||
api.sessions.get(id, { events: false }),
|
||||
api.pricing.sessionCost(id).catch(() => null),
|
||||
]);
|
||||
setSession(data.session);
|
||||
@@ -505,12 +508,21 @@ export function SessionDetail() {
|
||||
}, EVENTS_REFRESH_DEBOUNCE_MS);
|
||||
return;
|
||||
}
|
||||
// Only this session's messages: every hook of every session broadcasts,
|
||||
// and a reload per message re-read this page's whole session each time.
|
||||
const d = msg.data as { id?: string; session_id?: string } | undefined;
|
||||
const mine = d?.session_id === id || (msg.type === "session_updated" && d?.id === id);
|
||||
if (!mine) return;
|
||||
if (
|
||||
msg.type === "agent_created" ||
|
||||
msg.type === "agent_updated" ||
|
||||
msg.type === "session_updated"
|
||||
) {
|
||||
load();
|
||||
if (loadTimerRef.current) clearTimeout(loadTimerRef.current);
|
||||
loadTimerRef.current = setTimeout(() => {
|
||||
loadTimerRef.current = null;
|
||||
load();
|
||||
}, EVENTS_REFRESH_DEBOUNCE_MS);
|
||||
}
|
||||
if (msg.type === "new_event") {
|
||||
// Debounce bursts into one filter-aware refetch that preserves the
|
||||
@@ -524,12 +536,13 @@ export function SessionDetail() {
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (loadTimerRef.current) clearTimeout(loadTimerRef.current);
|
||||
if (eventsRefreshTimerRef.current) {
|
||||
clearTimeout(eventsRefreshTimerRef.current);
|
||||
eventsRefreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [load, refreshEventsWithPagination]);
|
||||
}, [id, load, refreshEventsWithPagination]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Sessions.tsx
|
||||
* @description Displays a list of all recorded sessions with filtering, searching, and pagination features. Sessions are updated in real-time based on events received from the event bus.
|
||||
* @description Displays a list of all recorded sessions with filtering, searching, and pagination features; a row's right-click opens `SessionRowMenu`. Sessions are updated in real-time based on events received from the event bus.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -75,6 +75,8 @@ import { SessionStatusBadge } from "../components/StatusBadge";
|
||||
import { SessionToolBadge } from "../components/SessionToolBadge";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { TableRowSkeleton } from "../components/Skeleton";
|
||||
import { useContextMenu } from "../components/ContextMenu";
|
||||
import { SessionRowMenu } from "../components/SessionRowMenu";
|
||||
import { formatDateTime, formatDuration, truncate, fmtCost } from "../lib/format";
|
||||
import {
|
||||
effectiveSessionStatus,
|
||||
@@ -87,6 +89,7 @@ const PAGE_SIZE = 10;
|
||||
|
||||
export function Sessions() {
|
||||
const navigate = useNavigate();
|
||||
const rowCtx = useContextMenu<Session>();
|
||||
const { t } = useTranslation("sessions");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -271,6 +274,14 @@ export function Sessions() {
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{rowCtx.menu && (
|
||||
<SessionRowMenu
|
||||
session={rowCtx.menu.item}
|
||||
x={rowCtx.menu.x}
|
||||
y={rowCtx.menu.y}
|
||||
onClose={rowCtx.close}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
@@ -427,6 +438,7 @@ export function Sessions() {
|
||||
<tr
|
||||
key={session.id}
|
||||
onClick={() => navigate(`/sessions/${session.id}`)}
|
||||
onContextMenu={(e) => rowCtx.open(e, session)}
|
||||
className="hover:bg-surface-4 transition-colors cursor-pointer group"
|
||||
>
|
||||
<td className="px-5 py-4">
|
||||
|
||||
@@ -62,7 +62,7 @@ import { keepIfSame } from "../lib/keepIfSame";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
import { ConsoleTabs } from "../components/run/ConsoleTabs";
|
||||
import { type LayoutUpdate, PaneGrid, usePaneLayout } from "../components/run/PaneGrid";
|
||||
import { type LayoutUpdate, PaneGrid, SplitMenus, usePaneLayout } from "../components/run/PaneGrid";
|
||||
import {
|
||||
type Edge,
|
||||
type PaneLayout,
|
||||
@@ -298,7 +298,7 @@ function ConsoleArea({
|
||||
onRunsChanged();
|
||||
if (!r.run_id || laneRef.current !== laneId) return;
|
||||
pending.current.set(slot, activeRuns);
|
||||
change((l) => moveInLayout(l, slot, target, edge));
|
||||
change((l) => moveInLayout(l.root ? l : view, slot, target, edge));
|
||||
} catch (err) {
|
||||
setSplitError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -327,8 +327,25 @@ function ConsoleArea({
|
||||
}}
|
||||
onClosed={(runId) => ended(slotOf(runId))}
|
||||
onHome={onNewSession}
|
||||
// Per-workspace actions (mcp sync, sync-base check, readme).
|
||||
actions={<LaneToolsMenu lane={lane} />}
|
||||
// Per-workspace actions (mcp sync, sync-base check, readme), and
|
||||
// while one pane is shown its split menus (a split pane has its own).
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
{shown.length === 1 && shown[0] !== undefined && (
|
||||
<SplitMenus
|
||||
slot={shown[0]}
|
||||
shown={shown}
|
||||
runs={tabs}
|
||||
label={label}
|
||||
// A layout never saved is the one-pane `view`: split that.
|
||||
onChange={(fn) => change((l) => fn(l.root ? l : view))}
|
||||
canStart={free !== null}
|
||||
onNewSplit={(target, edge) => void newSplit(target, edge)}
|
||||
/>
|
||||
)}
|
||||
<LaneToolsMenu lane={lane} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{splitError && (
|
||||
|
||||
@@ -233,4 +233,25 @@ describe("AiRouterConfig — testing an endpoint before saving it", () => {
|
||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "2");
|
||||
await waitFor(() => expect(screen.queryByTestId("router-test-result")).toBeNull());
|
||||
});
|
||||
|
||||
it("opens a profile's Edit and Remove from a right-click; the built-in row has no menu", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValueOnce(false);
|
||||
const user = userEvent.setup();
|
||||
render(<AiRouterConfig />);
|
||||
const row = (await screen.findByText("9Router")).closest("tr")!;
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByText(native.label) });
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
await user.click(screen.getByRole("menuitem", { name: "aiRouter.remove" }));
|
||||
expect(confirm).toHaveBeenCalledWith("aiRouter.removeConfirm:9Router");
|
||||
expect(api.aiRouters.remove).not.toHaveBeenCalled();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
await user.click(screen.getByRole("menuitem", { name: "aiRouter.edit" }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
confirm.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @file Covers the right-click menus on the Claude Code config page: a menu
|
||||
* item runs the SAME action as the card's button — a plugin's uninstall still
|
||||
* takes two clicks before it reaches the API, and a profile's delete calls the
|
||||
* same endpoint the trash button does.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const ccConfigMock = vi.hoisted(() => {
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
return {
|
||||
overview: r({
|
||||
roots: {
|
||||
claudeHome: "/home/.claude",
|
||||
projectClaudeDir: "/repo/.claude",
|
||||
projectRoot: "/repo",
|
||||
},
|
||||
counts: {
|
||||
skills: { user: 0, project: 0 },
|
||||
agents: { user: 0, project: 0 },
|
||||
commands: { user: 0, project: 0 },
|
||||
outputStyles: { user: 0, project: 0 },
|
||||
mcpServers: { user: 0, project: 0 },
|
||||
hooks: {},
|
||||
plugins: 1,
|
||||
marketplaces: 0,
|
||||
keybindings: 0,
|
||||
settingsFiles: 0,
|
||||
memory: 0,
|
||||
},
|
||||
}),
|
||||
skills: r({ items: [] }),
|
||||
agents: r({ items: [] }),
|
||||
commands: r({ items: [] }),
|
||||
outputStyles: r({ items: [] }),
|
||||
memory: r({ items: [] }),
|
||||
plugins: r({
|
||||
manifestPath: "/home/.claude/plugins/installed_plugins.json",
|
||||
manifestExists: true,
|
||||
plugins: [{ key: "ponytail@ponytail", name: "ponytail", enabled: true, version: "4.9.0" }],
|
||||
}),
|
||||
marketplaces: r({ items: [] }),
|
||||
mcp: r({ user: [], projectScoped: [] }),
|
||||
hooks: r({ hooks: [] }),
|
||||
settings: r({ sources: [] }),
|
||||
keybindings: r({ groups: [] }),
|
||||
statusline: r({}),
|
||||
hookScripts: r({ items: [] }),
|
||||
backups: r({ items: [] }),
|
||||
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
|
||||
pluginsAvailable: r({ installed: [], available: [] }),
|
||||
updateAllStatus: r({ batch: null }),
|
||||
pluginAction: r({ ok: true }),
|
||||
profiles: r({
|
||||
items: [
|
||||
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
|
||||
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
|
||||
],
|
||||
active: { accountUuid: "u1", email: "a@b.c" },
|
||||
}),
|
||||
profileUsage: r({ name: "x", refreshed: false, fiveHour: null, sevenDay: null }),
|
||||
deleteProfile: r({ ok: true }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
import { CcConfig } from "../CcConfig";
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("CcConfig — right-click menus", () => {
|
||||
it("uninstalls a plugin from its menu only on the second click, like the button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CcConfig />);
|
||||
await settle();
|
||||
await user.click(screen.getByRole("tab", { name: /plugins/i }));
|
||||
await settle();
|
||||
|
||||
await user.pointer({
|
||||
keys: "[MouseRight]",
|
||||
target: screen.getByTestId("plugin-card-ponytail@ponytail"),
|
||||
});
|
||||
const item = screen.getByTestId("plugin-menu-uninstall");
|
||||
await user.click(item);
|
||||
expect(ccConfigMock.pluginAction).not.toHaveBeenCalled();
|
||||
expect(item).toHaveTextContent(/click again/i);
|
||||
|
||||
await user.click(item);
|
||||
await settle();
|
||||
expect(ccConfigMock.pluginAction).toHaveBeenCalledWith("ponytail@ponytail", "uninstall");
|
||||
expect(screen.queryByTestId("plugin-menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes a saved profile from its menu through the same API as the trash button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CcConfig />);
|
||||
await settle();
|
||||
await user.click(screen.getByRole("button", { name: /profile/i }));
|
||||
await settle();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
|
||||
await user.click(screen.getByTestId("profile-menu-delete"));
|
||||
await settle();
|
||||
expect(ccConfigMock.deleteProfile).toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("offers no delete for the only saved copy of the live account", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CcConfig />);
|
||||
await settle();
|
||||
await user.click(screen.getByRole("button", { name: /profile/i }));
|
||||
await settle();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-work") });
|
||||
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file Tests for the Git credentials settings tab (issue #45): lists saved
|
||||
* logins without ever rendering a token, replaces the token for a host, adds
|
||||
* one from a URL, and deletes only after a confirm.
|
||||
* one from a URL, and deletes only after a confirm — from the button or the
|
||||
* row's right-click menu.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -98,4 +99,24 @@ describe("GitCredentialsConfig", () => {
|
||||
);
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
it("offers Replace and Remove on a right-click, Remove behind the same confirm", async () => {
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValueOnce(true);
|
||||
const user = userEvent.setup();
|
||||
render(<GitCredentialsConfig />);
|
||||
const row = (await screen.findByText("git.smartgift.io.vn")).closest("tr")!;
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
await user.click(screen.getByRole("menuitem", { name: "gitCreds.replace" }));
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
expect(screen.getByLabelText("gitCreds.fieldHost")).toHaveValue("https://git.smartgift.io.vn");
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
await user.click(screen.getByRole("menuitem", { name: "gitCreds.remove" }));
|
||||
expect(confirm).toHaveBeenCalledWith("gitCreds.removeConfirm");
|
||||
await waitFor(() =>
|
||||
expect(api.gitCredentials.remove).toHaveBeenCalledWith("https", "git.smartgift.io.vn")
|
||||
);
|
||||
confirm.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -763,6 +763,39 @@ describe("split panes", () => {
|
||||
expect(screen.getByTestId("pane-0").style.left).toBe("50%");
|
||||
});
|
||||
|
||||
it("right-click on a tab opens it in a split or closes it; on a pane header, swaps, splits or closes", async () => {
|
||||
withRuns([live(0), live(1, "shell"), live(2)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
const user = userEvent.setup();
|
||||
const right = (target: Element) => user.pointer({ keys: "[MouseRight]", target });
|
||||
|
||||
await right(screen.getByTestId("console-tab-1"));
|
||||
await user.click(within(screen.getByTestId("console-tab-menu-1")).getByText("Open in split"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
|
||||
await right(screen.getByTestId("pane-header-0"));
|
||||
await user.click(within(screen.getByTestId("pane-menu-0")).getByText(/Show Terminal here/));
|
||||
await settle();
|
||||
expect(screen.getByTestId("pane-1").style.left).toBe("0%");
|
||||
|
||||
await right(screen.getByTestId("pane-header-1"));
|
||||
await user.click(screen.getByTestId("pane-menu-split-bottom"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 3, shell: true });
|
||||
|
||||
await right(screen.getByTestId("pane-header-1"));
|
||||
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Close pane"));
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
expect(api.run.kill).not.toHaveBeenCalled();
|
||||
|
||||
await right(screen.getByTestId("console-tab-2"));
|
||||
await user.click(
|
||||
within(screen.getByTestId("console-tab-menu-2")).getByText("Close tab (stops its session)")
|
||||
);
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
|
||||
});
|
||||
|
||||
it("a split menu starts a new terminal in the first free slot", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
@@ -773,6 +806,23 @@ describe("split panes", () => {
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 2, shell: true });
|
||||
});
|
||||
|
||||
it("with one pane, the split menus sit beside Tools and split it", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
expect(shownPanes()).toEqual(["pane-0"]);
|
||||
expect(screen.queryByTestId("pane-header-0")).toBeNull();
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("pane-split-right-0"));
|
||||
await user.click(screen.getAllByRole("menuitem")[0] as HTMLElement);
|
||||
await settle();
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||
// Split: the tab bar's copy goes, each pane header has its own.
|
||||
expect(screen.getAllByTestId("pane-split-right-0")).toHaveLength(1);
|
||||
expect(screen.getByTestId("pane-header-0")).toContainElement(
|
||||
screen.getByTestId("pane-split-right-0")
|
||||
);
|
||||
});
|
||||
|
||||
it("a pane whose run exits closes, and the URL follows the pane that takes its place", async () => {
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
|
||||
Reference in New Issue
Block a user