feat: right-click menus everywhere, workspace/session cost, long-term DB safety

Right-click menus: one portal shell (components/ContextMenu.tsx) for every
item with actions - sidebar/Recents/archived session rows (SessionMenu),
console tabs, pane headers, the Changes panel's files/tree/commits, the
workspace home, sessions outside a workspace (SessionRowMenu: Sessions
table, Dashboard, Activity, Workflow runs), prompt images, every Settings
list and every Claude Code/Codex config list (FileItemMenu). Buttons stay;
a delete keeps its button's confirm; the shell stops portal clicks from
reaching the card that opened it.

Status bar: Workspace $ and Session $ on the Workspace page, from
?lane= on GET /api/pricing/cost and the focused/replayed session's cost.

Database, no data dropped: synchronous=NORMAL, journal_size_limit 64 MB,
hourly PASSIVE checkpoint + PRAGMA optimize, idx_events_session_created
(replacing the redundant idx_events_session), watchdog reads
sessions.transcript_path, transcript parsed before the hook's write
transaction, GET /api/sessions/:id?events=0 for the web page (reloads only
on its own session, debounced), streamed export, transactional clear-data
and chunked cleanup, subagent import reads tool_use_ids once, runtime logs
rotated past 5 MB.
This commit is contained in:
2026-10-05 09:37:48 +07:00
parent 65fe680d38
commit ad6e80192d
80 changed files with 2914 additions and 489 deletions
+13 -1
View File
@@ -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>
)}
+135
View File
@@ -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" />;
}
+39 -2
View File
@@ -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>
+50
View File
@@ -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>
);
}
+54 -6
View File
@@ -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>
+51 -28
View File
@@ -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") &&
+26 -1
View File
@@ -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>
)}
+2 -1
View File
@@ -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: [
+95 -5
View File
@@ -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);
});
});
+18 -1
View File
@@ -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.
+102
View File
@@ -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>
);
}
+89 -11
View File
@@ -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", () => {
+41 -1
View File
@@ -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>
);
}
+141 -57
View File
@@ -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}
+26 -1
View File
@@ -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}
>