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,6 @@
|
||||
/**
|
||||
* @file AgentCard.tsx
|
||||
* @description Defines the AgentCard component that displays a summary of an agent's information, including its name, status, task, current tool, and timestamps. The card is clickable and navigates to the agent's session details when clicked. It also visually distinguishes active agents with a border highlight.
|
||||
* @description Defines the AgentCard component that displays a summary of an agent's information, including its name, status, task, current tool, and timestamps. The card is clickable and navigates to the agent's session details when clicked; a right-click opens `SessionRowMenu`. It also visually distinguishes active agents with a border highlight.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -51,6 +51,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { Bot, GitBranch, Clock, Wrench, Cpu, Coins } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { useContextMenu } from "./ContextMenu";
|
||||
import { SessionRowMenu } from "./SessionRowMenu";
|
||||
import { effectiveAgentStatus, isAgentAwaitingInput, agentAwaitingReason } from "../lib/types";
|
||||
import type { Agent, Session } from "../lib/types";
|
||||
import { formatDuration, timeAgo, formatModelName, pathBasename, fmtCost } from "../lib/format";
|
||||
@@ -83,6 +85,7 @@ interface AgentCardProps {
|
||||
export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation("kanban");
|
||||
const ctx = useContextMenu<true>();
|
||||
const isWaiting = agent.status === "waiting" || isAgentAwaitingInput(agent);
|
||||
const status = effectiveAgentStatus(agent);
|
||||
const isActive = agent.status === "working";
|
||||
@@ -170,6 +173,7 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onContextMenu={(e) => ctx.open(e, true)}
|
||||
className={`card-hover p-4 cursor-pointer overflow-hidden ${
|
||||
isWaiting
|
||||
? "border-l-2 border-l-yellow-500/60"
|
||||
@@ -252,6 +256,14 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
<span className="font-mono flex-shrink-0">{agent.session_id.slice(0, 8)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{ctx.menu && (
|
||||
<SessionRowMenu
|
||||
session={{ id: agent.session_id, lane_id: session?.lane_id, cwd: session?.cwd }}
|
||||
x={ctx.menu.x}
|
||||
y={ctx.menu.y}
|
||||
onClose={ctx.close}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* • Activity - the live fired-alert feed with acknowledge controls
|
||||
* Tab badges reflect live state (rule count, unacked alert count), and the feed
|
||||
* + counts refetch on alert_triggered / alert_updated WebSocket messages.
|
||||
* A right-click on a rule opens its Enable/Disable and Delete as a menu.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -85,6 +86,7 @@ import { WebhookSettings } from "./WebhookSettings";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { FieldHelp } from "./FieldHelp";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
|
||||
|
||||
@@ -218,6 +220,13 @@ export function AlertsNotifications() {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
|
||||
const ruleCtx = useContextMenu<AlertRule>();
|
||||
// A rule-menu item: close the menu, then act on the rule it was opened on.
|
||||
const pickRule = (fn: (rule: AlertRule) => unknown) => () => {
|
||||
const rule = ruleCtx.menu?.item;
|
||||
ruleCtx.close();
|
||||
if (rule) fn(rule);
|
||||
};
|
||||
|
||||
// Feed
|
||||
const [alerts, setAlerts] = useState<AlertEvent[]>([]);
|
||||
@@ -670,6 +679,7 @@ export function AlertsNotifications() {
|
||||
{rules.map((rule) => (
|
||||
<li
|
||||
key={rule.id}
|
||||
onContextMenu={(e) => ruleCtx.open(e, rule)}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-surface-2 px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
@@ -713,6 +723,22 @@ export function AlertsNotifications() {
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{ruleCtx.menu && (
|
||||
<ContextMenu
|
||||
x={ruleCtx.menu.x}
|
||||
y={ruleCtx.menu.y}
|
||||
onClose={ruleCtx.close}
|
||||
testId="alert-rule-menu"
|
||||
>
|
||||
<MenuItem onSelect={pickRule(onToggleRule)}>
|
||||
{ruleCtx.menu.item.enabled ? t("rules.disable") : t("rules.enable")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pickRule(setConfirmRule)}>
|
||||
{t("rules.delete")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @file ContextMenu — the one shell every right-click (and `⋯`) menu opens
|
||||
* in: a fixed list at a viewport point, rendered into `document.body` so no
|
||||
* transformed or clipped ancestor moves it, nudged back inside the viewport
|
||||
* once measured, closed by Escape, a press outside it, or after an item runs.
|
||||
* `useContextMenu` keeps "which item, where" for a list of rows; `menuPoint`
|
||||
* turns a mouse event into that point (and stops the browser's own menu).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
|
||||
export type MenuPoint = { x: number; y: number };
|
||||
|
||||
/** The pointer of a right-click, the browser's own menu suppressed. */
|
||||
export function menuPoint(e: ReactMouseEvent): MenuPoint {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
/** Menu state for a list of rows: which row's menu is open, and where. */
|
||||
export function useContextMenu<T>() {
|
||||
const [menu, setMenu] = useState<(MenuPoint & { item: T }) | null>(null);
|
||||
const open = useCallback((e: ReactMouseEvent, item: T) => {
|
||||
setMenu({ ...menuPoint(e), item });
|
||||
}, []);
|
||||
const close = useCallback(() => setMenu(null), []);
|
||||
return { menu, open, close };
|
||||
}
|
||||
|
||||
const MARGIN = 8;
|
||||
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
|
||||
|
||||
export function ContextMenu({
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
testId,
|
||||
className = "w-56",
|
||||
children,
|
||||
}: MenuPoint & {
|
||||
onClose: () => void;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState({ left: x, top: y });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
setPos({
|
||||
left: Math.max(MARGIN, Math.min(x, window.innerWidth - r.width - MARGIN)),
|
||||
top: Math.max(MARGIN, Math.min(y, window.innerHeight - r.height - MARGIN)),
|
||||
});
|
||||
}, [x, y]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
window.addEventListener("blur", onClose);
|
||||
window.addEventListener("resize", onClose);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("blur", onClose);
|
||||
window.removeEventListener("resize", onClose);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
data-testid={testId}
|
||||
style={pos}
|
||||
// A portal still bubbles React events to the component that rendered
|
||||
// it — a menu opened from a clickable card must not click the card too.
|
||||
onClick={stop}
|
||||
onDoubleClick={stop}
|
||||
onMouseDown={stop}
|
||||
onPointerDown={stop}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={`fixed z-50 rounded-lg border border-border bg-surface-2 p-1 shadow-lg ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export const MENU_ITEM =
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary disabled:pointer-events-none disabled:opacity-50";
|
||||
|
||||
export function MenuItem({
|
||||
onSelect,
|
||||
danger,
|
||||
disabled,
|
||||
testId,
|
||||
children,
|
||||
}: {
|
||||
onSelect: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuSeparator() {
|
||||
return <div role="separator" className="my-1 border-t border-border" />;
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
* here — authentication defers to the host's SSH stack (~/.ssh/config, agent,
|
||||
* keys, known_hosts); a source is just a label + ssh destination (+ optional
|
||||
* port / identity file / remote home). Live status/sync updates arrive over the
|
||||
* `remote_source.status` WebSocket message.
|
||||
* `remote_source.status` WebSocket message. A right-click on a source opens
|
||||
* its Test / Sync / Enable-Disable / Edit / Delete (plus Copy host) as a menu.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -58,6 +59,7 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -135,6 +137,13 @@ export function RemoteSources() {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const ctx = useContextMenu<RemoteSource>();
|
||||
// A menu item: close the menu, then act on the source it was opened on.
|
||||
const pick = (fn: (s: RemoteSource) => unknown) => () => {
|
||||
const s = ctx.menu?.item;
|
||||
ctx.close();
|
||||
if (s) fn(s);
|
||||
};
|
||||
const [syncingAll, setSyncingAll] = useState(false);
|
||||
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; message: string }>>(
|
||||
{}
|
||||
@@ -598,7 +607,7 @@ export function RemoteSources() {
|
||||
const test = testResults[s.id];
|
||||
const busy = busyId === s.id;
|
||||
return (
|
||||
<div key={s.id} className="card p-4">
|
||||
<div key={s.id} onContextMenu={(e) => ctx.open(e, s)} className="card p-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -753,6 +762,34 @@ export function RemoteSources() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{ctx.menu && (
|
||||
<ContextMenu
|
||||
x={ctx.menu.x}
|
||||
y={ctx.menu.y}
|
||||
onClose={ctx.close}
|
||||
testId="remote-source-menu"
|
||||
>
|
||||
<MenuItem disabled={busyId === ctx.menu.item.id} onSelect={pick(testSource)}>
|
||||
{t("remoteSources.test", "Test")}
|
||||
</MenuItem>
|
||||
<MenuItem disabled={busyId === ctx.menu.item.id} onSelect={pick(syncNow)}>
|
||||
{t("remoteSources.syncNow", "Sync")}
|
||||
</MenuItem>
|
||||
<MenuItem disabled={busyId === ctx.menu.item.id} onSelect={pick(toggleEnabled)}>
|
||||
{ctx.menu.item.enabled
|
||||
? t("remoteSources.disable", "Disable")
|
||||
: t("remoteSources.enable", "Enable")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(openEdit)}>{t("common:edit", "Edit")}</MenuItem>
|
||||
<MenuItem onSelect={pick((s) => void navigator.clipboard.writeText(s.host))}>
|
||||
{t("rowMenu.copyHost")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pick((s) => setConfirmDelete({ id: s.id, purge: false }))}>
|
||||
{t("common:delete", "Delete")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file SessionRowMenu — the right-click menu of a session anywhere outside a
|
||||
* workspace's own lists (the Sessions table, the Dashboard's session and agent
|
||||
* cards): open its analysis page, open it in the workspace that owns it
|
||||
* (`sessions.lane_id` — an ended session opens its conversation there, never
|
||||
* a resume), copy its id or folder. Inside a workspace, `lanes/SessionMenu`
|
||||
* is used instead.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "./ContextMenu";
|
||||
|
||||
export type SessionRowRef = { id: string; lane_id?: number | null; cwd?: string | null };
|
||||
|
||||
export function SessionRowMenu({
|
||||
session,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
}: MenuPoint & { session: SessionRowRef; onClose: () => void }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const navigate = useNavigate();
|
||||
const pick = (fn: () => unknown) => () => {
|
||||
onClose();
|
||||
fn();
|
||||
};
|
||||
const copy = (text: string) => pick(() => void navigator.clipboard.writeText(text));
|
||||
|
||||
return (
|
||||
<ContextMenu x={x} y={y} onClose={onClose} testId={`session-row-menu-${session.id}`}>
|
||||
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
||||
{t("menu.details")}
|
||||
</MenuItem>
|
||||
{session.lane_id != null && (
|
||||
<MenuItem
|
||||
onSelect={pick(() =>
|
||||
navigate(`/workspace?lane=${session.lane_id}&session=${encodeURIComponent(session.id)}`)
|
||||
)}
|
||||
>
|
||||
{t("menu.inWorkspace")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={copy(session.id)}>{t("menu.copyId")}</MenuItem>
|
||||
{session.cwd && <MenuItem onSelect={copy(session.cwd)}>{t("menu.copyFolder")}</MenuItem>}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -5,17 +5,23 @@
|
||||
* — with a green dot while a newer release is out —, how many workspaces are running and how
|
||||
* many need the user (from the shared lane list, no request of its own), and
|
||||
* today's spend — the `daily_costs` bucket for the local date from
|
||||
* `GET /api/pricing/cost`, the Dashboard's own cost call, re-read every 30 s
|
||||
* while the tab is visible. No provider quota: reading Claude's quota rotates
|
||||
* `GET /api/pricing/cost`, the Dashboard's own cost call — and, on the
|
||||
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
||||
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
|
||||
* the URL's `session`, or the focused pane's session from the page's run poll,
|
||||
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
|
||||
* whenever the URL names another workspace or session. No provider quota: reading Claude's quota rotates
|
||||
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
||||
|
||||
const COST_POLL_MS = 30_000;
|
||||
|
||||
@@ -42,23 +48,55 @@ export function StatusBar({
|
||||
const running = lanes.filter((l) => l.status === "running").length;
|
||||
const needYou = lanes.filter((l) => l.needs_action).length;
|
||||
const [today, setToday] = useState<number | null>(null);
|
||||
const [laneCost, setLaneCost] = useState<number | null>(null);
|
||||
const [sessionCost, setSessionCost] = useState<number | null>(null);
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
const location = useLocation();
|
||||
const shown = selectedFrom(location.pathname, location.search);
|
||||
const lane = shown.lane;
|
||||
const sessionId =
|
||||
shown.session ?? (shown.run && runs.find((r) => r.id === shown.run)?.sessionId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const take = (e: Event) => setRuns((e as CustomEvent<RunHandle[]>).detail);
|
||||
window.addEventListener(LIVE_RUNS_EVENT, take);
|
||||
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
// Another workspace or session: never show the previous one's figure.
|
||||
setLaneCost(null);
|
||||
setSessionCost(null);
|
||||
const tick = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
api.pricing
|
||||
.totalCost()
|
||||
.then((r) => setToday(r.daily_costs.find((d) => d.date === localDate())?.cost ?? 0))
|
||||
.then((r) => live && setToday(r.daily_costs.find((d) => d.date === localDate())?.cost ?? 0))
|
||||
.catch(() => undefined);
|
||||
if (lane !== null) {
|
||||
api.pricing
|
||||
.totalCost({ lane })
|
||||
.then((r) => live && setLaneCost(r.total_cost))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if (sessionId) {
|
||||
api.pricing
|
||||
.sessionCost(sessionId)
|
||||
.then((r) => live && setSessionCost(r.total_cost))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, COST_POLL_MS);
|
||||
document.addEventListener("visibilitychange", tick);
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", tick);
|
||||
};
|
||||
}, []);
|
||||
}, [lane, sessionId]);
|
||||
const usd = (n: number) => `$${n.toFixed(2)}`;
|
||||
|
||||
return (
|
||||
<footer
|
||||
@@ -94,7 +132,17 @@ export function StatusBar({
|
||||
</span>
|
||||
{today !== null && (
|
||||
<span data-testid="status-bar-cost" className="tabular-nums">
|
||||
{t("statusBar.today", { cost: `$${today.toFixed(2)}` })}
|
||||
{t("statusBar.today", { cost: usd(today) })}
|
||||
</span>
|
||||
)}
|
||||
{lane !== null && laneCost !== null && (
|
||||
<span data-testid="status-bar-workspace-cost" className="tabular-nums">
|
||||
{t("statusBar.workspace", { cost: usd(laneCost) })}
|
||||
</span>
|
||||
)}
|
||||
{sessionId && sessionCost !== null && (
|
||||
<span data-testid="status-bar-session-cost" className="tabular-nums">
|
||||
{t("statusBar.session", { cost: usd(sessionCost) })}
|
||||
</span>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* next `title=` someone writes would be back to the browser's grey box.
|
||||
* {@link GlobalTooltip} instead listens once at the document: on hover or
|
||||
* keyboard focus (`:focus-visible`, not the focus a click gives) it takes the element's `title`, suppresses the native tooltip
|
||||
* by removing the attribute for as long as the bubble is up, and restores it
|
||||
* by removing the attribute — its ancestors' too, or the browser falls back
|
||||
* to the nearest one's — for as long as the bubble is up, and restores them
|
||||
* on the way out. Nothing else in the app has to know it exists, and a control
|
||||
* keeps its native tooltip (and its accessible name) when JS has not run.
|
||||
* While the title is parked, a control with no other name (icon-only) carries
|
||||
@@ -50,6 +51,40 @@ const SELECTOR = "[title]:not([data-tip-off]), [data-tip]";
|
||||
type Anchored = { text: string; rect: DOMRect };
|
||||
type Placed = { left: number; top: number; arrow: number; above: boolean };
|
||||
|
||||
/**
|
||||
* Takes `el`'s title into `data-tip`. React removing `title` while it is
|
||||
* parked calls removeAttribute on an attribute that is already gone: no
|
||||
* MutationRecord, nothing to see — and putting the parked copy back would
|
||||
* write stale text for good. So, on THIS element and only while parked, that
|
||||
* call is taken as React's intent: the parked copy is dropped and `onDrop`
|
||||
* runs. (React DOM calls `node.removeAttribute`, the instance method; `unpark`
|
||||
* deletes the shim.) No global prototype is touched.
|
||||
*/
|
||||
function park(el: HTMLElement, text: string, onDrop?: () => void) {
|
||||
el.dataset.tip = text;
|
||||
el.removeAttribute("title");
|
||||
el.removeAttribute = (name: string) => {
|
||||
if (name.toLowerCase() === "title" && el.dataset.tip !== undefined) {
|
||||
delete el.dataset.tip;
|
||||
onDrop?.();
|
||||
}
|
||||
Element.prototype.removeAttribute.call(el, name);
|
||||
};
|
||||
}
|
||||
|
||||
/** Undoes {@link park}. */
|
||||
function unpark(el: HTMLElement) {
|
||||
// Drop the instance shim; the prototype method is back.
|
||||
delete (el as { removeAttribute?: unknown }).removeAttribute;
|
||||
const text = el.dataset.tip;
|
||||
if (text === undefined) return;
|
||||
// React re-set `title` while it was parked (a poll flipped the hint): that
|
||||
// value is current, the parked copy is stale. React never re-applies an
|
||||
// unchanged prop, so writing the old one back would win for good.
|
||||
if (!el.hasAttribute("title")) el.setAttribute("title", text);
|
||||
delete el.dataset.tip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount ONCE, near the router root. Every `title` in the app becomes a
|
||||
* designed tooltip; nothing else has to change.
|
||||
@@ -62,6 +97,8 @@ export function GlobalTooltip() {
|
||||
// put back even if the pointer never leaves it (a route change, a re-render
|
||||
// that unmounts it, Escape).
|
||||
const parked = useRef<HTMLElement | null>(null);
|
||||
// Ancestors of `parked` whose titles are parked with it (see `open`).
|
||||
const silenced = useRef<HTMLElement[]>([]);
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
// The element whose delay is running, so re-entering it does not restart it.
|
||||
const pending = useRef<HTMLElement | null>(null);
|
||||
@@ -69,19 +106,11 @@ export function GlobalTooltip() {
|
||||
const restore = useCallback(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
pending.current = null;
|
||||
silenced.current.forEach(unpark);
|
||||
silenced.current = [];
|
||||
const el = parked.current;
|
||||
if (el) {
|
||||
// Drop the instance shim `open` installed; the prototype method is back.
|
||||
delete (el as { removeAttribute?: unknown }).removeAttribute;
|
||||
const text = el.dataset.tip;
|
||||
if (text !== undefined) {
|
||||
// React re-set `title` while it was parked (a poll flipped the hint):
|
||||
// that value is current, the parked copy is stale. React never
|
||||
// re-applies an unchanged prop, so writing the old one back would win
|
||||
// for good.
|
||||
if (!el.hasAttribute("title")) el.setAttribute("title", text);
|
||||
delete el.dataset.tip;
|
||||
}
|
||||
unpark(el);
|
||||
if (el.dataset.tipAria !== undefined) {
|
||||
el.removeAttribute("aria-label");
|
||||
delete el.dataset.tipAria;
|
||||
@@ -105,22 +134,16 @@ export function GlobalTooltip() {
|
||||
if (!el.isConnected) return;
|
||||
const current = (el.getAttribute("title") || "").trim();
|
||||
if (!current) return;
|
||||
el.dataset.tip = current;
|
||||
el.removeAttribute("title");
|
||||
// React removing `title` while it is parked calls removeAttribute on an
|
||||
// attribute that is already gone: no MutationRecord, nothing to see —
|
||||
// and `restore` would write the stale text back for good. So, on THIS
|
||||
// element and only while parked, that call is taken as React's intent:
|
||||
// the parked copy is dropped and the bubble hides. (React DOM calls
|
||||
// `node.removeAttribute`, the instance method; `restore` deletes the
|
||||
// shim.) No global prototype is touched.
|
||||
el.removeAttribute = (name: string) => {
|
||||
if (name.toLowerCase() === "title" && el.dataset.tip !== undefined) {
|
||||
delete el.dataset.tip;
|
||||
restore();
|
||||
}
|
||||
Element.prototype.removeAttribute.call(el, name);
|
||||
};
|
||||
park(el, current, restore);
|
||||
// With its own title gone the browser shows the nearest ANCESTOR's
|
||||
// native one instead (a pane header's drag hint over its Close
|
||||
// button's bubble), so every ancestor title is parked too.
|
||||
for (let a = el.parentElement; a; a = a.parentElement) {
|
||||
const t = a.getAttribute("title");
|
||||
if (t === null) continue;
|
||||
park(a, t);
|
||||
silenced.current.push(a);
|
||||
}
|
||||
// The title was an icon-only control's ONLY name; keep it named.
|
||||
if (
|
||||
!el.hasAttribute("aria-label") &&
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* fetched from the server (`/api/webhooks/providers`): each provider declares
|
||||
* whether it needs a URL and which credential fields to render, so adding a
|
||||
* provider server-side surfaces here with no UI change. Secrets are never
|
||||
* returned by the API - URLs are masked and re-entered to change.
|
||||
* returned by the API - URLs are masked and re-entered to change. A right-click
|
||||
* on a target opens its Test / Edit / Delete as a menu.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -82,6 +83,7 @@ import { api } from "../lib/api";
|
||||
import { Select } from "./Select";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
|
||||
import { WEBHOOK_DOCS } from "./webhookGuides";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import type {
|
||||
@@ -144,6 +146,13 @@ export function WebhookSettings() {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const ctx = useContextMenu<WebhookTarget>();
|
||||
// A menu item: close the menu, then act on the target it was opened on.
|
||||
const pick = (fn: (target: WebhookTarget) => unknown) => () => {
|
||||
const target = ctx.menu?.item;
|
||||
ctx.close();
|
||||
if (target) fn(target);
|
||||
};
|
||||
const [testResult, setTestResult] = useState<Record<string, WebhookTestResult>>({});
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [guideOpen, setGuideOpen] = useState(false);
|
||||
@@ -388,6 +397,7 @@ export function WebhookSettings() {
|
||||
return (
|
||||
<div
|
||||
key={target.id}
|
||||
onContextMenu={(e) => ctx.open(e, target)}
|
||||
className="bg-surface-2 border border-border rounded-lg px-3.5 py-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
@@ -482,6 +492,21 @@ export function WebhookSettings() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="webhook-menu">
|
||||
<MenuItem
|
||||
disabled={testing === ctx.menu.item.id}
|
||||
onSelect={pick((target) => void onTest(target.id))}
|
||||
>
|
||||
{t("webhooks.test")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(openEdit)}>{t("webhooks.edit")}</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pick((target) => setConfirmDelete(target.id))}>
|
||||
{t("webhooks.delete")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ import { AddLaneModal } from "./lanes/AddLaneModal";
|
||||
import { DestructiveLaneModal } from "./lanes/DestructiveLaneModal";
|
||||
|
||||
/** The workspace (and attached run) the Workspace page shows, from its URL. */
|
||||
function selectedFrom(pathname: string, search: string) {
|
||||
/** The workspace, pane run and replayed session the URL names (Workspace page only). */
|
||||
export function selectedFrom(pathname: string, search: string) {
|
||||
if (!pathname.startsWith("/workspace")) return { lane: null, run: null, session: null };
|
||||
const p = new URLSearchParams(search);
|
||||
const v = p.get("lane");
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, within } from "@testing-library/react";
|
||||
// render is used inside renderCard helper
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AgentCard } from "../AgentCard";
|
||||
import type { Agent } from "../../lib/types";
|
||||
import { formatModelName, fmtCost } from "../../lib/format";
|
||||
@@ -325,4 +326,42 @@ describe("AgentCard", () => {
|
||||
);
|
||||
expect(screen.getByText(/ran 5m 30s/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("right-click opens the session menu; its items act without clicking the card", async () => {
|
||||
function Where() {
|
||||
const loc = useLocation();
|
||||
return <span data-testid="where">{loc.pathname + loc.search}</span>;
|
||||
}
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<>
|
||||
<AgentCard
|
||||
agent={makeAgent()}
|
||||
session={{ id: "sess-1", lane_id: 4, cwd: "/r/app" } as never}
|
||||
onClick={onClick}
|
||||
/>
|
||||
<Where />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByText("Main Agent") });
|
||||
const menu = screen.getByTestId("session-row-menu-sess-1");
|
||||
await user.click(within(menu).getByText("Copy folder path"));
|
||||
expect(await navigator.clipboard.readText()).toBe("/r/app");
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByText("Main Agent") });
|
||||
await user.click(screen.getByText("Open in its workspace"));
|
||||
expect(screen.getByTestId("where")).toHaveTextContent("/workspace?lane=4&session=sess-1");
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
* @file StatusBar.test.tsx
|
||||
* @description Tests for the shell's bottom status bar: the live/disconnected
|
||||
* state with the version (click opens the connection details), the running and
|
||||
* needs-you counts from the shared lane list, and today's spend from the
|
||||
* pricing cost call's `daily_costs`.
|
||||
* needs-you counts from the shared lane list, today's spend from the
|
||||
* pricing cost call's `daily_costs`, and on the Workspace page the open
|
||||
* workspace's and the shown session's totals.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
function today() {
|
||||
@@ -30,25 +32,35 @@ vi.mock("../../lib/api", () => ({
|
||||
}),
|
||||
},
|
||||
pricing: {
|
||||
totalCost: vi.fn().mockImplementation(async () => ({
|
||||
total_cost: 10,
|
||||
totalCost: vi.fn().mockImplementation(async (opts?: { lane?: number }) => ({
|
||||
total_cost: opts?.lane ? 123.456 : 10,
|
||||
breakdown: [],
|
||||
daily_costs: [
|
||||
{ date: "2000-01-01", cost: 9 },
|
||||
{ date: today(), cost: 1.234 },
|
||||
],
|
||||
})),
|
||||
sessionCost: vi.fn().mockImplementation(async (id: string) => ({
|
||||
total_cost: id === "s-live" ? 4.5 : 7,
|
||||
breakdown: [],
|
||||
daily_costs: [],
|
||||
})),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../../lib/api";
|
||||
import { StatusBar } from "../StatusBar";
|
||||
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
||||
|
||||
const at = (url: string, ui: JSX.Element) =>
|
||||
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
|
||||
|
||||
describe("StatusBar", () => {
|
||||
it("shows the connection with the version, the counts and today's spend", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConnectionClick = vi.fn();
|
||||
render(<StatusBar wsConnected left="15rem" onConnectionClick={onConnectionClick} />);
|
||||
at("/", <StatusBar wsConnected left="15rem" onConnectionClick={onConnectionClick} />);
|
||||
expect(screen.getByText("Live")).toBeInTheDocument();
|
||||
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument();
|
||||
expect(await screen.findByText("Today $1.23")).toBeInTheDocument();
|
||||
@@ -58,7 +70,41 @@ describe("StatusBar", () => {
|
||||
});
|
||||
|
||||
it("says Disconnected when the socket is down", () => {
|
||||
render(<StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
||||
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
|
||||
expect(screen.getByText("Disconnected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("off the Workspace page shows no workspace or session cost", async () => {
|
||||
at("/sessions", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
|
||||
await screen.findByText("Today $1.23");
|
||||
expect(screen.queryByTestId("status-bar-workspace-cost")).toBeNull();
|
||||
expect(screen.queryByTestId("status-bar-session-cost")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the open workspace's total and the replayed session's", async () => {
|
||||
at(
|
||||
"/workspace?lane=3&session=s-old",
|
||||
<StatusBar wsConnected left="0" onConnectionClick={() => {}} />
|
||||
);
|
||||
expect(await screen.findByText("Workspace $123.46")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Session $7.00")).toBeInTheDocument();
|
||||
expect(api.pricing.totalCost).toHaveBeenCalledWith({ lane: 3 });
|
||||
});
|
||||
|
||||
it("takes a focused pane's session from the page's run poll", async () => {
|
||||
at(
|
||||
"/workspace?lane=3&run=agenthub-lane-3-1",
|
||||
<StatusBar wsConnected left="0" onConnectionClick={() => {}} />
|
||||
);
|
||||
await screen.findByText("Workspace $123.46");
|
||||
expect(screen.queryByTestId("status-bar-session-cost")).toBeNull();
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(LIVE_RUNS_EVENT, {
|
||||
detail: [{ id: "agenthub-lane-3-1", laneId: 3, status: "running", sessionId: "s-live" }],
|
||||
})
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText("Session $4.50")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -285,4 +285,51 @@ describe("GlobalTooltip", () => {
|
||||
fireEvent.scroll(document);
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("silences an ancestor's native title while a child's bubble is up", async () => {
|
||||
// Parking only the child's title let the browser fall back to the nearest
|
||||
// ancestor's — a native black box over the designed bubble.
|
||||
render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<div data-testid="header" title="Drag to move this pane">
|
||||
<button title="Close pane">x</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
const header = screen.getByTestId("header");
|
||||
const button = screen.getByRole("button", { name: "x" });
|
||||
hover(button);
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Close pane");
|
||||
expect(header).not.toHaveAttribute("title");
|
||||
|
||||
unhover(button);
|
||||
expect(header).toHaveAttribute("title", "Drag to move this pane");
|
||||
expect(button).toHaveAttribute("title", "Close pane");
|
||||
});
|
||||
|
||||
it("does not bring back an ancestor title React removed while it was parked", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<div data-testid="header" title="Drag">
|
||||
<button title="Close">x</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "x" });
|
||||
hover(button);
|
||||
await settle();
|
||||
rerender(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<div data-testid="header">
|
||||
<button title="Close">x</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
unhover(button);
|
||||
expect(screen.getByTestId("header")).not.toHaveAttribute("title");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
items: [{ id: "agenthub-lane-1-2", laneId: 1, status: "running", sessionId: "s-live" }],
|
||||
})),
|
||||
cwds: vi.fn(async () => ({ items: [] })),
|
||||
kill: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
aiTools: {
|
||||
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
||||
@@ -163,6 +164,40 @@ describe("WorkspaceTree", () => {
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||
});
|
||||
|
||||
it("right-click on a session opens its menu: split, rename, archive, close its tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
const right = (target: Element) => user.pointer({ keys: "[MouseRight]", target });
|
||||
|
||||
await right(await screen.findByTestId("workspace-session-s-old"));
|
||||
let menu = screen.getByTestId("session-menu-s-old");
|
||||
// Ended: no pane to split or close.
|
||||
expect(within(menu).queryByText("Open in split")).toBeNull();
|
||||
expect(within(menu).queryByText("Close tab (stops its session)")).toBeNull();
|
||||
await user.click(within(menu).getByText("Rename"));
|
||||
expect(screen.queryByTestId("session-menu-s-old")).toBeNull();
|
||||
expect(screen.getByTestId("workspace-session-rename-s-old")).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await right(screen.getByTestId("workspace-session-s-live"));
|
||||
menu = screen.getByTestId("session-menu-s-live");
|
||||
await user.click(within(menu).getByText("Open in split"));
|
||||
expect(screen.getByTestId("where")).toHaveTextContent(
|
||||
"/workspace?lane=1&run=agenthub-lane-1-2"
|
||||
);
|
||||
|
||||
await right(screen.getByTestId("workspace-session-s-live"));
|
||||
await user.click(within(screen.getByTestId("session-menu-s-live")).getByText("Archive"));
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||
|
||||
await right(screen.getByTestId("workspace-session-s-live"));
|
||||
await user.click(
|
||||
within(screen.getByTestId("session-menu-s-live")).getByText("Close tab (stops its session)")
|
||||
);
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
|
||||
});
|
||||
|
||||
it("archiving a worktree session removes its worktree first, after asking", async () => {
|
||||
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||
sessions: [
|
||||
|
||||
@@ -3,13 +3,23 @@
|
||||
* @description Generic list + card for markdown-frontmatter config artifacts
|
||||
* (skills, agents, commands, output styles). Tool-agnostic: callers pass the
|
||||
* items and the open/edit/delete handlers, nothing here reaches for an API.
|
||||
* A right-click on a card opens `FileItemMenu` — the same handlers (Delete
|
||||
* still goes through the caller's confirm) plus Copy path; the Claude Code and
|
||||
* Codex config pages reuse that menu for their other file-backed cards.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, Pencil, Trash2 } from "lucide-react";
|
||||
import type { CcArtifactType, CcMdItem } from "../../lib/api";
|
||||
import {
|
||||
ContextMenu,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
useContextMenu,
|
||||
type MenuPoint,
|
||||
} from "../ContextMenu";
|
||||
import { ScopeBadge } from "./ScopeBadge";
|
||||
import { Empty, SkeletonRows } from "./configPrimitives";
|
||||
|
||||
@@ -30,7 +40,65 @@ interface MdItemListProps {
|
||||
kind: "skills" | "agents" | "commands" | "outputStyles";
|
||||
}
|
||||
|
||||
const fileOf = (item: CcMdItem) => item.file || `${item.path}/SKILL.md`;
|
||||
const typeOf = (kind: MdItemListProps["kind"]): CcArtifactType =>
|
||||
kind === "outputStyles" ? "output-styles" : kind;
|
||||
|
||||
/** Right-click menu of a file-backed config card: the card's own Open / Edit /
|
||||
* Delete handlers — Delete opens whatever confirm the button opens — plus a
|
||||
* harmless Copy path. Labels default to the Claude Code page's. */
|
||||
export function FileItemMenu({
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
path,
|
||||
onOpen,
|
||||
onEdit,
|
||||
onDelete,
|
||||
labels,
|
||||
}: MenuPoint & {
|
||||
onClose: () => void;
|
||||
path: string;
|
||||
onOpen: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
labels?: { open: string; edit: string; delete: string; copyPath: string };
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const l = labels ?? {
|
||||
open: t("common.viewSource"),
|
||||
edit: t("edit.editButton"),
|
||||
delete: t("edit.deleteButton"),
|
||||
copyPath: t("common.copyPath"),
|
||||
};
|
||||
const pick = (fn: () => void) => () => {
|
||||
onClose();
|
||||
fn();
|
||||
};
|
||||
return (
|
||||
<ContextMenu x={x} y={y} onClose={onClose} testId="config-item-menu">
|
||||
<MenuItem testId="config-item-menu-open" onSelect={pick(onOpen)}>
|
||||
{l.open}
|
||||
</MenuItem>
|
||||
<MenuItem testId="config-item-menu-edit" onSelect={pick(onEdit)}>
|
||||
{l.edit}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
testId="config-item-menu-copy"
|
||||
onSelect={pick(() => void navigator.clipboard.writeText(path))}
|
||||
>
|
||||
{l.copyPath}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger testId="config-item-menu-delete" onSelect={pick(onDelete)}>
|
||||
{l.delete}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: MdItemListProps) {
|
||||
const ctx = useContextMenu<CcMdItem>();
|
||||
const filtered = useMemo(() => {
|
||||
if (!items) return null;
|
||||
const q = search.toLowerCase();
|
||||
@@ -56,9 +124,27 @@ export function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: Md
|
||||
onOpen={onOpen}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onMenu={ctx.open}
|
||||
kind={kind}
|
||||
/>
|
||||
))}
|
||||
{ctx.menu && (
|
||||
<FileItemMenu
|
||||
x={ctx.menu.x}
|
||||
y={ctx.menu.y}
|
||||
onClose={ctx.close}
|
||||
path={fileOf(ctx.menu.item)}
|
||||
onOpen={() => onOpen(fileOf(ctx.menu!.item))}
|
||||
onEdit={() => {
|
||||
const it = ctx.menu!.item;
|
||||
onEdit(typeOf(kind), { scope: it.scope, name: it.name, filePath: fileOf(it) });
|
||||
}}
|
||||
onDelete={() => {
|
||||
const it = ctx.menu!.item;
|
||||
onDelete(typeOf(kind), it.scope, it.name, fileOf(it));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,14 +162,15 @@ interface MdItemCardProps {
|
||||
name: string | undefined,
|
||||
path: string
|
||||
) => void;
|
||||
onMenu: (e: MouseEvent, item: CcMdItem) => void;
|
||||
kind: "skills" | "agents" | "commands" | "outputStyles";
|
||||
}
|
||||
|
||||
function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) {
|
||||
function MdItemCard({ item, onOpen, onEdit, onDelete, onMenu, kind }: MdItemCardProps) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const artifactType: CcArtifactType = kind === "outputStyles" ? "output-styles" : kind;
|
||||
const artifactType = typeOf(kind);
|
||||
|
||||
const filePath = item.file || `${item.path}/SKILL.md`;
|
||||
const filePath = fileOf(item);
|
||||
const description =
|
||||
item.frontmatter.description ||
|
||||
item.preview
|
||||
@@ -91,7 +178,10 @@ function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) {
|
||||
.trim()
|
||||
.slice(0, 200);
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3 hover:border-border/80 transition-colors">
|
||||
<div
|
||||
onContextMenu={(e) => onMenu(e, item)}
|
||||
className="rounded-lg border border-border bg-surface-2 px-4 py-3 hover:border-border/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @file Covers MdItemList's right-click menu: each entry calls the same
|
||||
* handler, with the same arguments, as the card button it repeats — Delete
|
||||
* included, so the caller's confirm modal stays the only way to delete.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MdItemList } from "../MdItemList";
|
||||
import type { CcMdItem } from "../../../lib/api";
|
||||
|
||||
const item = {
|
||||
name: "my-skill",
|
||||
scope: "user",
|
||||
path: "/home/.claude/skills/my-skill",
|
||||
file: "",
|
||||
frontmatter: { description: "Does things" },
|
||||
preview: "",
|
||||
} as unknown as CcMdItem;
|
||||
|
||||
function setup() {
|
||||
const handlers = { onOpen: vi.fn(), onEdit: vi.fn(), onDelete: vi.fn() };
|
||||
render(<MdItemList items={[item]} search="" kind="skills" {...handlers} />);
|
||||
return handlers;
|
||||
}
|
||||
|
||||
async function openMenu(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByText("my-skill") });
|
||||
expect(screen.getByTestId("config-item-menu")).toBeInTheDocument();
|
||||
}
|
||||
|
||||
const path = "/home/.claude/skills/my-skill/SKILL.md";
|
||||
|
||||
describe("MdItemList — right-click menu", () => {
|
||||
it("Open, Edit and Delete call the card buttons' handlers", async () => {
|
||||
const user = userEvent.setup();
|
||||
const h = setup();
|
||||
|
||||
await openMenu(user);
|
||||
await user.click(screen.getByTestId("config-item-menu-open"));
|
||||
expect(h.onOpen).toHaveBeenCalledWith(path);
|
||||
expect(screen.queryByTestId("config-item-menu")).toBeNull();
|
||||
|
||||
await openMenu(user);
|
||||
await user.click(screen.getByTestId("config-item-menu-edit"));
|
||||
expect(h.onEdit).toHaveBeenCalledWith("skills", {
|
||||
scope: "user",
|
||||
name: "my-skill",
|
||||
filePath: path,
|
||||
});
|
||||
|
||||
await openMenu(user);
|
||||
await user.click(screen.getByTestId("config-item-menu-delete"));
|
||||
expect(h.onDelete).toHaveBeenCalledWith("skills", "user", "my-skill", path);
|
||||
|
||||
// The buttons still do the same thing.
|
||||
await user.click(screen.getByRole("button", { name: /delete/i }));
|
||||
expect(h.onDelete).toHaveBeenLastCalledWith("skills", "user", "my-skill", path);
|
||||
});
|
||||
|
||||
it("Copy path writes the item's file path", async () => {
|
||||
const user = userEvent.setup();
|
||||
setup();
|
||||
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue();
|
||||
|
||||
await openMenu(user);
|
||||
await user.click(screen.getByTestId("config-item-menu-copy"));
|
||||
expect(writeText).toHaveBeenCalledWith(path);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file One lane's card: CI/PR facts, the "needs you"
|
||||
* banner sourced from Claude Code's Notification hook, and (for managed
|
||||
* lanes only) a ⋯ menu holding the rare `reset worktree` action behind
|
||||
* lanes only) a ⋯ menu — also on a right-click — holding the rare `reset worktree` action behind
|
||||
* DestructiveLaneModal. A dead lane (its driving session went silent while
|
||||
* it should have been working) is called out loudly — that is the failure
|
||||
* this view exists to catch. Stage, kind, and the "auto: <stage>" chip are
|
||||
@@ -23,6 +23,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
@@ -271,6 +272,7 @@ export default function LaneCard({
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [menuAt, setMenuAt] = useState<MenuPoint | null>(null);
|
||||
const [runtimeBump, setRuntimeBump] = useState(0);
|
||||
const [bootLine, setBootLine] = useState<string | null>(null);
|
||||
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
||||
@@ -301,6 +303,8 @@ export default function LaneCard({
|
||||
<>
|
||||
<div
|
||||
data-testid={`lane-card-${lane.id}`}
|
||||
// Right-click: the ⋯ menu's actions at the pointer (managed only).
|
||||
onContextMenu={lane.kind === "managed" ? (e) => setMenuAt(menuPoint(e)) : undefined}
|
||||
// Every row below is conditional: a lane with nothing to report
|
||||
// renders no frame at all rather than an empty box.
|
||||
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light empty:hidden"
|
||||
@@ -462,6 +466,19 @@ export default function LaneCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{menuAt && (
|
||||
<ContextMenu x={menuAt.x} y={menuAt.y} onClose={() => setMenuAt(null)}>
|
||||
<MenuItem
|
||||
danger
|
||||
onSelect={() => {
|
||||
setMenuAt(null);
|
||||
setDestructiveAction("reset");
|
||||
}}
|
||||
>
|
||||
{t("action.reset")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{destructiveAction && (
|
||||
<DestructiveLaneModal
|
||||
lane={lane}
|
||||
|
||||
@@ -63,6 +63,7 @@ import { api, gitTarget } from "../../lib/api";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
||||
import { LaneConflictView } from "./LaneConflictView";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||
import type {
|
||||
@@ -151,6 +152,7 @@ function TreeNodes({
|
||||
expanded,
|
||||
onToggleDir,
|
||||
onOpenFile,
|
||||
onMenu,
|
||||
active,
|
||||
}: {
|
||||
path: string;
|
||||
@@ -159,6 +161,8 @@ function TreeNodes({
|
||||
expanded: Set<string>;
|
||||
onToggleDir: (path: string) => void;
|
||||
onOpenFile: (path: string) => void;
|
||||
/** Right-click on an entry. */
|
||||
onMenu?: (e: React.MouseEvent, entry: LaneTreeEntry) => void;
|
||||
/** The file open beside the tree, drawn selected. */
|
||||
active?: string | null;
|
||||
}) {
|
||||
@@ -174,6 +178,7 @@ function TreeNodes({
|
||||
type="button"
|
||||
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
|
||||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||||
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
|
||||
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
||||
aria-current={active === e.path ? "true" : undefined}
|
||||
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
|
||||
@@ -217,6 +222,7 @@ function TreeNodes({
|
||||
expanded={expanded}
|
||||
onToggleDir={onToggleDir}
|
||||
onOpenFile={onOpenFile}
|
||||
onMenu={onMenu}
|
||||
active={active}
|
||||
/>
|
||||
</ul>
|
||||
@@ -372,10 +378,12 @@ function ChangeRow({
|
||||
onAct,
|
||||
onArm,
|
||||
onResolve,
|
||||
onMenu,
|
||||
}: {
|
||||
f: LaneChangeFile;
|
||||
range: LaneChangeRange;
|
||||
onResolve: Resolve;
|
||||
onMenu: (e: React.MouseEvent, f: LaneChangeFile) => void;
|
||||
busy: boolean;
|
||||
/** This row's discard has been clicked once and awaits the second click. */
|
||||
armed: boolean;
|
||||
@@ -385,7 +393,7 @@ function ChangeRow({
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
return (
|
||||
<li className="group flex items-baseline gap-1">
|
||||
<li className="group flex items-baseline gap-1" onContextMenu={(e) => onMenu(e, f)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(f.path)}
|
||||
@@ -844,6 +852,136 @@ export function LaneChangesPanel({
|
||||
};
|
||||
const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side });
|
||||
|
||||
const fileCtx = useContextMenu<LaneChangeFile>();
|
||||
const treeCtx = useContextMenu<LaneTreeEntry>();
|
||||
const commitCtx = useContextMenu<LaneCommit>();
|
||||
const viewCommit = (sha: string) => {
|
||||
setSha(sha);
|
||||
setRange("commit");
|
||||
setMode("review");
|
||||
};
|
||||
const copy = (text: string) => void navigator.clipboard.writeText(text);
|
||||
// A menu item: close its menu, then act.
|
||||
const pick = (close: () => void, fn: () => void) => () => {
|
||||
close();
|
||||
fn();
|
||||
};
|
||||
const menuFile = fileCtx.menu?.item;
|
||||
const menuArmed = !!menuFile && confirmDiscard === menuFile.path;
|
||||
const menus = (
|
||||
<>
|
||||
{fileCtx.menu && menuFile && (
|
||||
<ContextMenu
|
||||
x={fileCtx.menu.x}
|
||||
y={fileCtx.menu.y}
|
||||
onClose={fileCtx.close}
|
||||
testId="lane-changes-file-menu"
|
||||
>
|
||||
<MenuItem onSelect={pick(fileCtx.close, () => show(menuFile.path))}>
|
||||
{t("changes.openFile", { path: menuFile.path.split("/").pop() })}
|
||||
</MenuItem>
|
||||
{range === "work" && menuFile.conflicted && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
disabled={busy}
|
||||
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "ours"))}
|
||||
>
|
||||
{t("changes.takeOurs")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={busy}
|
||||
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "theirs"))}
|
||||
>
|
||||
{t("changes.takeTheirs")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
disabled={busy}
|
||||
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "done"))}
|
||||
>
|
||||
{t("changes.markResolved")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
{range === "work" && !menuFile.conflicted && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
{(menuFile.unstaged || menuFile.untracked) && (
|
||||
<MenuItem
|
||||
disabled={busy}
|
||||
onSelect={pick(fileCtx.close, () => void act("stage", menuFile.path))}
|
||||
>
|
||||
{t("changes.stage")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{menuFile.staged && (
|
||||
<MenuItem
|
||||
disabled={busy}
|
||||
onSelect={pick(fileCtx.close, () => void act("unstage", menuFile.path))}
|
||||
>
|
||||
{t("changes.unstage")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={busy}
|
||||
testId="lane-changes-menu-discard"
|
||||
// Two clicks here too: the first arms (the row's trash
|
||||
// shows it), only the second reaches the server.
|
||||
onSelect={() =>
|
||||
menuArmed
|
||||
? pick(fileCtx.close, () => void act("discard", menuFile.path, true))()
|
||||
: setConfirmDiscard(menuFile.path)
|
||||
}
|
||||
>
|
||||
{menuArmed ? t("changes.discardConfirm") : t("changes.discard")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={pick(fileCtx.close, () => copy(menuFile.path))}>
|
||||
{t("changes.copyPath")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{treeCtx.menu && (
|
||||
<ContextMenu
|
||||
x={treeCtx.menu.x}
|
||||
y={treeCtx.menu.y}
|
||||
onClose={treeCtx.close}
|
||||
testId="lane-changes-tree-menu"
|
||||
>
|
||||
{!treeCtx.menu.item.dir && (
|
||||
<MenuItem onSelect={pick(treeCtx.close, () => openWhole(treeCtx.menu!.item.path))}>
|
||||
{t("changes.openFile", { path: treeCtx.menu.item.name })}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
|
||||
{t("changes.copyPath")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{commitCtx.menu && (
|
||||
<ContextMenu
|
||||
x={commitCtx.menu.x}
|
||||
y={commitCtx.menu.y}
|
||||
onClose={commitCtx.close}
|
||||
testId="lane-changes-commit-menu"
|
||||
>
|
||||
<MenuItem onSelect={pick(commitCtx.close, () => viewCommit(commitCtx.menu!.item.sha))}>
|
||||
{t("changes.viewCommit")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.sha))}>
|
||||
{t("changes.copySha")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.subject))}>
|
||||
{t("changes.copySubject")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// One change block, named by its hunk's `@@` line and its index inside that
|
||||
// hunk, matched against a fresh diff at the context this diff was read
|
||||
// with — so the server never applies a stale patch.
|
||||
@@ -1038,6 +1176,7 @@ export function LaneChangesPanel({
|
||||
expanded={expandedDirs}
|
||||
onToggleDir={toggleDir}
|
||||
onOpenFile={openWhole}
|
||||
onMenu={treeCtx.open}
|
||||
active={full && mode === "file" ? openFile : null}
|
||||
/>
|
||||
</ul>
|
||||
@@ -1050,6 +1189,7 @@ export function LaneChangesPanel({
|
||||
data-testid="lane-changes"
|
||||
className={`flex min-h-0 flex-1 ${full ? "gap-3" : "flex-col"}`}
|
||||
>
|
||||
{menus}
|
||||
{/* Full width has room for an explorer: the tree stays beside the file
|
||||
it opened, instead of being a mode you leave to read one. */}
|
||||
{full && (
|
||||
@@ -1278,11 +1418,8 @@ export function LaneChangesPanel({
|
||||
<li key={c.sha}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSha(c.sha);
|
||||
setRange("commit");
|
||||
setMode("review");
|
||||
}}
|
||||
onClick={() => viewCommit(c.sha)}
|
||||
onContextMenu={(e) => commitCtx.open(e, c)}
|
||||
className="w-full rounded px-1 py-0.5 text-left hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
@@ -1432,6 +1569,7 @@ export function LaneChangesPanel({
|
||||
onAct={(...a) => void act(...a)}
|
||||
onArm={setConfirmDiscard}
|
||||
onResolve={resolve}
|
||||
onMenu={fileCtx.open}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -1603,6 +1741,7 @@ export function LaneChangesPanel({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => toggleCollapsed(f.path)}
|
||||
onContextMenu={(e) => fileCtx.open(e, f)}
|
||||
onKeyDown={(e) => {
|
||||
// Only the header's own keys: Enter on the path button
|
||||
// inside bubbles here and must open, not fold.
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @file SessionMenu — a session row's right-click menu (sidebar, the workspace
|
||||
* home's Recents, the archived fold), and a bare live tab's (`session` null —
|
||||
* the sidebar's **New session** row). Every item is something the row already
|
||||
* does: open it the way its click does (a live pane attaches, an ended session
|
||||
* opens its conversation — never a resume), open a live one in a split,
|
||||
* rename / archive / unarchive when the list offers them, copy its id, open
|
||||
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
|
||||
* tab's X does).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import type { Session } from "../../lib/types";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu";
|
||||
|
||||
export function SessionMenu({
|
||||
laneId,
|
||||
session,
|
||||
run,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
onRename,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
}: MenuPoint & {
|
||||
laneId: number;
|
||||
session: Session | null;
|
||||
/** The live pane holding it, if any. */
|
||||
run: RunHandle | null;
|
||||
onClose: () => void;
|
||||
onRename?: () => void;
|
||||
onArchive?: () => void;
|
||||
onUnarchive?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const runUrl = run && `/workspace?lane=${laneId}&run=${encodeURIComponent(run.id)}`;
|
||||
|
||||
const pick = (fn: () => unknown) => () => {
|
||||
onClose();
|
||||
fn();
|
||||
};
|
||||
const closeTab = async () => {
|
||||
if (!run) return;
|
||||
setError(null);
|
||||
try {
|
||||
await api.run.kill(run.id);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu x={x} y={y} onClose={onClose} testId={`session-menu-${session?.id ?? run?.id}`}>
|
||||
<MenuItem
|
||||
onSelect={pick(() =>
|
||||
navigate(
|
||||
runUrl ?? `/workspace?lane=${laneId}&session=${encodeURIComponent(session?.id ?? "")}`
|
||||
)
|
||||
)}
|
||||
>
|
||||
{t("sessionMenu.open")}
|
||||
</MenuItem>
|
||||
{runUrl && (
|
||||
<MenuItem onSelect={pick(() => navigate(runUrl, { state: { split: true } }))}>
|
||||
{t("sessionMenu.openSplit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onRename && <MenuItem onSelect={pick(onRename)}>{t("menu.rename")}</MenuItem>}
|
||||
{session && (
|
||||
<>
|
||||
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
||||
{t("sessionMenu.details")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(() => void navigator.clipboard.writeText(session.id))}>
|
||||
{t("sessionMenu.copyId")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
{(run || onArchive || onUnarchive) && <MenuSeparator />}
|
||||
{onUnarchive && <MenuItem onSelect={pick(onUnarchive)}>{t("tree.unarchive")}</MenuItem>}
|
||||
{onArchive && <MenuItem onSelect={pick(onArchive)}>{t("sessionMenu.archive")}</MenuItem>}
|
||||
{run && (
|
||||
<MenuItem danger onSelect={() => void closeTab()}>
|
||||
{t("tabs.close")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
type CodexSandbox,
|
||||
type EffortLevel,
|
||||
type LaneMemoryFile,
|
||||
type LaneSource,
|
||||
type LaneSourcesView,
|
||||
type ModelChoice,
|
||||
type PermissionMode,
|
||||
@@ -57,6 +58,8 @@ import { Select } from "../Select";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||
import { workspaceName } from "./WorkspaceRow";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, menuPoint, useContextMenu } from "../ContextMenu";
|
||||
import {
|
||||
liveRunFor,
|
||||
SessionFold,
|
||||
@@ -162,12 +165,15 @@ function HomeBody({
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const [memoryTab, setMemoryTab] = useState(0);
|
||||
const [folderOpen, setFolderOpen] = useState(false);
|
||||
const [folderPath, setFolderPath] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||||
const [title, setTitle] = useState<string | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const recentCtx = useContextMenu<Session>();
|
||||
const sourceCtx = useContextMenu<LaneSource>();
|
||||
|
||||
// A new pane, a pane gone, a session id learned — all move these lane fields.
|
||||
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
|
||||
@@ -342,6 +348,7 @@ function HomeBody({
|
||||
{title === null ? (
|
||||
<h1
|
||||
title={lane.cwd}
|
||||
onContextMenu={(e) => setMenuAt(menuPoint(e))}
|
||||
className="min-w-0 flex-1 truncate text-2xl font-semibold text-fg-primary"
|
||||
>
|
||||
{name}
|
||||
@@ -582,6 +589,7 @@ function HomeBody({
|
||||
data-testid={`workspace-recent-${s.id}`}
|
||||
data-live={live ? "true" : undefined}
|
||||
onClick={() => openSession(s)}
|
||||
onContextMenu={(e) => recentCtx.open(e, s)}
|
||||
className="flex w-full items-center gap-3 py-2.5 text-left hover:bg-surface-3"
|
||||
>
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${sessionDot(s, live)}`} />
|
||||
@@ -619,6 +627,7 @@ function HomeBody({
|
||||
actionIcon={<ArchiveRestore className="h-3.5 w-3.5" />}
|
||||
onAction={(s) => api.sessions.patch(s.id, { archived: false })}
|
||||
onDone={() => setVersion((v) => v + 1)}
|
||||
laneId={lane.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -690,7 +699,10 @@ function HomeBody({
|
||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.memory")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMemoryOpen(true)}
|
||||
onClick={() => {
|
||||
setMemoryTab(0);
|
||||
setMemoryOpen(true);
|
||||
}}
|
||||
disabled={!memory?.length}
|
||||
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
@@ -743,7 +755,11 @@ function HomeBody({
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{files.map((f) => (
|
||||
<li key={f.id} className="group flex items-center gap-2 text-xs">
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-2 text-xs"
|
||||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||
{f.name}
|
||||
@@ -790,7 +806,11 @@ function HomeBody({
|
||||
</span>
|
||||
</li>
|
||||
{folders.map((f) => (
|
||||
<li key={f.id} className="group flex items-center gap-2 text-xs">
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-2 text-xs"
|
||||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||
{f.name}
|
||||
@@ -872,7 +892,7 @@ function HomeBody({
|
||||
aria-label={t("home.memory")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.key === "Escape" && setMemoryOpen(false)}
|
||||
className="max-h-[85vh] w-full max-w-3xl overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
|
||||
@@ -886,18 +906,76 @@ function HomeBody({
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{memory.map((f) => (
|
||||
<section key={f.path} className="mb-5">
|
||||
<p title={f.path} className="mb-1 font-mono text-xs text-fg-muted">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("home.memory")}
|
||||
className="mb-3 flex gap-1.5 overflow-x-auto border-b border-border/60 pb-2 no-scrollbar"
|
||||
>
|
||||
{memory.map((f, i) => (
|
||||
<button
|
||||
key={f.path}
|
||||
type="button"
|
||||
role="tab"
|
||||
title={f.path}
|
||||
aria-selected={i === memoryTab}
|
||||
onClick={() => setMemoryTab(i)}
|
||||
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
|
||||
i === memoryTab
|
||||
? "border-accent/30 bg-accent/15 text-accent"
|
||||
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</p>
|
||||
<MarkdownPreview text={f.content} />
|
||||
</section>
|
||||
))}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{memory[memoryTab] && (
|
||||
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto">
|
||||
<MarkdownPreview text={memory[memoryTab].content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recentCtx.menu && (
|
||||
<SessionMenu
|
||||
x={recentCtx.menu.x}
|
||||
y={recentCtx.menu.y}
|
||||
laneId={lane.id}
|
||||
session={recentCtx.menu.item}
|
||||
run={liveRunFor(lane, liveRuns, recentCtx.menu.item.id)}
|
||||
onClose={recentCtx.close}
|
||||
/>
|
||||
)}
|
||||
{sourceCtx.menu && (
|
||||
<ContextMenu
|
||||
x={sourceCtx.menu.x}
|
||||
y={sourceCtx.menu.y}
|
||||
onClose={sourceCtx.close}
|
||||
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
|
||||
>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
void navigator.clipboard.writeText(sourceCtx.menu!.item.path);
|
||||
sourceCtx.close();
|
||||
}}
|
||||
>
|
||||
{t("menu.copyPath")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
onSelect={() => {
|
||||
const id = sourceCtx.menu!.item.id;
|
||||
sourceCtx.close();
|
||||
void run(() => api.lanes.removeSource(lane.id, id));
|
||||
}}
|
||||
>
|
||||
{t("home.remove")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{menuAt && (
|
||||
<WorkspaceRowMenu
|
||||
lane={lane}
|
||||
|
||||
@@ -6,15 +6,16 @@
|
||||
* the lane Tools menu starts), New workspace here (the Add modal prefilled),
|
||||
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
||||
* managed lane. The server's guards are the real ones; the menu only hides
|
||||
* what the lane's kind or tool makes meaningless. Closes on Escape, an outside
|
||||
* click or after an item runs.
|
||||
* what the lane's kind or tool makes meaningless. The shell (position,
|
||||
* Escape / outside-click close) is `ContextMenu`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
||||
|
||||
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started";
|
||||
|
||||
@@ -33,24 +34,8 @@ export function WorkspaceRowMenu({
|
||||
onAction: (action: WorkspaceMenuAction) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, then?: WorkspaceMenuAction) => {
|
||||
setError(null);
|
||||
try {
|
||||
@@ -62,41 +47,21 @@ export function WorkspaceRowMenu({
|
||||
}
|
||||
};
|
||||
const live = !!lane.run_id || lane.status === "running";
|
||||
const item =
|
||||
"block w-full rounded px-2 py-1 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
data-testid={`workspace-row-menu-${lane.id}`}
|
||||
style={{
|
||||
left: Math.min(x, window.innerWidth - 232),
|
||||
top: Math.min(y, window.innerHeight - 320),
|
||||
}}
|
||||
className="fixed z-50 w-56 rounded-lg border border-border bg-surface-2 p-1 shadow-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={item}
|
||||
onClick={() =>
|
||||
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
|
||||
<MenuItem
|
||||
onSelect={() =>
|
||||
live
|
||||
? void run(() => api.lanes.action(lane.id, "stop"))
|
||||
: void run(() => api.lanes.action(lane.id, "start", { slot: 0, prompt: "" }), "started")
|
||||
}
|
||||
>
|
||||
{live ? t("menu.stop") : t("menu.start")}
|
||||
</button>
|
||||
<button type="button" role="menuitem" className={item} onClick={() => onAction("rename")}>
|
||||
{t("menu.rename")}
|
||||
</button>
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
|
||||
{lane.tool === "claude" && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={item}
|
||||
onClick={() =>
|
||||
<MenuItem
|
||||
onSelect={() =>
|
||||
void run(
|
||||
() =>
|
||||
api.run.start({
|
||||
@@ -109,43 +74,26 @@ export function WorkspaceRowMenu({
|
||||
}
|
||||
>
|
||||
{t("actions.writeDocs")}
|
||||
</button>
|
||||
</MenuItem>
|
||||
)}
|
||||
<button type="button" role="menuitem" className={item} onClick={() => onAction("new-here")}>
|
||||
{t("menu.newHere")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={item}
|
||||
onClick={() => void run(() => navigator.clipboard.writeText(lane.cwd))}
|
||||
>
|
||||
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
|
||||
<MenuItem onSelect={() => void run(() => navigator.clipboard.writeText(lane.cwd))}>
|
||||
{t("menu.copyPath")}
|
||||
</button>
|
||||
<div className="my-1 border-t border-border" />
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
{lane.kind === "managed" && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${item} !text-status-danger`}
|
||||
onClick={() => onAction("reset")}
|
||||
>
|
||||
<MenuItem danger onSelect={() => onAction("reset")}>
|
||||
{t("menu.reset")}
|
||||
</button>
|
||||
</MenuItem>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${item} !text-status-danger`}
|
||||
onClick={() => onAction("remove")}
|
||||
>
|
||||
<MenuItem danger onSelect={() => onAction("remove")}>
|
||||
{t("menu.remove")}
|
||||
</button>
|
||||
</MenuItem>
|
||||
{error && (
|
||||
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* shows its state (`sessionDot`: live pane, needs you, error) and its right
|
||||
* edge the AI tool the session ran on (`sessions.tool`); double-click
|
||||
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
|
||||
* which deletes nothing). A session with a LIVE pane (a `GET /api/run` handle
|
||||
* which deletes nothing); a right-click opens the same actions and more
|
||||
* (`SessionMenu`). A session with a LIVE pane (a `GET /api/run` handle
|
||||
* carrying its session id) opens that tmux pane on the Workspace page
|
||||
* (`?lane=<id>&run=<runId>`, its tab in the session view; a live tab whose
|
||||
* session has no row yet — a bare tab before its first prompt — is listed as
|
||||
@@ -29,6 +30,8 @@ import { timeAgo } from "../../lib/format";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useContextMenu } from "../ContextMenu";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
|
||||
export const SESSION_LIMIT = 20;
|
||||
|
||||
@@ -96,6 +99,7 @@ export function SessionFold({
|
||||
onAction,
|
||||
onDone,
|
||||
indent = "",
|
||||
laneId,
|
||||
}: {
|
||||
testId: string;
|
||||
page: SessionPage | null;
|
||||
@@ -107,9 +111,12 @@ export function SessionFold({
|
||||
onDone: () => void;
|
||||
/** Left padding of the rows, to line up with the list above. */
|
||||
indent?: string;
|
||||
/** The rows' workspace: given, a right-click opens the session menu. */
|
||||
laneId?: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const ctx = useContextMenu<Session>();
|
||||
if (!page || page.total === 0) return null;
|
||||
const act = async (s: Session) => {
|
||||
setError(null);
|
||||
@@ -134,7 +141,11 @@ export function SessionFold({
|
||||
{open && (
|
||||
<ul className="space-y-px">
|
||||
{page.sessions.map((s) => (
|
||||
<li key={s.id} className={`flex items-center gap-1 pr-1 ${indent}`}>
|
||||
<li
|
||||
key={s.id}
|
||||
className={`flex items-center gap-1 pr-1 ${indent}`}
|
||||
onContextMenu={laneId === undefined ? undefined : (e) => ctx.open(e, s)}
|
||||
>
|
||||
<Link
|
||||
to={`/sessions/${s.id}`}
|
||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}`}
|
||||
@@ -161,6 +172,17 @@ export function SessionFold({
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{ctx.menu && laneId !== undefined && (
|
||||
<SessionMenu
|
||||
x={ctx.menu.x}
|
||||
y={ctx.menu.y}
|
||||
laneId={laneId}
|
||||
session={ctx.menu.item}
|
||||
run={null}
|
||||
onClose={ctx.close}
|
||||
onUnarchive={() => void act(ctx.menu!.item)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -201,6 +223,8 @@ export function WorkspaceSessionList({
|
||||
// worktree too, so that is confirmed first.
|
||||
const [archiving, setArchiving] = useState<Session | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const ctx = useContextMenu<RunHandle>();
|
||||
const sessionCtx = useContextMenu<Session>();
|
||||
|
||||
// This workspace's live AI tabs. A tab started bare (no prompt) has its
|
||||
// session id from the start, but no session row until a hook creates one.
|
||||
@@ -246,6 +270,10 @@ export function WorkspaceSessionList({
|
||||
// Archive: a worktree session with no live pane loses its worktree first
|
||||
// (never forced — git refuses unsaved work, which then stops the archive);
|
||||
// one Claude already cleaned up is just archived.
|
||||
const startRename = (s: Session) => {
|
||||
setDraft(s.name || "");
|
||||
setRenaming(s.id);
|
||||
};
|
||||
const archive = (s: Session) =>
|
||||
sessionWorktreeName(lane, s) && !runFor(s)
|
||||
? setArchiving(s)
|
||||
@@ -311,6 +339,7 @@ export function WorkspaceSessionList({
|
||||
e.ctrlKey || e.metaKey ? { state: { split: true } } : undefined
|
||||
)
|
||||
}
|
||||
onContextMenu={(e) => ctx.open(e, r)}
|
||||
title={`${t("tree.newSession")}\n${t("tree.splitHint")}`}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
||||
r.id === selectedRunId
|
||||
@@ -370,10 +399,8 @@ export function WorkspaceSessionList({
|
||||
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
|
||||
)
|
||||
}
|
||||
onDoubleClick={() => {
|
||||
setDraft(s.name || "");
|
||||
setRenaming(s.id);
|
||||
}}
|
||||
onDoubleClick={() => startRename(s)}
|
||||
onContextMenu={(e) => sessionCtx.open(e, s)}
|
||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
|
||||
sessionWorktreeName(lane, s)
|
||||
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
|
||||
@@ -418,6 +445,28 @@ export function WorkspaceSessionList({
|
||||
</ul>
|
||||
{failure}
|
||||
{confirmArchive}
|
||||
{ctx.menu && (
|
||||
<SessionMenu
|
||||
x={ctx.menu.x}
|
||||
y={ctx.menu.y}
|
||||
laneId={lane.id}
|
||||
session={null}
|
||||
run={ctx.menu.item}
|
||||
onClose={ctx.close}
|
||||
/>
|
||||
)}
|
||||
{sessionCtx.menu && (
|
||||
<SessionMenu
|
||||
x={sessionCtx.menu.x}
|
||||
y={sessionCtx.menu.y}
|
||||
laneId={lane.id}
|
||||
session={sessionCtx.menu.item}
|
||||
run={runFor(sessionCtx.menu.item)}
|
||||
onClose={sessionCtx.close}
|
||||
onRename={() => startRename(sessionCtx.menu!.item)}
|
||||
onArchive={() => archive(sessionCtx.menu!.item)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -468,6 +468,39 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("right-click on a changed file offers stage, a two-click discard and copy path", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const row = within(list).getByText("server/lib/git-ops.js");
|
||||
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
|
||||
|
||||
await right();
|
||||
await user.click(
|
||||
within(screen.getByTestId("lane-changes-file-menu")).getByText("Stage this file")
|
||||
);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
});
|
||||
vi.mocked(api.lanes.gitAction).mockClear();
|
||||
|
||||
await right();
|
||||
await user.click(screen.getByTestId("lane-changes-menu-discard"));
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("lane-changes-menu-discard")).toHaveTextContent("Click again");
|
||||
await user.click(screen.getByTestId("lane-changes-menu-discard"));
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
confirm: true,
|
||||
});
|
||||
|
||||
await right();
|
||||
await user.click(
|
||||
within(screen.getByTestId("lane-changes-file-menu")).getByText("Copy relative path")
|
||||
);
|
||||
expect(await navigator.clipboard.readText()).toBe("server/lib/git-ops.js");
|
||||
});
|
||||
|
||||
it("disarms a pending discard when the lane changes, so one click cannot discard in another lane", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
||||
|
||||
@@ -439,6 +439,43 @@ describe("WorkspaceHome", () => {
|
||||
expect(within(dialog).getByText("Rules here")).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows one memory file per tab", async () => {
|
||||
vi.mocked(api.lanes.memory).mockResolvedValueOnce({
|
||||
files: [
|
||||
{
|
||||
label: "CLAUDE.md",
|
||||
path: "/r/CLAUDE.md",
|
||||
content: "# Rules here",
|
||||
size: 0,
|
||||
mtime: "",
|
||||
truncated: false,
|
||||
},
|
||||
{
|
||||
label: "memory/a.md",
|
||||
path: "/m/a.md",
|
||||
content: "# Note A",
|
||||
size: 0,
|
||||
mtime: "",
|
||||
truncated: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
expect(await screen.findByText("2 files the agent reads")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "View memory" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "Memory" });
|
||||
expect(within(dialog).getByRole("tab", { name: "CLAUDE.md" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true"
|
||||
);
|
||||
expect(within(dialog).getByText("Rules here")).toBeInTheDocument();
|
||||
expect(within(dialog).queryByText("Note A")).toBeNull();
|
||||
await user.click(within(dialog).getByRole("tab", { name: "memory/a.md" }));
|
||||
expect(within(dialog).getByText("Note A")).toBeInTheDocument();
|
||||
expect(within(dialog).queryByText("Rules here")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceHome across workspaces", () => {
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
* bar is the console's only header: Home (the workspace's home) at its left
|
||||
* end, `actions` (the workspace's Tools menu) at its right end. A click shows a tab in the focused pane, Ctrl/Cmd+click
|
||||
* (on a tab or a `+` item) opens it in a split to the right, and a tab drags
|
||||
* onto the panes (`PaneGrid.tsx`); a tab another pane shows is marked.
|
||||
* onto the panes (`PaneGrid.tsx`); a tab another pane shows is marked. A
|
||||
* right-click on a tab opens the same actions as a menu (`ContextMenu`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -26,6 +27,7 @@ import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { freeSlot } from "../lanes/WorkspaceHome";
|
||||
import { RunIcon, startPaneDrag } from "./PaneGrid";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
|
||||
export function ConsoleTabs({
|
||||
lane,
|
||||
@@ -59,6 +61,7 @@ export function ConsoleTabs({
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const tabCtx = useContextMenu<RunHandle>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
@@ -106,6 +109,13 @@ export function ConsoleTabs({
|
||||
}
|
||||
};
|
||||
|
||||
// A tab-menu item: close the menu, then act on the tab it was opened on.
|
||||
const pickTab = (fn: (r: RunHandle) => unknown) => () => {
|
||||
const r = tabCtx.menu?.item;
|
||||
tabCtx.close();
|
||||
if (r) fn(r);
|
||||
};
|
||||
|
||||
const item =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||
|
||||
@@ -132,6 +142,7 @@ export function ConsoleTabs({
|
||||
key={r.id}
|
||||
draggable
|
||||
onDragStart={(e) => startPaneDrag(e, r.slot ?? 0)}
|
||||
onContextMenu={(e) => tabCtx.open(e, r)}
|
||||
data-shown={shown ? "true" : undefined}
|
||||
className={`group relative flex min-w-0 max-w-[15rem] items-center rounded-t-md ${
|
||||
active
|
||||
@@ -252,6 +263,35 @@ export function ConsoleTabs({
|
||||
</span>
|
||||
)}
|
||||
{actions && <div className="ml-auto flex items-center pr-2">{actions}</div>}
|
||||
{tabCtx.menu && (
|
||||
<ContextMenu
|
||||
x={tabCtx.menu.x}
|
||||
y={tabCtx.menu.y}
|
||||
onClose={tabCtx.close}
|
||||
testId={`console-tab-menu-${tabCtx.menu.item.slot ?? 0}`}
|
||||
>
|
||||
<MenuItem onSelect={pickTab((r) => onOpen(r.id))}>{t("sessionMenu.open")}</MenuItem>
|
||||
<MenuItem onSelect={pickTab((r) => onOpen(r.id, true))}>
|
||||
{t("sessionMenu.openSplit")}
|
||||
</MenuItem>
|
||||
{tabCtx.menu.item.sessionId && (
|
||||
<>
|
||||
<MenuItem onSelect={pickTab((r) => navigate(`/sessions/${r.sessionId}`))}>
|
||||
{t("sessionMenu.details")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={pickTab((r) => void navigator.clipboard.writeText(r.sessionId ?? ""))}
|
||||
>
|
||||
{t("sessionMenu.copyId")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pickTab((r) => void close(r.id))}>
|
||||
{t("tabs.close")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
* header: drag it to move the pane (drop on another pane's edge to split
|
||||
* there, on its middle to swap), pick another live session for it, split it
|
||||
* right or down, or close the pane (its session keeps running as a tab).
|
||||
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). Dividers
|
||||
* resize by pointer or by arrow keys.
|
||||
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
|
||||
* right-click on a header offers the swap, a split with a new terminal and
|
||||
* the close as one menu. Dividers resize by pointer or by arrow keys.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import {
|
||||
EMPTY_LAYOUT,
|
||||
type Edge,
|
||||
@@ -171,6 +173,90 @@ function PaneMenu({
|
||||
const ITEM =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||
|
||||
/** A pane's split-right / split-down menus: move a hidden run beside
|
||||
* `slot`, or start a new terminal there. In a pane header when the view is
|
||||
* split, beside Tools in the tab bar while one pane is shown. */
|
||||
export function SplitMenus({
|
||||
slot,
|
||||
shown,
|
||||
runs,
|
||||
label,
|
||||
onChange,
|
||||
canStart,
|
||||
onNewSplit,
|
||||
}: {
|
||||
slot: number;
|
||||
/** The slots on screen. */
|
||||
shown: number[];
|
||||
runs: RunHandle[];
|
||||
label: (r: RunHandle) => string;
|
||||
onChange: LayoutUpdate;
|
||||
canStart: boolean;
|
||||
onNewSplit: (target: number, edge: Edge) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const full = shown.length >= MAX_PANES;
|
||||
return (
|
||||
<>
|
||||
{(["right", "bottom"] as const).map((edge) => (
|
||||
<PaneMenu
|
||||
key={edge}
|
||||
testId={`pane-split-${edge}-${slot}`}
|
||||
label={full ? t("panes.full") : t(`panes.split.${edge}`)}
|
||||
disabled={full}
|
||||
icon={
|
||||
edge === "right" ? (
|
||||
<Columns2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Rows2 className="h-3.5 w-3.5" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{(close) => (
|
||||
<>
|
||||
{runs
|
||||
.filter((x) => !shown.includes(x.slot ?? 0))
|
||||
.map((x) => (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={ITEM}
|
||||
onClick={() => {
|
||||
close();
|
||||
onChange((l) => moveInLayout(l, x.slot ?? 0, slot, edge));
|
||||
}}
|
||||
>
|
||||
<RunIcon run={x} label={label(x)} />
|
||||
<span className="min-w-0 truncate">{label(x)}</span>
|
||||
</button>
|
||||
))}
|
||||
{runs.some((x) => !shown.includes(x.slot ?? 0)) && (
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid={`pane-split-new-${edge}-${slot}`}
|
||||
disabled={!canStart}
|
||||
title={canStart ? undefined : t("tabs.full")}
|
||||
className={`${ITEM} disabled:opacity-40 disabled:hover:bg-transparent`}
|
||||
onClick={() => {
|
||||
close();
|
||||
onNewSplit(slot, edge);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("panes.newTerminal")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</PaneMenu>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaneGrid({
|
||||
layout,
|
||||
mounted,
|
||||
@@ -203,6 +289,13 @@ export function PaneGrid({
|
||||
const multi = panes.length > 1;
|
||||
const full = shown.length >= MAX_PANES;
|
||||
const runOf = (slot: number) => runs.find((r) => (r.slot ?? 0) === slot);
|
||||
const paneCtx = useContextMenu<number>();
|
||||
// A header-menu item: close the menu, then act on the pane it was opened on.
|
||||
const pickPane = (fn: (slot: number) => void) => () => {
|
||||
const slot = paneCtx.menu?.item;
|
||||
paneCtx.close();
|
||||
if (slot !== undefined) fn(slot);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const end = () => {
|
||||
@@ -310,6 +403,7 @@ export function PaneGrid({
|
||||
data-testid={`pane-header-${slot}`}
|
||||
draggable
|
||||
onDragStart={(e) => startPaneDrag(e, slot)}
|
||||
onContextMenu={(e) => paneCtx.open(e, slot)}
|
||||
title={t("panes.drag")}
|
||||
className={`relative flex h-8 shrink-0 cursor-grab items-center gap-1 border-b border-border bg-surface-1 pl-1.5 pr-1 text-xs ${
|
||||
focused ? "text-fg-primary" : "text-fg-muted"
|
||||
@@ -349,61 +443,15 @@ export function PaneGrid({
|
||||
}
|
||||
</PaneMenu>
|
||||
<div className="ml-auto flex items-center">
|
||||
{(["right", "bottom"] as const).map((edge) => (
|
||||
<PaneMenu
|
||||
key={edge}
|
||||
testId={`pane-split-${edge}-${slot}`}
|
||||
label={full ? t("panes.full") : t(`panes.split.${edge}`)}
|
||||
disabled={full}
|
||||
icon={
|
||||
edge === "right" ? (
|
||||
<Columns2 className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Rows2 className="h-3.5 w-3.5" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{(close) => (
|
||||
<>
|
||||
{runs
|
||||
.filter((x) => !shown.includes(x.slot ?? 0))
|
||||
.map((x) => (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={ITEM}
|
||||
onClick={() => {
|
||||
close();
|
||||
onChange((l) => moveInLayout(l, x.slot ?? 0, slot, edge));
|
||||
}}
|
||||
>
|
||||
<RunIcon run={x} label={label(x)} />
|
||||
<span className="min-w-0 truncate">{label(x)}</span>
|
||||
</button>
|
||||
))}
|
||||
{runs.some((x) => !shown.includes(x.slot ?? 0)) && (
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid={`pane-split-new-${edge}-${slot}`}
|
||||
disabled={!canStart}
|
||||
title={canStart ? undefined : t("tabs.full")}
|
||||
className={`${ITEM} disabled:opacity-40 disabled:hover:bg-transparent`}
|
||||
onClick={() => {
|
||||
close();
|
||||
onNewSplit(slot, edge);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("panes.newTerminal")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</PaneMenu>
|
||||
))}
|
||||
<SplitMenus
|
||||
slot={slot}
|
||||
shown={shown}
|
||||
runs={runs}
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
canStart={canStart}
|
||||
onNewSplit={onNewSplit}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pane-close-${slot}`}
|
||||
@@ -423,6 +471,42 @@ export function PaneGrid({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{paneCtx.menu && (
|
||||
<ContextMenu
|
||||
x={paneCtx.menu.x}
|
||||
y={paneCtx.menu.y}
|
||||
onClose={paneCtx.close}
|
||||
testId={`pane-menu-${paneCtx.menu.item}`}
|
||||
className="w-64"
|
||||
>
|
||||
{runs
|
||||
.filter((x) => (x.slot ?? 0) !== paneCtx.menu?.item)
|
||||
.map((x) => (
|
||||
<MenuItem
|
||||
key={x.id}
|
||||
onSelect={pickPane((slot) => onChange((l) => swapInLayout(l, x.slot ?? 0, slot)))}
|
||||
>
|
||||
<RunIcon run={x} label={label(x)} />
|
||||
<span className="min-w-0 truncate">{t("panes.show", { name: label(x) })}</span>
|
||||
</MenuItem>
|
||||
))}
|
||||
{runs.length > 1 && <MenuSeparator />}
|
||||
{(["right", "bottom"] as const).map((edge) => (
|
||||
<MenuItem
|
||||
key={edge}
|
||||
testId={`pane-menu-split-${edge}`}
|
||||
disabled={full || !canStart}
|
||||
onSelect={pickPane((slot) => onNewSplit(slot, edge))}
|
||||
>
|
||||
{t(`panes.splitNew.${edge}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
|
||||
{t("panes.closeShort")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{dividers.map((d) => (
|
||||
<Divider
|
||||
key={d.path}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file RunSetup.tsx
|
||||
* @description The run-form pieces the rest of the app reuses — the pane's
|
||||
* @description The run-form pieces the rest of the app reuses (an attached
|
||||
* image's right-click offers View / Remove) — the pane's
|
||||
* own run form is gone, a new session starts from the workspace home's prompt
|
||||
* box (`WorkspaceHome.tsx`):
|
||||
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
|
||||
import type { LaneTool } from "../../lib/types";
|
||||
import { Select } from "../Select";
|
||||
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
@@ -262,6 +264,7 @@ export function PromptEditor({
|
||||
// added to the prompt only when it is sent (promptWithImages).
|
||||
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
||||
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
||||
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
||||
const attachImages = (list: FileList | null | undefined) => {
|
||||
const picked = Array.from(list ?? []).filter((f) => f.type.startsWith("image/"));
|
||||
if (picked.length === 0) return false;
|
||||
@@ -342,6 +345,7 @@ export function PromptEditor({
|
||||
aria-label={img.name}
|
||||
title={img.name}
|
||||
onClick={() => setViewing(img)}
|
||||
onContextMenu={(e) => setImageMenu({ ...menuPoint(e), img })}
|
||||
className="block h-14 w-14 overflow-hidden rounded-lg border border-border bg-surface-3 hover:border-accent/50"
|
||||
>
|
||||
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
||||
@@ -357,6 +361,27 @@ export function PromptEditor({
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{imageMenu && (
|
||||
<ContextMenu x={imageMenu.x} y={imageMenu.y} onClose={() => setImageMenu(null)}>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
setImageMenu(null);
|
||||
setViewing(imageMenu.img);
|
||||
}}
|
||||
>
|
||||
{t("drop.view")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
danger
|
||||
onSelect={() => {
|
||||
setImageMenu(null);
|
||||
removeImage(imageMenu.img);
|
||||
}}
|
||||
>
|
||||
{t("drop.remove")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* the catalog knows how. Install/Update/Update all confirm the exact command
|
||||
* first, run as ONE server job at a time and show its log under the row
|
||||
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
|
||||
* A right-click on an installed row opens its Update / Set default /
|
||||
* Enable-Disable (plus Copy path) as a menu; the buttons stay.
|
||||
* Refresh re-reads the login shell's PATH and re-detects. Icons come from
|
||||
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
|
||||
* working.
|
||||
@@ -21,6 +23,7 @@ import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { api, type AiTool, type AiToolJob } from "../../lib/api";
|
||||
|
||||
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
||||
@@ -120,6 +123,16 @@ export function AiToolsSettings() {
|
||||
const [job, setJob] = useState<AiToolJob | null>(null);
|
||||
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||
const [showAvailable, setShowAvailable] = useState(false);
|
||||
const ctx = useContextMenu<AiTool>();
|
||||
// A menu item: close the menu, then act on the tool it was opened on.
|
||||
const pick = (fn: (tool: AiTool) => unknown) => () => {
|
||||
const tool = ctx.menu?.item;
|
||||
ctx.close();
|
||||
if (tool) fn(tool);
|
||||
};
|
||||
const setDefault = (tool: AiTool) => void run(tool.id, () => update(tool.id, { default: true }));
|
||||
const toggle = (tool: AiTool) =>
|
||||
void run(tool.id, () => update(tool.id, { enabled: !tool.enabled }));
|
||||
|
||||
// A modal opened mid-job picks it up. Deferred like useAiTools' load: an
|
||||
// unreachable server (or a mocked api without it) means "no job".
|
||||
@@ -236,7 +249,12 @@ export function AiToolsSettings() {
|
||||
{installed.map((tool) => {
|
||||
const isDefault = tool.id === defaultId;
|
||||
return (
|
||||
<li key={tool.id} data-testid={`ai-tool-${tool.id}`} className="px-3 py-2.5">
|
||||
<li
|
||||
key={tool.id}
|
||||
data-testid={`ai-tool-${tool.id}`}
|
||||
onContextMenu={(e) => ctx.open(e, tool)}
|
||||
className="px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex min-w-0 flex-1 items-center gap-3 ${tool.enabled ? "" : "opacity-50"}`}
|
||||
@@ -278,26 +296,53 @@ export function AiToolsSettings() {
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tool.enabled || busy !== null}
|
||||
onClick={() => void run(tool.id, () => update(tool.id, { default: true }))}
|
||||
onClick={() => setDefault(tool)}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.setDefault")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ToolSwitch
|
||||
tool={tool}
|
||||
disabled={busy !== null}
|
||||
onToggle={() =>
|
||||
void run(tool.id, () => update(tool.id, { enabled: !tool.enabled }))
|
||||
}
|
||||
/>
|
||||
<ToolSwitch tool={tool} disabled={busy !== null} onToggle={() => toggle(tool)} />
|
||||
</div>
|
||||
{logFor(tool.id)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-tool-menu">
|
||||
{ctx.menu.item.update && (
|
||||
<MenuItem
|
||||
disabled={jobBusy}
|
||||
onSelect={pick((tool) => setConfirm({ kind: "update", tool }))}
|
||||
>
|
||||
{t("aiTools.update")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{ctx.menu.item.id !== defaultId && (
|
||||
<MenuItem
|
||||
disabled={!ctx.menu.item.enabled || busy !== null}
|
||||
onSelect={pick(setDefault)}
|
||||
>
|
||||
{t("aiTools.setDefault")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
|
||||
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
|
||||
</MenuItem>
|
||||
{ctx.menu.item.path && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
|
||||
>
|
||||
{t("rowMenu.copyPath")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{available.length > 0 && (
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
* **Show all models** switch lists every row, and a row added outside the
|
||||
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
|
||||
* rates, so only the Claude tab offers it; the server re-fetches any row the
|
||||
* automatic sync owns (OpenAI's, Gemini's) straight after.
|
||||
* automatic sync owns (OpenAI's, Gemini's) straight after. A right-click on a
|
||||
* rule row opens its Edit / Delete (plus Copy model pattern) as a menu.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -21,6 +22,7 @@ import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { getCurrentLocale } from "../../lib/format";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import type { ModelPricing, WSMessage } from "../../lib/types";
|
||||
|
||||
interface EditRow {
|
||||
@@ -215,6 +217,13 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const ctx = useContextMenu<ModelPricing>();
|
||||
// A menu item: close the menu, then act on the rule it was opened on.
|
||||
const pick = (fn: (rule: ModelPricing) => unknown) => () => {
|
||||
const rule = ctx.menu?.item;
|
||||
ctx.close();
|
||||
if (rule) fn(rule);
|
||||
};
|
||||
const [actionResult, setActionResult] = useState<{
|
||||
key: string;
|
||||
message: string;
|
||||
@@ -663,6 +672,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
||||
) : (
|
||||
<tr
|
||||
key={rule.model_pattern}
|
||||
onContextMenu={(e) => ctx.open(e, rule)}
|
||||
className="hover:bg-surface-4 transition-colors group"
|
||||
>
|
||||
<td className="whitespace-nowrap px-3 py-3 text-sm font-mono text-fg-secondary">
|
||||
@@ -767,6 +777,26 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="pricing-menu">
|
||||
<MenuItem disabled={isEditing} onSelect={pick(startEdit)}>
|
||||
{t("common:edit")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={pick((rule) => void navigator.clipboard.writeText(rule.model_pattern))}
|
||||
>
|
||||
{t("rowMenu.copyPattern")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={isEditing}
|
||||
onSelect={pick((rule) => void deleteRule(rule.model_pattern))}
|
||||
>
|
||||
{t("common:delete")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
|
||||
{lastUpdated && (
|
||||
<p className="text-xs text-fg-muted mt-3">
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* enable switch and Set default, available rows fold behind a disclosure with
|
||||
* a homepage link, Refresh re-detects, every updater gets Update without a version
|
||||
* lookup, and Install/Update confirm the exact command, then show the polled
|
||||
* job log, folded once it is done.
|
||||
* job log, folded once it is done. An installed row's right-click menu runs
|
||||
* the same Set default / enable switch as its controls.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -125,6 +126,21 @@ describe("AiToolsSettings", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("sets the default and disables a tool from its right-click menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
const gemini = await screen.findByTestId("ai-tool-gemini");
|
||||
await user.pointer({ keys: "[MouseRight]", target: gemini });
|
||||
await user.click(screen.getByRole("menuitem", { name: "Set default" }));
|
||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
|
||||
await user.pointer({ keys: "[MouseRight]", target: gemini });
|
||||
await user.click(screen.getByRole("menuitem", { name: "Disable" }));
|
||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
|
||||
// A disabled tool cannot become the default from the menu either.
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-grok") });
|
||||
expect(screen.getByRole("menuitem", { name: "Set default" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AiToolsSettings — install and update", () => {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* @description The shared Model Pricing table: each tool's tab shows its own
|
||||
* vendor's rows, **Show all models** lists the rest, a row added outside the
|
||||
* filter turns that switch on instead of vanishing, and Reset Defaults is
|
||||
* offered only where asked for (the Claude tab).
|
||||
* offered only where asked for (the Claude tab). A row's right-click menu
|
||||
* runs the same Edit / Delete as its buttons.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -107,4 +108,21 @@ describe("PricingSettings", () => {
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledWith("claude");
|
||||
});
|
||||
|
||||
it("edits and deletes a rule from its right-click menu", async () => {
|
||||
vi.mocked(api.pricing.delete)
|
||||
.mockReset()
|
||||
.mockResolvedValue({} as never);
|
||||
const user = userEvent.setup();
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
const row = (await screen.findByText("gpt-5.6-sol%")).closest("tr")!;
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
await user.click(screen.getByRole("menuitem", { name: "Delete" }));
|
||||
expect(api.pricing.delete).toHaveBeenCalledWith("gpt-5.6-sol%");
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||
await user.click(screen.getByRole("menuitem", { name: "Edit" }));
|
||||
expect(screen.getByDisplayValue("GPT-5.6 Sol")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file WorkflowRunsPanel.tsx
|
||||
* @description Surfaces dynamic Workflow-tool runs (issue #167) - fleets of
|
||||
* @description Surfaces dynamic Workflow-tool runs (issue #167; a run's
|
||||
* right-click opens its session's `SessionRowMenu`) - fleets of
|
||||
* inner sub-agents spawned by the Claude Code "Workflow" tool, ingested from
|
||||
* on-disk run journals. Works in two modes: controlled (pass `runs`, e.g. from
|
||||
* SessionDetail) or self-fetching (pass a `statusFilter`, e.g. the Workflows
|
||||
@@ -80,6 +81,8 @@ import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Workflow, ChevronRight, ChevronDown, Layers, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { useContextMenu } from "../ContextMenu";
|
||||
import { SessionRowMenu } from "../SessionRowMenu";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import type {
|
||||
WorkflowRun,
|
||||
@@ -227,6 +230,7 @@ export function WorkflowRunsPanel({
|
||||
hideSessionLink,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("workflows");
|
||||
const runCtx = useContextMenu<string>();
|
||||
const controlled = controlledRuns != null;
|
||||
|
||||
const [fetchedRuns, setFetchedRuns] = useState<WorkflowRun[]>([]);
|
||||
@@ -337,6 +341,14 @@ export function WorkflowRunsPanel({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{runCtx.menu && (
|
||||
<SessionRowMenu
|
||||
session={{ id: runCtx.menu.item }}
|
||||
x={runCtx.menu.x}
|
||||
y={runCtx.menu.y}
|
||||
onClose={runCtx.close}
|
||||
/>
|
||||
)}
|
||||
{runs.map((run) => {
|
||||
const isOpen = expanded.has(run.run_id);
|
||||
const running = run.status === "running" || run.status === "working";
|
||||
@@ -354,6 +366,7 @@ export function WorkflowRunsPanel({
|
||||
>
|
||||
<button
|
||||
onClick={() => toggle(run.run_id)}
|
||||
onContextMenu={(e) => runCtx.open(e, run.session_id)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-surface-2/30 transition-colors"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"viewSource": "View source",
|
||||
"close": "Close",
|
||||
"copyPath": "Copy path",
|
||||
"copyName": "Copy name",
|
||||
"copied": "Copied",
|
||||
"truncated": "(truncated for preview)",
|
||||
"size": "Size",
|
||||
@@ -266,6 +267,7 @@
|
||||
"saving": "Saving…",
|
||||
"renameLabel": "Profile name",
|
||||
"renameHint": "Click to rename",
|
||||
"rename": "Rename",
|
||||
"renameSuccess": "Renamed to \"{{name}}\"."
|
||||
},
|
||||
"explain": {
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
"newButton": "New",
|
||||
"viewButton": "View source",
|
||||
"editButton": "Edit",
|
||||
"deleteButton": "Delete"
|
||||
"deleteButton": "Delete",
|
||||
"copyPath": "Copy path",
|
||||
"copyName": "Copy name"
|
||||
},
|
||||
"edit": {
|
||||
"saveSuccess": "Saved · {{path}}",
|
||||
|
||||
@@ -402,5 +402,18 @@
|
||||
"panes.newTerminal": "New terminal",
|
||||
"panes.close": "Close pane (its session keeps running as a tab)",
|
||||
"panes.resize": "Resize panes",
|
||||
"tabs.home": "Workspace home"
|
||||
"tabs.home": "Workspace home",
|
||||
"sessionMenu.open": "Open",
|
||||
"sessionMenu.openSplit": "Open in split",
|
||||
"sessionMenu.details": "Session details",
|
||||
"sessionMenu.copyId": "Copy session ID",
|
||||
"sessionMenu.archive": "Archive",
|
||||
"panes.show": "Show {{name}} here",
|
||||
"panes.splitNew.right": "Split right with a new terminal",
|
||||
"panes.splitNew.bottom": "Split down with a new terminal",
|
||||
"panes.closeShort": "Close pane",
|
||||
"changes.copyPath": "Copy relative path",
|
||||
"changes.viewCommit": "View commit",
|
||||
"changes.copySha": "Copy commit SHA",
|
||||
"changes.copySubject": "Copy commit message"
|
||||
}
|
||||
|
||||
@@ -64,7 +64,9 @@
|
||||
"statusBar": {
|
||||
"running": "{{count}} running",
|
||||
"needYou": "{{count}} need you",
|
||||
"today": "Today {{cost}}"
|
||||
"today": "Today {{cost}}",
|
||||
"workspace": "Workspace {{cost}}",
|
||||
"session": "Session {{cost}}"
|
||||
},
|
||||
"version": "Version"
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"uploading": "Uploading image…",
|
||||
"failed": "Could not attach the image",
|
||||
"imagesOnly": "Only images can be dropped here",
|
||||
"remove": "Remove image"
|
||||
"remove": "Remove image",
|
||||
"view": "View image"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,5 +40,11 @@
|
||||
"transcriptNotFound": "Conversation transcript not found for this agent. The transcript file may be missing or not yet linked."
|
||||
},
|
||||
"remoteSourceBadgeTitle": "Collected from a remote machine",
|
||||
"toolBadgeTitle": "Recorded by {{tool}}"
|
||||
"toolBadgeTitle": "Recorded by {{tool}}",
|
||||
"menu": {
|
||||
"details": "Open session details",
|
||||
"inWorkspace": "Open in its workspace",
|
||||
"copyId": "Copy session ID",
|
||||
"copyFolder": "Copy folder path"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,5 +735,13 @@
|
||||
"en": "English",
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"rowMenu": {
|
||||
"copyHost": "Copy host",
|
||||
"copyUrl": "Copy endpoint",
|
||||
"copyPattern": "Copy model pattern",
|
||||
"copyPath": "Copy path",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"viewSource": "Xem nguồn",
|
||||
"close": "Đóng",
|
||||
"copyPath": "Sao chép đường dẫn",
|
||||
"copyName": "Sao chép tên",
|
||||
"copied": "Đã sao chép",
|
||||
"truncated": "(đã cắt bớt để xem trước)",
|
||||
"size": "Kích thước",
|
||||
@@ -261,6 +262,7 @@
|
||||
"saving": "Đang lưu…",
|
||||
"renameLabel": "Tên profile",
|
||||
"renameHint": "Bấm để đổi tên",
|
||||
"rename": "Đổi tên",
|
||||
"renameSuccess": "Đã đổi tên thành \"{{name}}\"."
|
||||
},
|
||||
"explain": {
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
"newButton": "Mới",
|
||||
"viewButton": "Xem nguồn",
|
||||
"editButton": "Sửa",
|
||||
"deleteButton": "Xóa"
|
||||
"deleteButton": "Xóa",
|
||||
"copyPath": "Sao chép đường dẫn",
|
||||
"copyName": "Sao chép tên"
|
||||
},
|
||||
"edit": {
|
||||
"saveSuccess": "Đã lưu · {{path}}",
|
||||
|
||||
@@ -402,5 +402,18 @@
|
||||
"panes.newTerminal": "Terminal mới",
|
||||
"panes.close": "Đóng pane (session vẫn chạy dưới dạng tab)",
|
||||
"panes.resize": "Đổi kích thước pane",
|
||||
"tabs.home": "Trang chủ workspace"
|
||||
"tabs.home": "Trang chủ workspace",
|
||||
"sessionMenu.open": "Mở",
|
||||
"sessionMenu.openSplit": "Mở ở pane chia đôi",
|
||||
"sessionMenu.details": "Chi tiết session",
|
||||
"sessionMenu.copyId": "Sao chép ID session",
|
||||
"sessionMenu.archive": "Lưu trữ",
|
||||
"panes.show": "Hiện {{name}} ở đây",
|
||||
"panes.splitNew.right": "Chia sang phải với terminal mới",
|
||||
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
|
||||
"panes.closeShort": "Đóng pane",
|
||||
"changes.copyPath": "Sao chép đường dẫn tương đối",
|
||||
"changes.viewCommit": "Xem commit",
|
||||
"changes.copySha": "Sao chép SHA của commit",
|
||||
"changes.copySubject": "Sao chép commit message"
|
||||
}
|
||||
|
||||
@@ -64,7 +64,9 @@
|
||||
"statusBar": {
|
||||
"running": "{{count}} đang chạy",
|
||||
"needYou": "{{count}} cần bạn",
|
||||
"today": "Hôm nay {{cost}}"
|
||||
"today": "Hôm nay {{cost}}",
|
||||
"workspace": "Workspace {{cost}}",
|
||||
"session": "Session {{cost}}"
|
||||
},
|
||||
"version": "Phiên bản"
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
"uploading": "Đang tải ảnh lên…",
|
||||
"failed": "Không đính kèm được ảnh",
|
||||
"imagesOnly": "Chỉ thả được ảnh vào đây",
|
||||
"remove": "Bỏ ảnh"
|
||||
"remove": "Bỏ ảnh",
|
||||
"view": "Xem ảnh"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,5 +39,11 @@
|
||||
"timeline": "Dòng thời gian ({{shown}}/{{total}})",
|
||||
"transcriptNotFound": "Không tìm thấy bản ghi hội thoại cho agent này. Tệp transcript có thể bị thiếu hoặc chưa được liên kết."
|
||||
},
|
||||
"toolBadgeTitle": "Ghi lại bởi {{tool}}"
|
||||
"toolBadgeTitle": "Ghi lại bởi {{tool}}",
|
||||
"menu": {
|
||||
"details": "Mở chi tiết session",
|
||||
"inWorkspace": "Mở trong workspace của nó",
|
||||
"copyId": "Sao chép ID session",
|
||||
"copyFolder": "Sao chép đường dẫn thư mục"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,5 +735,13 @@
|
||||
"en": "English",
|
||||
"vi": "Tiếng Việt"
|
||||
}
|
||||
},
|
||||
"rowMenu": {
|
||||
"copyHost": "Sao chép host",
|
||||
"copyUrl": "Sao chép endpoint",
|
||||
"copyPattern": "Sao chép mẫu mô hình",
|
||||
"copyPath": "Sao chép đường dẫn",
|
||||
"enable": "Bật",
|
||||
"disable": "Tắt"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,13 +737,15 @@ export const api = {
|
||||
* @param id The session id.
|
||||
* @returns `{ session, agents, events, workflows }` for the detail view.
|
||||
*/
|
||||
get: (id: string) =>
|
||||
/** `events: false` sends `?events=0`: `events` comes back empty instead of
|
||||
* every event of the session with its full payload. */
|
||||
get: (id: string, opts: { events?: boolean } = {}) =>
|
||||
request<{
|
||||
session: Session;
|
||||
agents: Agent[];
|
||||
events: DashboardEvent[];
|
||||
workflows: WorkflowRun[];
|
||||
}>(`/sessions/${encodeURIComponent(id)}`),
|
||||
}>(`/sessions/${encodeURIComponent(id)}${opts.events === false ? "?events=0" : ""}`),
|
||||
/**
|
||||
* GET /api/sessions/:id/stats - per-session rollups for the detail page.
|
||||
*
|
||||
@@ -1334,11 +1336,13 @@ export const api = {
|
||||
*
|
||||
* @returns {@link CostResult} — the aggregate cost breakdown across sessions.
|
||||
*/
|
||||
totalCost: () => {
|
||||
totalCost: (opts: { lane?: number } = {}) => {
|
||||
// Scope the aggregate to the active data-scope, exactly like the sessions /
|
||||
// stats / analytics endpoints — otherwise switching the Data scope selector
|
||||
// left the Dashboard "total cost" showing the un-narrowed global total.
|
||||
// `lane`: only the sessions that workspace owns.
|
||||
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
|
||||
if (opts.lane !== undefined) qs.set("lane", String(opts.lane));
|
||||
applyScope(qs);
|
||||
return request<CostResult>(`/pricing/cost?${qs.toString()}`);
|
||||
},
|
||||
|
||||
@@ -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