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:
@@ -71,6 +71,10 @@
|
|||||||
# Set to 0 to disable the poll while leaving the watcher running (default: 30000).
|
# Set to 0 to disable the poll while leaving the watcher running (default: 30000).
|
||||||
# DASHBOARD_SESSION_SYNC_MS=30000
|
# DASHBOARD_SESSION_SYNC_MS=30000
|
||||||
|
|
||||||
|
# Hourly database upkeep: a non-blocking WAL checkpoint (the WAL then shrinks
|
||||||
|
# to 64 MB) and PRAGMA optimize. 0 turns it off. Never VACUUMs.
|
||||||
|
# DASHBOARD_DB_MAINTAIN_MS=3600000
|
||||||
|
|
||||||
# Poll interval (ms) for Antigravity CLI sessions and tokens, read from agy's
|
# Poll interval (ms) for Antigravity CLI sessions and tokens, read from agy's
|
||||||
# own ~/.gemini/antigravity-cli/conversations/*.db (0 disables; default: 15000).
|
# own ~/.gemini/antigravity-cli/conversations/*.db (0 disables; default: 15000).
|
||||||
# DASHBOARD_ANTIGRAVITY_POLL_MS=15000
|
# DASHBOARD_ANTIGRAVITY_POLL_MS=15000
|
||||||
|
|||||||
+2
-2
@@ -905,11 +905,11 @@ erDiagram
|
|||||||
| ---------------------- | -------- | ----------------- | ------------------------------ |
|
| ---------------------- | -------- | ----------------- | ------------------------------ |
|
||||||
| `idx_agents_session` | agents | `session_id` | Fast agent lookup by session |
|
| `idx_agents_session` | agents | `session_id` | Fast agent lookup by session |
|
||||||
| `idx_agents_status` | agents | `status` | Kanban board column queries |
|
| `idx_agents_status` | agents | `status` | Kanban board column queries |
|
||||||
| `idx_events_session` | events | `session_id` | Session detail event list |
|
| `idx_events_session_created` | events | `session_id, created_at` | A session's newest event / time order (watchdog every 15 s, every lane payload); replaced `idx_events_session`, a redundant prefix of `idx_events_session_type` |
|
||||||
| `idx_events_type` | events | `event_type` | Filter events by type |
|
| `idx_events_type` | events | `event_type` | Filter events by type |
|
||||||
| `idx_events_created` | events | `created_at DESC` | Activity feed ordering |
|
| `idx_events_created` | events | `created_at DESC` | Activity feed ordering |
|
||||||
| `idx_events_session_type` | events | `session_id, event_type` | Per-session event-type filters |
|
| `idx_events_session_type` | events | `session_id, event_type` | Per-session event-type filters |
|
||||||
| `idx_events_agent_type` | events | `agent_id, event_type` | Keeps `importSubagentFromJsonl`'s per-tool-event `data LIKE` dedup an index seek instead of a full events scan — a large re-import (startup sweep touching a subagent-heavy session) drops from tens of seconds to sub-second |
|
| `idx_events_agent_type` | events | `agent_id, event_type` | `importSubagentFromJsonl` reads the agent's existing `tool_use_id`s once through it (it used to run a `data LIKE` per tool event) |
|
||||||
| `idx_sessions_status` | sessions | `status` | Status filter on Sessions page and Kanban Sessions view |
|
| `idx_sessions_status` | sessions | `status` | Status filter on Sessions page and Kanban Sessions view |
|
||||||
| `idx_sessions_started` | sessions | `started_at DESC` | Default sort order |
|
| `idx_sessions_started` | sessions | `started_at DESC` | Default sort order |
|
||||||
| `idx_sessions_source` | sessions | `source` | Data-scope (`?sources=`) filtering by source |
|
| `idx_sessions_source` | sessions | `source` | Data-scope (`?sources=`) filtering by source |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file AgentCard.tsx
|
* @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>
|
* @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 { Bot, GitBranch, Clock, Wrench, Cpu, Coins } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { AgentStatusBadge } from "./StatusBadge";
|
import { AgentStatusBadge } from "./StatusBadge";
|
||||||
|
import { useContextMenu } from "./ContextMenu";
|
||||||
|
import { SessionRowMenu } from "./SessionRowMenu";
|
||||||
import { effectiveAgentStatus, isAgentAwaitingInput, agentAwaitingReason } from "../lib/types";
|
import { effectiveAgentStatus, isAgentAwaitingInput, agentAwaitingReason } from "../lib/types";
|
||||||
import type { Agent, Session } from "../lib/types";
|
import type { Agent, Session } from "../lib/types";
|
||||||
import { formatDuration, timeAgo, formatModelName, pathBasename, fmtCost } from "../lib/format";
|
import { formatDuration, timeAgo, formatModelName, pathBasename, fmtCost } from "../lib/format";
|
||||||
@@ -83,6 +85,7 @@ interface AgentCardProps {
|
|||||||
export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation("kanban");
|
const { t } = useTranslation("kanban");
|
||||||
|
const ctx = useContextMenu<true>();
|
||||||
const isWaiting = agent.status === "waiting" || isAgentAwaitingInput(agent);
|
const isWaiting = agent.status === "waiting" || isAgentAwaitingInput(agent);
|
||||||
const status = effectiveAgentStatus(agent);
|
const status = effectiveAgentStatus(agent);
|
||||||
const isActive = agent.status === "working";
|
const isActive = agent.status === "working";
|
||||||
@@ -170,6 +173,7 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
onContextMenu={(e) => ctx.open(e, true)}
|
||||||
className={`card-hover p-4 cursor-pointer overflow-hidden ${
|
className={`card-hover p-4 cursor-pointer overflow-hidden ${
|
||||||
isWaiting
|
isWaiting
|
||||||
? "border-l-2 border-l-yellow-500/60"
|
? "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 className="font-mono flex-shrink-0">{agent.session_id.slice(0, 8)}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* • Activity - the live fired-alert feed with acknowledge controls
|
* • Activity - the live fired-alert feed with acknowledge controls
|
||||||
* Tab badges reflect live state (rule count, unacked alert count), and the feed
|
* Tab badges reflect live state (rule count, unacked alert count), and the feed
|
||||||
* + counts refetch on alert_triggered / alert_updated WebSocket messages.
|
* + 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -85,6 +86,7 @@ import { WebhookSettings } from "./WebhookSettings";
|
|||||||
import { ConfirmModal } from "./ConfirmModal";
|
import { ConfirmModal } from "./ConfirmModal";
|
||||||
import { Checkbox } from "./Checkbox";
|
import { Checkbox } from "./Checkbox";
|
||||||
import { FieldHelp } from "./FieldHelp";
|
import { FieldHelp } from "./FieldHelp";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
|
||||||
import { timeAgo } from "../lib/format";
|
import { timeAgo } from "../lib/format";
|
||||||
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
|
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
|
||||||
|
|
||||||
@@ -218,6 +220,13 @@ export function AlertsNotifications() {
|
|||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
|
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
|
// Feed
|
||||||
const [alerts, setAlerts] = useState<AlertEvent[]>([]);
|
const [alerts, setAlerts] = useState<AlertEvent[]>([]);
|
||||||
@@ -670,6 +679,7 @@ export function AlertsNotifications() {
|
|||||||
{rules.map((rule) => (
|
{rules.map((rule) => (
|
||||||
<li
|
<li
|
||||||
key={rule.id}
|
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"
|
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">
|
<div className="min-w-0">
|
||||||
@@ -713,6 +723,22 @@ export function AlertsNotifications() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* @file ContextMenu — the one shell every right-click (and `⋯`) menu opens
|
||||||
|
* in: a fixed list at a viewport point, rendered into `document.body` so no
|
||||||
|
* transformed or clipped ancestor moves it, nudged back inside the viewport
|
||||||
|
* once measured, closed by Escape, a press outside it, or after an item runs.
|
||||||
|
* `useContextMenu` keeps "which item, where" for a list of rows; `menuPoint`
|
||||||
|
* turns a mouse event into that point (and stops the browser's own menu).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||||
|
|
||||||
|
export type MenuPoint = { x: number; y: number };
|
||||||
|
|
||||||
|
/** The pointer of a right-click, the browser's own menu suppressed. */
|
||||||
|
export function menuPoint(e: ReactMouseEvent): MenuPoint {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
return { x: e.clientX, y: e.clientY };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Menu state for a list of rows: which row's menu is open, and where. */
|
||||||
|
export function useContextMenu<T>() {
|
||||||
|
const [menu, setMenu] = useState<(MenuPoint & { item: T }) | null>(null);
|
||||||
|
const open = useCallback((e: ReactMouseEvent, item: T) => {
|
||||||
|
setMenu({ ...menuPoint(e), item });
|
||||||
|
}, []);
|
||||||
|
const close = useCallback(() => setMenu(null), []);
|
||||||
|
return { menu, open, close };
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARGIN = 8;
|
||||||
|
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
|
||||||
|
|
||||||
|
export function ContextMenu({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
onClose,
|
||||||
|
testId,
|
||||||
|
className = "w-56",
|
||||||
|
children,
|
||||||
|
}: MenuPoint & {
|
||||||
|
onClose: () => void;
|
||||||
|
testId?: string;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [pos, setPos] = useState({ left: x, top: y });
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const r = ref.current?.getBoundingClientRect();
|
||||||
|
if (!r) return;
|
||||||
|
setPos({
|
||||||
|
left: Math.max(MARGIN, Math.min(x, window.innerWidth - r.width - MARGIN)),
|
||||||
|
top: Math.max(MARGIN, Math.min(y, window.innerHeight - r.height - MARGIN)),
|
||||||
|
});
|
||||||
|
}, [x, y]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (!ref.current?.contains(e.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
window.addEventListener("blur", onClose);
|
||||||
|
window.addEventListener("resize", onClose);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDown);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
window.removeEventListener("blur", onClose);
|
||||||
|
window.removeEventListener("resize", onClose);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="menu"
|
||||||
|
data-testid={testId}
|
||||||
|
style={pos}
|
||||||
|
// A portal still bubbles React events to the component that rendered
|
||||||
|
// it — a menu opened from a clickable card must not click the card too.
|
||||||
|
onClick={stop}
|
||||||
|
onDoubleClick={stop}
|
||||||
|
onMouseDown={stop}
|
||||||
|
onPointerDown={stop}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className={`fixed z-50 rounded-lg border border-border bg-surface-2 p-1 shadow-lg ${className}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MENU_ITEM =
|
||||||
|
"flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary disabled:pointer-events-none disabled:opacity-50";
|
||||||
|
|
||||||
|
export function MenuItem({
|
||||||
|
onSelect,
|
||||||
|
danger,
|
||||||
|
disabled,
|
||||||
|
testId,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onSelect: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
testId?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
data-testid={testId}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MenuSeparator() {
|
||||||
|
return <div role="separator" className="my-1 border-t border-border" />;
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
* here — authentication defers to the host's SSH stack (~/.ssh/config, agent,
|
* here — authentication defers to the host's SSH stack (~/.ssh/config, agent,
|
||||||
* keys, known_hosts); a source is just a label + ssh destination (+ optional
|
* keys, known_hosts); a source is just a label + ssh destination (+ optional
|
||||||
* port / identity file / remote home). Live status/sync updates arrive over the
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { Checkbox } from "./Checkbox";
|
import { Checkbox } from "./Checkbox";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -135,6 +137,13 @@ export function RemoteSources() {
|
|||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [busyId, setBusyId] = 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 [syncingAll, setSyncingAll] = useState(false);
|
||||||
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; message: string }>>(
|
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; message: string }>>(
|
||||||
{}
|
{}
|
||||||
@@ -598,7 +607,7 @@ export function RemoteSources() {
|
|||||||
const test = testResults[s.id];
|
const test = testResults[s.id];
|
||||||
const busy = busyId === s.id;
|
const busy = busyId === s.id;
|
||||||
return (
|
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="flex items-start justify-between gap-3 flex-wrap">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -753,6 +762,34 @@ export function RemoteSources() {
|
|||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* @file SessionRowMenu — the right-click menu of a session anywhere outside a
|
||||||
|
* workspace's own lists (the Sessions table, the Dashboard's session and agent
|
||||||
|
* cards): open its analysis page, open it in the workspace that owns it
|
||||||
|
* (`sessions.lane_id` — an ended session opens its conversation there, never
|
||||||
|
* a resume), copy its id or folder. Inside a workspace, `lanes/SessionMenu`
|
||||||
|
* is used instead.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "./ContextMenu";
|
||||||
|
|
||||||
|
export type SessionRowRef = { id: string; lane_id?: number | null; cwd?: string | null };
|
||||||
|
|
||||||
|
export function SessionRowMenu({
|
||||||
|
session,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
onClose,
|
||||||
|
}: MenuPoint & { session: SessionRowRef; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation("sessions");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const pick = (fn: () => unknown) => () => {
|
||||||
|
onClose();
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
const copy = (text: string) => pick(() => void navigator.clipboard.writeText(text));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu x={x} y={y} onClose={onClose} testId={`session-row-menu-${session.id}`}>
|
||||||
|
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
||||||
|
{t("menu.details")}
|
||||||
|
</MenuItem>
|
||||||
|
{session.lane_id != null && (
|
||||||
|
<MenuItem
|
||||||
|
onSelect={pick(() =>
|
||||||
|
navigate(`/workspace?lane=${session.lane_id}&session=${encodeURIComponent(session.id)}`)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("menu.inWorkspace")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem onSelect={copy(session.id)}>{t("menu.copyId")}</MenuItem>
|
||||||
|
{session.cwd && <MenuItem onSelect={copy(session.cwd)}>{t("menu.copyFolder")}</MenuItem>}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,17 +5,23 @@
|
|||||||
* — with a green dot while a newer release is out —, how many workspaces are running and how
|
* — 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
|
* 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
|
* 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
|
* `GET /api/pricing/cost`, the Dashboard's own cost call — and, on the
|
||||||
* while the tab is visible. No provider quota: reading Claude's quota rotates
|
* 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.
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
import { Wifi, WifiOff } from "lucide-react";
|
import { Wifi, WifiOff } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api, type RunHandle } from "../lib/api";
|
||||||
import { useLanes } from "../hooks/useLanes";
|
import { useLanes } from "../hooks/useLanes";
|
||||||
|
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
||||||
|
|
||||||
const COST_POLL_MS = 30_000;
|
const COST_POLL_MS = 30_000;
|
||||||
|
|
||||||
@@ -42,23 +48,55 @@ export function StatusBar({
|
|||||||
const running = lanes.filter((l) => l.status === "running").length;
|
const running = lanes.filter((l) => l.status === "running").length;
|
||||||
const needYou = lanes.filter((l) => l.needs_action).length;
|
const needYou = lanes.filter((l) => l.needs_action).length;
|
||||||
const [today, setToday] = useState<number | null>(null);
|
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(() => {
|
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 = () => {
|
const tick = () => {
|
||||||
if (document.visibilityState !== "visible") return;
|
if (document.visibilityState !== "visible") return;
|
||||||
api.pricing
|
api.pricing
|
||||||
.totalCost()
|
.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);
|
.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();
|
tick();
|
||||||
const id = setInterval(tick, COST_POLL_MS);
|
const id = setInterval(tick, COST_POLL_MS);
|
||||||
document.addEventListener("visibilitychange", tick);
|
document.addEventListener("visibilitychange", tick);
|
||||||
return () => {
|
return () => {
|
||||||
|
live = false;
|
||||||
clearInterval(id);
|
clearInterval(id);
|
||||||
document.removeEventListener("visibilitychange", tick);
|
document.removeEventListener("visibilitychange", tick);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [lane, sessionId]);
|
||||||
|
const usd = (n: number) => `$${n.toFixed(2)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
@@ -94,7 +132,17 @@ export function StatusBar({
|
|||||||
</span>
|
</span>
|
||||||
{today !== null && (
|
{today !== null && (
|
||||||
<span data-testid="status-bar-cost" className="tabular-nums">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
* next `title=` someone writes would be back to the browser's grey box.
|
* next `title=` someone writes would be back to the browser's grey box.
|
||||||
* {@link GlobalTooltip} instead listens once at the document: on hover or
|
* {@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
|
* 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
|
* 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.
|
* 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
|
* 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 Anchored = { text: string; rect: DOMRect };
|
||||||
type Placed = { left: number; top: number; arrow: number; above: boolean };
|
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
|
* Mount ONCE, near the router root. Every `title` in the app becomes a
|
||||||
* designed tooltip; nothing else has to change.
|
* 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
|
// put back even if the pointer never leaves it (a route change, a re-render
|
||||||
// that unmounts it, Escape).
|
// that unmounts it, Escape).
|
||||||
const parked = useRef<HTMLElement | null>(null);
|
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);
|
const timer = useRef<number | undefined>(undefined);
|
||||||
// The element whose delay is running, so re-entering it does not restart it.
|
// The element whose delay is running, so re-entering it does not restart it.
|
||||||
const pending = useRef<HTMLElement | null>(null);
|
const pending = useRef<HTMLElement | null>(null);
|
||||||
@@ -69,19 +106,11 @@ export function GlobalTooltip() {
|
|||||||
const restore = useCallback(() => {
|
const restore = useCallback(() => {
|
||||||
window.clearTimeout(timer.current);
|
window.clearTimeout(timer.current);
|
||||||
pending.current = null;
|
pending.current = null;
|
||||||
|
silenced.current.forEach(unpark);
|
||||||
|
silenced.current = [];
|
||||||
const el = parked.current;
|
const el = parked.current;
|
||||||
if (el) {
|
if (el) {
|
||||||
// Drop the instance shim `open` installed; the prototype method is back.
|
unpark(el);
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (el.dataset.tipAria !== undefined) {
|
if (el.dataset.tipAria !== undefined) {
|
||||||
el.removeAttribute("aria-label");
|
el.removeAttribute("aria-label");
|
||||||
delete el.dataset.tipAria;
|
delete el.dataset.tipAria;
|
||||||
@@ -105,22 +134,16 @@ export function GlobalTooltip() {
|
|||||||
if (!el.isConnected) return;
|
if (!el.isConnected) return;
|
||||||
const current = (el.getAttribute("title") || "").trim();
|
const current = (el.getAttribute("title") || "").trim();
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
el.dataset.tip = current;
|
park(el, current, restore);
|
||||||
el.removeAttribute("title");
|
// With its own title gone the browser shows the nearest ANCESTOR's
|
||||||
// React removing `title` while it is parked calls removeAttribute on an
|
// native one instead (a pane header's drag hint over its Close
|
||||||
// attribute that is already gone: no MutationRecord, nothing to see —
|
// button's bubble), so every ancestor title is parked too.
|
||||||
// and `restore` would write the stale text back for good. So, on THIS
|
for (let a = el.parentElement; a; a = a.parentElement) {
|
||||||
// element and only while parked, that call is taken as React's intent:
|
const t = a.getAttribute("title");
|
||||||
// the parked copy is dropped and the bubble hides. (React DOM calls
|
if (t === null) continue;
|
||||||
// `node.removeAttribute`, the instance method; `restore` deletes the
|
park(a, t);
|
||||||
// shim.) No global prototype is touched.
|
silenced.current.push(a);
|
||||||
el.removeAttribute = (name: string) => {
|
}
|
||||||
if (name.toLowerCase() === "title" && el.dataset.tip !== undefined) {
|
|
||||||
delete el.dataset.tip;
|
|
||||||
restore();
|
|
||||||
}
|
|
||||||
Element.prototype.removeAttribute.call(el, name);
|
|
||||||
};
|
|
||||||
// The title was an icon-only control's ONLY name; keep it named.
|
// The title was an icon-only control's ONLY name; keep it named.
|
||||||
if (
|
if (
|
||||||
!el.hasAttribute("aria-label") &&
|
!el.hasAttribute("aria-label") &&
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
* fetched from the server (`/api/webhooks/providers`): each provider declares
|
* fetched from the server (`/api/webhooks/providers`): each provider declares
|
||||||
* whether it needs a URL and which credential fields to render, so adding a
|
* 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
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -82,6 +83,7 @@ import { api } from "../lib/api";
|
|||||||
import { Select } from "./Select";
|
import { Select } from "./Select";
|
||||||
import { ConfirmModal } from "./ConfirmModal";
|
import { ConfirmModal } from "./ConfirmModal";
|
||||||
import { Checkbox } from "./Checkbox";
|
import { Checkbox } from "./Checkbox";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
|
||||||
import { WEBHOOK_DOCS } from "./webhookGuides";
|
import { WEBHOOK_DOCS } from "./webhookGuides";
|
||||||
import { timeAgo } from "../lib/format";
|
import { timeAgo } from "../lib/format";
|
||||||
import type {
|
import type {
|
||||||
@@ -144,6 +146,13 @@ export function WebhookSettings() {
|
|||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [testing, setTesting] = useState<string | null>(null);
|
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 [testResult, setTestResult] = useState<Record<string, WebhookTestResult>>({});
|
||||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||||
const [guideOpen, setGuideOpen] = useState(false);
|
const [guideOpen, setGuideOpen] = useState(false);
|
||||||
@@ -388,6 +397,7 @@ export function WebhookSettings() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={target.id}
|
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"
|
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">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
@@ -482,6 +492,21 @@ export function WebhookSettings() {
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ import { AddLaneModal } from "./lanes/AddLaneModal";
|
|||||||
import { DestructiveLaneModal } from "./lanes/DestructiveLaneModal";
|
import { DestructiveLaneModal } from "./lanes/DestructiveLaneModal";
|
||||||
|
|
||||||
/** The workspace (and attached run) the Workspace page shows, from its URL. */
|
/** 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 };
|
if (!pathname.startsWith("/workspace")) return { lane: null, run: null, session: null };
|
||||||
const p = new URLSearchParams(search);
|
const p = new URLSearchParams(search);
|
||||||
const v = p.get("lane");
|
const v = p.get("lane");
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
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
|
// 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 { AgentCard } from "../AgentCard";
|
||||||
import type { Agent } from "../../lib/types";
|
import type { Agent } from "../../lib/types";
|
||||||
import { formatModelName, fmtCost } from "../../lib/format";
|
import { formatModelName, fmtCost } from "../../lib/format";
|
||||||
@@ -325,4 +326,42 @@ describe("AgentCard", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByText(/ran 5m 30s/)).toBeInTheDocument();
|
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
|
* @file StatusBar.test.tsx
|
||||||
* @description Tests for the shell's bottom status bar: the live/disconnected
|
* @description Tests for the shell's bottom status bar: the live/disconnected
|
||||||
* state with the version (click opens the connection details), the running and
|
* 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
|
* needs-you counts from the shared lane list, today's spend from the
|
||||||
* pricing cost call's `daily_costs`.
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi } from "vitest";
|
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";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
function today() {
|
function today() {
|
||||||
@@ -30,25 +32,35 @@ vi.mock("../../lib/api", () => ({
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
pricing: {
|
pricing: {
|
||||||
totalCost: vi.fn().mockImplementation(async () => ({
|
totalCost: vi.fn().mockImplementation(async (opts?: { lane?: number }) => ({
|
||||||
total_cost: 10,
|
total_cost: opts?.lane ? 123.456 : 10,
|
||||||
breakdown: [],
|
breakdown: [],
|
||||||
daily_costs: [
|
daily_costs: [
|
||||||
{ date: "2000-01-01", cost: 9 },
|
{ date: "2000-01-01", cost: 9 },
|
||||||
{ date: today(), cost: 1.234 },
|
{ 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 { StatusBar } from "../StatusBar";
|
||||||
|
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
||||||
|
|
||||||
|
const at = (url: string, ui: JSX.Element) =>
|
||||||
|
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
|
||||||
|
|
||||||
describe("StatusBar", () => {
|
describe("StatusBar", () => {
|
||||||
it("shows the connection with the version, the counts and today's spend", async () => {
|
it("shows the connection with the version, the counts and today's spend", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onConnectionClick = vi.fn();
|
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("Live")).toBeInTheDocument();
|
||||||
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument();
|
expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument();
|
||||||
expect(await screen.findByText("Today $1.23")).toBeInTheDocument();
|
expect(await screen.findByText("Today $1.23")).toBeInTheDocument();
|
||||||
@@ -58,7 +70,41 @@ describe("StatusBar", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("says Disconnected when the socket is down", () => {
|
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();
|
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);
|
fireEvent.scroll(document);
|
||||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
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" }],
|
items: [{ id: "agenthub-lane-1-2", laneId: 1, status: "running", sessionId: "s-live" }],
|
||||||
})),
|
})),
|
||||||
cwds: vi.fn(async () => ({ items: [] })),
|
cwds: vi.fn(async () => ({ items: [] })),
|
||||||
|
kill: vi.fn(async () => ({ ok: true })),
|
||||||
},
|
},
|
||||||
aiTools: {
|
aiTools: {
|
||||||
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
||||||
@@ -163,6 +164,40 @@ describe("WorkspaceTree", () => {
|
|||||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
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 () => {
|
it("archiving a worktree session removes its worktree first, after asking", async () => {
|
||||||
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||||
sessions: [
|
sessions: [
|
||||||
|
|||||||
@@ -3,13 +3,23 @@
|
|||||||
* @description Generic list + card for markdown-frontmatter config artifacts
|
* @description Generic list + card for markdown-frontmatter config artifacts
|
||||||
* (skills, agents, commands, output styles). Tool-agnostic: callers pass the
|
* (skills, agents, commands, output styles). Tool-agnostic: callers pass the
|
||||||
* items and the open/edit/delete handlers, nothing here reaches for an API.
|
* 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>
|
* @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 { useTranslation } from "react-i18next";
|
||||||
import { ExternalLink, Pencil, Trash2 } from "lucide-react";
|
import { ExternalLink, Pencil, Trash2 } from "lucide-react";
|
||||||
import type { CcArtifactType, CcMdItem } from "../../lib/api";
|
import type { CcArtifactType, CcMdItem } from "../../lib/api";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
MenuItem,
|
||||||
|
MenuSeparator,
|
||||||
|
useContextMenu,
|
||||||
|
type MenuPoint,
|
||||||
|
} from "../ContextMenu";
|
||||||
import { ScopeBadge } from "./ScopeBadge";
|
import { ScopeBadge } from "./ScopeBadge";
|
||||||
import { Empty, SkeletonRows } from "./configPrimitives";
|
import { Empty, SkeletonRows } from "./configPrimitives";
|
||||||
|
|
||||||
@@ -30,7 +40,65 @@ interface MdItemListProps {
|
|||||||
kind: "skills" | "agents" | "commands" | "outputStyles";
|
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) {
|
export function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: MdItemListProps) {
|
||||||
|
const ctx = useContextMenu<CcMdItem>();
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!items) return null;
|
if (!items) return null;
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
@@ -56,9 +124,27 @@ export function MdItemList({ items, search, onOpen, onEdit, onDelete, kind }: Md
|
|||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
onMenu={ctx.open}
|
||||||
kind={kind}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -76,14 +162,15 @@ interface MdItemCardProps {
|
|||||||
name: string | undefined,
|
name: string | undefined,
|
||||||
path: string
|
path: string
|
||||||
) => void;
|
) => void;
|
||||||
|
onMenu: (e: MouseEvent, item: CcMdItem) => void;
|
||||||
kind: "skills" | "agents" | "commands" | "outputStyles";
|
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 { 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 =
|
const description =
|
||||||
item.frontmatter.description ||
|
item.frontmatter.description ||
|
||||||
item.preview
|
item.preview
|
||||||
@@ -91,7 +178,10 @@ function MdItemCard({ item, onOpen, onEdit, onDelete, kind }: MdItemCardProps) {
|
|||||||
.trim()
|
.trim()
|
||||||
.slice(0, 200);
|
.slice(0, 200);
|
||||||
return (
|
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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* @file Covers MdItemList's right-click menu: each entry calls the same
|
||||||
|
* handler, with the same arguments, as the card button it repeats — Delete
|
||||||
|
* included, so the caller's confirm modal stays the only way to delete.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MdItemList } from "../MdItemList";
|
||||||
|
import type { CcMdItem } from "../../../lib/api";
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
name: "my-skill",
|
||||||
|
scope: "user",
|
||||||
|
path: "/home/.claude/skills/my-skill",
|
||||||
|
file: "",
|
||||||
|
frontmatter: { description: "Does things" },
|
||||||
|
preview: "",
|
||||||
|
} as unknown as CcMdItem;
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const handlers = { onOpen: vi.fn(), onEdit: vi.fn(), onDelete: vi.fn() };
|
||||||
|
render(<MdItemList items={[item]} search="" kind="skills" {...handlers} />);
|
||||||
|
return handlers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMenu(user: ReturnType<typeof userEvent.setup>) {
|
||||||
|
await user.pointer({ keys: "[MouseRight]", target: screen.getByText("my-skill") });
|
||||||
|
expect(screen.getByTestId("config-item-menu")).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = "/home/.claude/skills/my-skill/SKILL.md";
|
||||||
|
|
||||||
|
describe("MdItemList — right-click menu", () => {
|
||||||
|
it("Open, Edit and Delete call the card buttons' handlers", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const h = setup();
|
||||||
|
|
||||||
|
await openMenu(user);
|
||||||
|
await user.click(screen.getByTestId("config-item-menu-open"));
|
||||||
|
expect(h.onOpen).toHaveBeenCalledWith(path);
|
||||||
|
expect(screen.queryByTestId("config-item-menu")).toBeNull();
|
||||||
|
|
||||||
|
await openMenu(user);
|
||||||
|
await user.click(screen.getByTestId("config-item-menu-edit"));
|
||||||
|
expect(h.onEdit).toHaveBeenCalledWith("skills", {
|
||||||
|
scope: "user",
|
||||||
|
name: "my-skill",
|
||||||
|
filePath: path,
|
||||||
|
});
|
||||||
|
|
||||||
|
await openMenu(user);
|
||||||
|
await user.click(screen.getByTestId("config-item-menu-delete"));
|
||||||
|
expect(h.onDelete).toHaveBeenCalledWith("skills", "user", "my-skill", path);
|
||||||
|
|
||||||
|
// The buttons still do the same thing.
|
||||||
|
await user.click(screen.getByRole("button", { name: /delete/i }));
|
||||||
|
expect(h.onDelete).toHaveBeenLastCalledWith("skills", "user", "my-skill", path);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Copy path writes the item's file path", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
setup();
|
||||||
|
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue();
|
||||||
|
|
||||||
|
await openMenu(user);
|
||||||
|
await user.click(screen.getByTestId("config-item-menu-copy"));
|
||||||
|
expect(writeText).toHaveBeenCalledWith(path);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* @file One lane's card: CI/PR facts, the "needs you"
|
* @file One lane's card: CI/PR facts, the "needs you"
|
||||||
* banner sourced from Claude Code's Notification hook, and (for managed
|
* 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
|
* DestructiveLaneModal. A dead lane (its driving session went silent while
|
||||||
* it should have been working) is called out loudly — that is the failure
|
* 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
|
* this view exists to catch. Stage, kind, and the "auto: <stage>" chip are
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||||
|
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { eventBus } from "../../lib/eventBus";
|
import { eventBus } from "../../lib/eventBus";
|
||||||
import { keepIfSame } from "../../lib/keepIfSame";
|
import { keepIfSame } from "../../lib/keepIfSame";
|
||||||
@@ -271,6 +272,7 @@ export default function LaneCard({
|
|||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
|
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [menuAt, setMenuAt] = useState<MenuPoint | null>(null);
|
||||||
const [runtimeBump, setRuntimeBump] = useState(0);
|
const [runtimeBump, setRuntimeBump] = useState(0);
|
||||||
const [bootLine, setBootLine] = useState<string | null>(null);
|
const [bootLine, setBootLine] = useState<string | null>(null);
|
||||||
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
||||||
@@ -301,6 +303,8 @@ export default function LaneCard({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
data-testid={`lane-card-${lane.id}`}
|
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
|
// Every row below is conditional: a lane with nothing to report
|
||||||
// renders no frame at all rather than an empty box.
|
// 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"
|
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>
|
</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 && (
|
{destructiveAction && (
|
||||||
<DestructiveLaneModal
|
<DestructiveLaneModal
|
||||||
lane={lane}
|
lane={lane}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { api, gitTarget } from "../../lib/api";
|
|||||||
import { pasteToLane } from "../../lib/panePaste";
|
import { pasteToLane } from "../../lib/panePaste";
|
||||||
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
|
||||||
import { LaneConflictView } from "./LaneConflictView";
|
import { LaneConflictView } from "./LaneConflictView";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||||
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
|
||||||
import type {
|
import type {
|
||||||
@@ -151,6 +152,7 @@ function TreeNodes({
|
|||||||
expanded,
|
expanded,
|
||||||
onToggleDir,
|
onToggleDir,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
|
onMenu,
|
||||||
active,
|
active,
|
||||||
}: {
|
}: {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -159,6 +161,8 @@ function TreeNodes({
|
|||||||
expanded: Set<string>;
|
expanded: Set<string>;
|
||||||
onToggleDir: (path: string) => void;
|
onToggleDir: (path: string) => void;
|
||||||
onOpenFile: (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. */
|
/** The file open beside the tree, drawn selected. */
|
||||||
active?: string | null;
|
active?: string | null;
|
||||||
}) {
|
}) {
|
||||||
@@ -174,6 +178,7 @@ function TreeNodes({
|
|||||||
type="button"
|
type="button"
|
||||||
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
|
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
|
||||||
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
|
||||||
|
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
|
||||||
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
|
||||||
aria-current={active === e.path ? "true" : undefined}
|
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 ${
|
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}
|
expanded={expanded}
|
||||||
onToggleDir={onToggleDir}
|
onToggleDir={onToggleDir}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
|
onMenu={onMenu}
|
||||||
active={active}
|
active={active}
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -372,10 +378,12 @@ function ChangeRow({
|
|||||||
onAct,
|
onAct,
|
||||||
onArm,
|
onArm,
|
||||||
onResolve,
|
onResolve,
|
||||||
|
onMenu,
|
||||||
}: {
|
}: {
|
||||||
f: LaneChangeFile;
|
f: LaneChangeFile;
|
||||||
range: LaneChangeRange;
|
range: LaneChangeRange;
|
||||||
onResolve: Resolve;
|
onResolve: Resolve;
|
||||||
|
onMenu: (e: React.MouseEvent, f: LaneChangeFile) => void;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
/** This row's discard has been clicked once and awaits the second click. */
|
/** This row's discard has been clicked once and awaits the second click. */
|
||||||
armed: boolean;
|
armed: boolean;
|
||||||
@@ -385,7 +393,7 @@ function ChangeRow({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
return (
|
return (
|
||||||
<li className="group flex items-baseline gap-1">
|
<li className="group flex items-baseline gap-1" onContextMenu={(e) => onMenu(e, f)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpen(f.path)}
|
onClick={() => onOpen(f.path)}
|
||||||
@@ -844,6 +852,136 @@ export function LaneChangesPanel({
|
|||||||
};
|
};
|
||||||
const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side });
|
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
|
// 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
|
// hunk, matched against a fresh diff at the context this diff was read
|
||||||
// with — so the server never applies a stale patch.
|
// with — so the server never applies a stale patch.
|
||||||
@@ -1038,6 +1176,7 @@ export function LaneChangesPanel({
|
|||||||
expanded={expandedDirs}
|
expanded={expandedDirs}
|
||||||
onToggleDir={toggleDir}
|
onToggleDir={toggleDir}
|
||||||
onOpenFile={openWhole}
|
onOpenFile={openWhole}
|
||||||
|
onMenu={treeCtx.open}
|
||||||
active={full && mode === "file" ? openFile : null}
|
active={full && mode === "file" ? openFile : null}
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1050,6 +1189,7 @@ export function LaneChangesPanel({
|
|||||||
data-testid="lane-changes"
|
data-testid="lane-changes"
|
||||||
className={`flex min-h-0 flex-1 ${full ? "gap-3" : "flex-col"}`}
|
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
|
{/* 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. */}
|
it opened, instead of being a mode you leave to read one. */}
|
||||||
{full && (
|
{full && (
|
||||||
@@ -1278,11 +1418,8 @@ export function LaneChangesPanel({
|
|||||||
<li key={c.sha}>
|
<li key={c.sha}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => viewCommit(c.sha)}
|
||||||
setSha(c.sha);
|
onContextMenu={(e) => commitCtx.open(e, c)}
|
||||||
setRange("commit");
|
|
||||||
setMode("review");
|
|
||||||
}}
|
|
||||||
className="w-full rounded px-1 py-0.5 text-left hover:bg-surface-2"
|
className="w-full rounded px-1 py-0.5 text-left hover:bg-surface-2"
|
||||||
>
|
>
|
||||||
<span className="flex items-baseline gap-1.5">
|
<span className="flex items-baseline gap-1.5">
|
||||||
@@ -1432,6 +1569,7 @@ export function LaneChangesPanel({
|
|||||||
onAct={(...a) => void act(...a)}
|
onAct={(...a) => void act(...a)}
|
||||||
onArm={setConfirmDiscard}
|
onArm={setConfirmDiscard}
|
||||||
onResolve={resolve}
|
onResolve={resolve}
|
||||||
|
onMenu={fileCtx.open}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -1603,6 +1741,7 @@ export function LaneChangesPanel({
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => toggleCollapsed(f.path)}
|
onClick={() => toggleCollapsed(f.path)}
|
||||||
|
onContextMenu={(e) => fileCtx.open(e, f)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Only the header's own keys: Enter on the path button
|
// Only the header's own keys: Enter on the path button
|
||||||
// inside bubbles here and must open, not fold.
|
// inside bubbles here and must open, not fold.
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* @file SessionMenu — a session row's right-click menu (sidebar, the workspace
|
||||||
|
* home's Recents, the archived fold), and a bare live tab's (`session` null —
|
||||||
|
* the sidebar's **New session** row). Every item is something the row already
|
||||||
|
* does: open it the way its click does (a live pane attaches, an ended session
|
||||||
|
* opens its conversation — never a resume), open a live one in a split,
|
||||||
|
* rename / archive / unarchive when the list offers them, copy its id, open
|
||||||
|
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
|
||||||
|
* tab's X does).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { api, type RunHandle } from "../../lib/api";
|
||||||
|
import type { Session } from "../../lib/types";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu";
|
||||||
|
|
||||||
|
export function SessionMenu({
|
||||||
|
laneId,
|
||||||
|
session,
|
||||||
|
run,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
onClose,
|
||||||
|
onRename,
|
||||||
|
onArchive,
|
||||||
|
onUnarchive,
|
||||||
|
}: MenuPoint & {
|
||||||
|
laneId: number;
|
||||||
|
session: Session | null;
|
||||||
|
/** The live pane holding it, if any. */
|
||||||
|
run: RunHandle | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onRename?: () => void;
|
||||||
|
onArchive?: () => void;
|
||||||
|
onUnarchive?: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("lanes");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const runUrl = run && `/workspace?lane=${laneId}&run=${encodeURIComponent(run.id)}`;
|
||||||
|
|
||||||
|
const pick = (fn: () => unknown) => () => {
|
||||||
|
onClose();
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
const closeTab = async () => {
|
||||||
|
if (!run) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api.run.kill(run.id);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu x={x} y={y} onClose={onClose} testId={`session-menu-${session?.id ?? run?.id}`}>
|
||||||
|
<MenuItem
|
||||||
|
onSelect={pick(() =>
|
||||||
|
navigate(
|
||||||
|
runUrl ?? `/workspace?lane=${laneId}&session=${encodeURIComponent(session?.id ?? "")}`
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("sessionMenu.open")}
|
||||||
|
</MenuItem>
|
||||||
|
{runUrl && (
|
||||||
|
<MenuItem onSelect={pick(() => navigate(runUrl, { state: { split: true } }))}>
|
||||||
|
{t("sessionMenu.openSplit")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{onRename && <MenuItem onSelect={pick(onRename)}>{t("menu.rename")}</MenuItem>}
|
||||||
|
{session && (
|
||||||
|
<>
|
||||||
|
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
||||||
|
{t("sessionMenu.details")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onSelect={pick(() => void navigator.clipboard.writeText(session.id))}>
|
||||||
|
{t("sessionMenu.copyId")}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(run || onArchive || onUnarchive) && <MenuSeparator />}
|
||||||
|
{onUnarchive && <MenuItem onSelect={pick(onUnarchive)}>{t("tree.unarchive")}</MenuItem>}
|
||||||
|
{onArchive && <MenuItem onSelect={pick(onArchive)}>{t("sessionMenu.archive")}</MenuItem>}
|
||||||
|
{run && (
|
||||||
|
<MenuItem danger onSelect={() => void closeTab()}>
|
||||||
|
{t("tabs.close")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
type CodexSandbox,
|
type CodexSandbox,
|
||||||
type EffortLevel,
|
type EffortLevel,
|
||||||
type LaneMemoryFile,
|
type LaneMemoryFile,
|
||||||
|
type LaneSource,
|
||||||
type LaneSourcesView,
|
type LaneSourcesView,
|
||||||
type ModelChoice,
|
type ModelChoice,
|
||||||
type PermissionMode,
|
type PermissionMode,
|
||||||
@@ -57,6 +58,8 @@ import { Select } from "../Select";
|
|||||||
import { MarkdownPreview } from "../MarkdownPreview";
|
import { MarkdownPreview } from "../MarkdownPreview";
|
||||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||||
import { workspaceName } from "./WorkspaceRow";
|
import { workspaceName } from "./WorkspaceRow";
|
||||||
|
import { SessionMenu } from "./SessionMenu";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, menuPoint, useContextMenu } from "../ContextMenu";
|
||||||
import {
|
import {
|
||||||
liveRunFor,
|
liveRunFor,
|
||||||
SessionFold,
|
SessionFold,
|
||||||
@@ -162,12 +165,15 @@ function HomeBody({
|
|||||||
const [editing, setEditing] = useState<string | null>(null);
|
const [editing, setEditing] = useState<string | null>(null);
|
||||||
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
|
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
|
||||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||||
|
const [memoryTab, setMemoryTab] = useState(0);
|
||||||
const [folderOpen, setFolderOpen] = useState(false);
|
const [folderOpen, setFolderOpen] = useState(false);
|
||||||
const [folderPath, setFolderPath] = useState("");
|
const [folderPath, setFolderPath] = useState("");
|
||||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [title, setTitle] = useState<string | null>(null);
|
const [title, setTitle] = useState<string | null>(null);
|
||||||
const fileInput = useRef<HTMLInputElement>(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.
|
// 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}`;
|
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
|
||||||
@@ -342,6 +348,7 @@ function HomeBody({
|
|||||||
{title === null ? (
|
{title === null ? (
|
||||||
<h1
|
<h1
|
||||||
title={lane.cwd}
|
title={lane.cwd}
|
||||||
|
onContextMenu={(e) => setMenuAt(menuPoint(e))}
|
||||||
className="min-w-0 flex-1 truncate text-2xl font-semibold text-fg-primary"
|
className="min-w-0 flex-1 truncate text-2xl font-semibold text-fg-primary"
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
@@ -582,6 +589,7 @@ function HomeBody({
|
|||||||
data-testid={`workspace-recent-${s.id}`}
|
data-testid={`workspace-recent-${s.id}`}
|
||||||
data-live={live ? "true" : undefined}
|
data-live={live ? "true" : undefined}
|
||||||
onClick={() => openSession(s)}
|
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"
|
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)}`} />
|
<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" />}
|
actionIcon={<ArchiveRestore className="h-3.5 w-3.5" />}
|
||||||
onAction={(s) => api.sessions.patch(s.id, { archived: false })}
|
onAction={(s) => api.sessions.patch(s.id, { archived: false })}
|
||||||
onDone={() => setVersion((v) => v + 1)}
|
onDone={() => setVersion((v) => v + 1)}
|
||||||
|
laneId={lane.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -690,7 +699,10 @@ function HomeBody({
|
|||||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.memory")}</h3>
|
<h3 className="text-sm font-medium text-fg-primary">{t("home.memory")}</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMemoryOpen(true)}
|
onClick={() => {
|
||||||
|
setMemoryTab(0);
|
||||||
|
setMemoryOpen(true);
|
||||||
|
}}
|
||||||
disabled={!memory?.length}
|
disabled={!memory?.length}
|
||||||
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
|
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">
|
<ul className="mt-2 space-y-1">
|
||||||
{files.map((f) => (
|
{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" />
|
<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">
|
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||||
{f.name}
|
{f.name}
|
||||||
@@ -790,7 +806,11 @@ function HomeBody({
|
|||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
{folders.map((f) => (
|
{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" />
|
<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">
|
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||||
{f.name}
|
{f.name}
|
||||||
@@ -872,7 +892,7 @@ function HomeBody({
|
|||||||
aria-label={t("home.memory")}
|
aria-label={t("home.memory")}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={(e) => e.key === "Escape" && setMemoryOpen(false)}
|
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">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
|
<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" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{memory.map((f) => (
|
<div
|
||||||
<section key={f.path} className="mb-5">
|
role="tablist"
|
||||||
<p title={f.path} className="mb-1 font-mono text-xs text-fg-muted">
|
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}
|
{f.label}
|
||||||
</p>
|
</button>
|
||||||
<MarkdownPreview text={f.content} />
|
))}
|
||||||
</section>
|
</div>
|
||||||
))}
|
{memory[memoryTab] && (
|
||||||
|
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<MarkdownPreview text={memory[memoryTab].content} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</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 && (
|
{menuAt && (
|
||||||
<WorkspaceRowMenu
|
<WorkspaceRowMenu
|
||||||
lane={lane}
|
lane={lane}
|
||||||
|
|||||||
@@ -6,15 +6,16 @@
|
|||||||
* the lane Tools menu starts), New workspace here (the Add modal prefilled),
|
* the lane Tools menu starts), New workspace here (the Add modal prefilled),
|
||||||
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
|
* 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
|
* 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
|
* what the lane's kind or tool makes meaningless. The shell (position,
|
||||||
* click or after an item runs.
|
* Escape / outside-click close) is `ContextMenu`.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @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 { useTranslation } from "react-i18next";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
|
||||||
|
|
||||||
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started";
|
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started";
|
||||||
|
|
||||||
@@ -33,24 +34,8 @@ export function WorkspaceRowMenu({
|
|||||||
onAction: (action: WorkspaceMenuAction) => void;
|
onAction: (action: WorkspaceMenuAction) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const [error, setError] = useState<string | null>(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) => {
|
const run = async (fn: () => Promise<unknown>, then?: WorkspaceMenuAction) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -62,41 +47,21 @@ export function WorkspaceRowMenu({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const live = !!lane.run_id || lane.status === "running";
|
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 (
|
return (
|
||||||
<div
|
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
|
||||||
ref={ref}
|
<MenuItem
|
||||||
role="menu"
|
onSelect={() =>
|
||||||
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={() =>
|
|
||||||
live
|
live
|
||||||
? void run(() => api.lanes.action(lane.id, "stop"))
|
? void run(() => api.lanes.action(lane.id, "stop"))
|
||||||
: void run(() => api.lanes.action(lane.id, "start", { slot: 0, prompt: "" }), "started")
|
: void run(() => api.lanes.action(lane.id, "start", { slot: 0, prompt: "" }), "started")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{live ? t("menu.stop") : t("menu.start")}
|
{live ? t("menu.stop") : t("menu.start")}
|
||||||
</button>
|
</MenuItem>
|
||||||
<button type="button" role="menuitem" className={item} onClick={() => onAction("rename")}>
|
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
|
||||||
{t("menu.rename")}
|
|
||||||
</button>
|
|
||||||
{lane.tool === "claude" && (
|
{lane.tool === "claude" && (
|
||||||
<button
|
<MenuItem
|
||||||
type="button"
|
onSelect={() =>
|
||||||
role="menuitem"
|
|
||||||
className={item}
|
|
||||||
onClick={() =>
|
|
||||||
void run(
|
void run(
|
||||||
() =>
|
() =>
|
||||||
api.run.start({
|
api.run.start({
|
||||||
@@ -109,43 +74,26 @@ export function WorkspaceRowMenu({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t("actions.writeDocs")}
|
{t("actions.writeDocs")}
|
||||||
</button>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
<button type="button" role="menuitem" className={item} onClick={() => onAction("new-here")}>
|
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
|
||||||
{t("menu.newHere")}
|
<MenuItem onSelect={() => void run(() => navigator.clipboard.writeText(lane.cwd))}>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
className={item}
|
|
||||||
onClick={() => void run(() => navigator.clipboard.writeText(lane.cwd))}
|
|
||||||
>
|
|
||||||
{t("menu.copyPath")}
|
{t("menu.copyPath")}
|
||||||
</button>
|
</MenuItem>
|
||||||
<div className="my-1 border-t border-border" />
|
<MenuSeparator />
|
||||||
{lane.kind === "managed" && (
|
{lane.kind === "managed" && (
|
||||||
<button
|
<MenuItem danger onSelect={() => onAction("reset")}>
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
className={`${item} !text-status-danger`}
|
|
||||||
onClick={() => onAction("reset")}
|
|
||||||
>
|
|
||||||
{t("menu.reset")}
|
{t("menu.reset")}
|
||||||
</button>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
<button
|
<MenuItem danger onSelect={() => onAction("remove")}>
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
className={`${item} !text-status-danger`}
|
|
||||||
onClick={() => onAction("remove")}
|
|
||||||
>
|
|
||||||
{t("menu.remove")}
|
{t("menu.remove")}
|
||||||
</button>
|
</MenuItem>
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
|
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ContextMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* shows its state (`sessionDot`: live pane, needs you, error) and its right
|
* shows its state (`sessionDot`: live pane, needs you, error) and its right
|
||||||
* edge the AI tool the session ran on (`sessions.tool`); double-click
|
* edge the AI tool the session ran on (`sessions.tool`); double-click
|
||||||
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
|
* 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
|
* 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
|
* (`?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
|
* 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 { ConfirmModal } from "../ConfirmModal";
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
|
import { useContextMenu } from "../ContextMenu";
|
||||||
|
import { SessionMenu } from "./SessionMenu";
|
||||||
|
|
||||||
export const SESSION_LIMIT = 20;
|
export const SESSION_LIMIT = 20;
|
||||||
|
|
||||||
@@ -96,6 +99,7 @@ export function SessionFold({
|
|||||||
onAction,
|
onAction,
|
||||||
onDone,
|
onDone,
|
||||||
indent = "",
|
indent = "",
|
||||||
|
laneId,
|
||||||
}: {
|
}: {
|
||||||
testId: string;
|
testId: string;
|
||||||
page: SessionPage | null;
|
page: SessionPage | null;
|
||||||
@@ -107,9 +111,12 @@ export function SessionFold({
|
|||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
/** Left padding of the rows, to line up with the list above. */
|
/** Left padding of the rows, to line up with the list above. */
|
||||||
indent?: string;
|
indent?: string;
|
||||||
|
/** The rows' workspace: given, a right-click opens the session menu. */
|
||||||
|
laneId?: number;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const ctx = useContextMenu<Session>();
|
||||||
if (!page || page.total === 0) return null;
|
if (!page || page.total === 0) return null;
|
||||||
const act = async (s: Session) => {
|
const act = async (s: Session) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -134,7 +141,11 @@ export function SessionFold({
|
|||||||
{open && (
|
{open && (
|
||||||
<ul className="space-y-px">
|
<ul className="space-y-px">
|
||||||
{page.sessions.map((s) => (
|
{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
|
<Link
|
||||||
to={`/sessions/${s.id}`}
|
to={`/sessions/${s.id}`}
|
||||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}`}
|
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}`}
|
||||||
@@ -161,6 +172,17 @@ export function SessionFold({
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -201,6 +223,8 @@ export function WorkspaceSessionList({
|
|||||||
// worktree too, so that is confirmed first.
|
// worktree too, so that is confirmed first.
|
||||||
const [archiving, setArchiving] = useState<Session | null>(null);
|
const [archiving, setArchiving] = useState<Session | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
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
|
// 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.
|
// 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
|
// Archive: a worktree session with no live pane loses its worktree first
|
||||||
// (never forced — git refuses unsaved work, which then stops the archive);
|
// (never forced — git refuses unsaved work, which then stops the archive);
|
||||||
// one Claude already cleaned up is just archived.
|
// one Claude already cleaned up is just archived.
|
||||||
|
const startRename = (s: Session) => {
|
||||||
|
setDraft(s.name || "");
|
||||||
|
setRenaming(s.id);
|
||||||
|
};
|
||||||
const archive = (s: Session) =>
|
const archive = (s: Session) =>
|
||||||
sessionWorktreeName(lane, s) && !runFor(s)
|
sessionWorktreeName(lane, s) && !runFor(s)
|
||||||
? setArchiving(s)
|
? setArchiving(s)
|
||||||
@@ -311,6 +339,7 @@ export function WorkspaceSessionList({
|
|||||||
e.ctrlKey || e.metaKey ? { state: { split: true } } : undefined
|
e.ctrlKey || e.metaKey ? { state: { split: true } } : undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
onContextMenu={(e) => ctx.open(e, r)}
|
||||||
title={`${t("tree.newSession")}\n${t("tree.splitHint")}`}
|
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] ${
|
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
|
||||||
r.id === selectedRunId
|
r.id === selectedRunId
|
||||||
@@ -370,10 +399,8 @@ export function WorkspaceSessionList({
|
|||||||
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
|
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => startRename(s)}
|
||||||
setDraft(s.name || "");
|
onContextMenu={(e) => sessionCtx.open(e, s)}
|
||||||
setRenaming(s.id);
|
|
||||||
}}
|
|
||||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
|
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
|
||||||
sessionWorktreeName(lane, s)
|
sessionWorktreeName(lane, s)
|
||||||
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
|
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
|
||||||
@@ -418,6 +445,28 @@ export function WorkspaceSessionList({
|
|||||||
</ul>
|
</ul>
|
||||||
{failure}
|
{failure}
|
||||||
{confirmArchive}
|
{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 () => {
|
it("disarms a pending discard when the lane changes, so one click cannot discard in another lane", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
||||||
|
|||||||
@@ -439,6 +439,43 @@ describe("WorkspaceHome", () => {
|
|||||||
expect(within(dialog).getByText("Rules here")).toBeInTheDocument();
|
expect(within(dialog).getByText("Rules here")).toBeInTheDocument();
|
||||||
expect(within(dialog).queryByRole("textbox")).toBeNull();
|
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", () => {
|
describe("WorkspaceHome across workspaces", () => {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
* bar is the console's only header: Home (the workspace's home) at its left
|
* 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
|
* 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
|
* (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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ import { useAiTools } from "../../hooks/useAiTools";
|
|||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { freeSlot } from "../lanes/WorkspaceHome";
|
import { freeSlot } from "../lanes/WorkspaceHome";
|
||||||
import { RunIcon, startPaneDrag } from "./PaneGrid";
|
import { RunIcon, startPaneDrag } from "./PaneGrid";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
|
|
||||||
export function ConsoleTabs({
|
export function ConsoleTabs({
|
||||||
lane,
|
lane,
|
||||||
@@ -59,6 +61,7 @@ export function ConsoleTabs({
|
|||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const tabCtx = useContextMenu<RunHandle>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return;
|
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 =
|
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";
|
"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}
|
key={r.id}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => startPaneDrag(e, r.slot ?? 0)}
|
onDragStart={(e) => startPaneDrag(e, r.slot ?? 0)}
|
||||||
|
onContextMenu={(e) => tabCtx.open(e, r)}
|
||||||
data-shown={shown ? "true" : undefined}
|
data-shown={shown ? "true" : undefined}
|
||||||
className={`group relative flex min-w-0 max-w-[15rem] items-center rounded-t-md ${
|
className={`group relative flex min-w-0 max-w-[15rem] items-center rounded-t-md ${
|
||||||
active
|
active
|
||||||
@@ -252,6 +263,35 @@ export function ConsoleTabs({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{actions && <div className="ml-auto flex items-center pr-2">{actions}</div>}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
* header: drag it to move the pane (drop on another pane's edge to split
|
* 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
|
* 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).
|
* right or down, or close the pane (its session keeps running as a tab).
|
||||||
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). Dividers
|
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
|
||||||
* resize by pointer or by arrow keys.
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { RunHandle } from "../../lib/api";
|
import type { RunHandle } from "../../lib/api";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
import {
|
import {
|
||||||
EMPTY_LAYOUT,
|
EMPTY_LAYOUT,
|
||||||
type Edge,
|
type Edge,
|
||||||
@@ -171,6 +173,90 @@ function PaneMenu({
|
|||||||
const ITEM =
|
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";
|
"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({
|
export function PaneGrid({
|
||||||
layout,
|
layout,
|
||||||
mounted,
|
mounted,
|
||||||
@@ -203,6 +289,13 @@ export function PaneGrid({
|
|||||||
const multi = panes.length > 1;
|
const multi = panes.length > 1;
|
||||||
const full = shown.length >= MAX_PANES;
|
const full = shown.length >= MAX_PANES;
|
||||||
const runOf = (slot: number) => runs.find((r) => (r.slot ?? 0) === slot);
|
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(() => {
|
useEffect(() => {
|
||||||
const end = () => {
|
const end = () => {
|
||||||
@@ -310,6 +403,7 @@ export function PaneGrid({
|
|||||||
data-testid={`pane-header-${slot}`}
|
data-testid={`pane-header-${slot}`}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => startPaneDrag(e, slot)}
|
onDragStart={(e) => startPaneDrag(e, slot)}
|
||||||
|
onContextMenu={(e) => paneCtx.open(e, slot)}
|
||||||
title={t("panes.drag")}
|
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 ${
|
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"
|
focused ? "text-fg-primary" : "text-fg-muted"
|
||||||
@@ -349,61 +443,15 @@ export function PaneGrid({
|
|||||||
}
|
}
|
||||||
</PaneMenu>
|
</PaneMenu>
|
||||||
<div className="ml-auto flex items-center">
|
<div className="ml-auto flex items-center">
|
||||||
{(["right", "bottom"] as const).map((edge) => (
|
<SplitMenus
|
||||||
<PaneMenu
|
slot={slot}
|
||||||
key={edge}
|
shown={shown}
|
||||||
testId={`pane-split-${edge}-${slot}`}
|
runs={runs}
|
||||||
label={full ? t("panes.full") : t(`panes.split.${edge}`)}
|
label={label}
|
||||||
disabled={full}
|
onChange={onChange}
|
||||||
icon={
|
canStart={canStart}
|
||||||
edge === "right" ? (
|
onNewSplit={onNewSplit}
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={`pane-close-${slot}`}
|
data-testid={`pane-close-${slot}`}
|
||||||
@@ -423,6 +471,42 @@ export function PaneGrid({
|
|||||||
</div>
|
</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) => (
|
{dividers.map((d) => (
|
||||||
<Divider
|
<Divider
|
||||||
key={d.path}
|
key={d.path}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* @file RunSetup.tsx
|
* @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
|
* own run form is gone, a new session starts from the workspace home's prompt
|
||||||
* box (`WorkspaceHome.tsx`):
|
* box (`WorkspaceHome.tsx`):
|
||||||
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
||||||
@@ -41,6 +42,7 @@ import {
|
|||||||
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
|
import type { CwdSuggestion, CcMdItem } from "../../lib/api";
|
||||||
import type { LaneTool } from "../../lib/types";
|
import type { LaneTool } from "../../lib/types";
|
||||||
import { Select } from "../Select";
|
import { Select } from "../Select";
|
||||||
|
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -262,6 +264,7 @@ export function PromptEditor({
|
|||||||
// added to the prompt only when it is sent (promptWithImages).
|
// added to the prompt only when it is sent (promptWithImages).
|
||||||
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
||||||
const [viewing, setViewing] = useState<PromptImage | 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 attachImages = (list: FileList | null | undefined) => {
|
||||||
const picked = Array.from(list ?? []).filter((f) => f.type.startsWith("image/"));
|
const picked = Array.from(list ?? []).filter((f) => f.type.startsWith("image/"));
|
||||||
if (picked.length === 0) return false;
|
if (picked.length === 0) return false;
|
||||||
@@ -342,6 +345,7 @@ export function PromptEditor({
|
|||||||
aria-label={img.name}
|
aria-label={img.name}
|
||||||
title={img.name}
|
title={img.name}
|
||||||
onClick={() => setViewing(img)}
|
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"
|
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" />
|
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
||||||
@@ -357,6 +361,27 @@ export function PromptEditor({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
* the catalog knows how. Install/Update/Update all confirm the exact command
|
* 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
|
* 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).
|
* (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
|
* 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
|
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
|
||||||
* working.
|
* working.
|
||||||
@@ -21,6 +23,7 @@ import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
|
|||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
import { api, type AiTool, type AiToolJob } from "../../lib/api";
|
import { api, type AiTool, type AiToolJob } from "../../lib/api";
|
||||||
|
|
||||||
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
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 [job, setJob] = useState<AiToolJob | null>(null);
|
||||||
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||||
const [showAvailable, setShowAvailable] = useState(false);
|
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
|
// A modal opened mid-job picks it up. Deferred like useAiTools' load: an
|
||||||
// unreachable server (or a mocked api without it) means "no job".
|
// unreachable server (or a mocked api without it) means "no job".
|
||||||
@@ -236,7 +249,12 @@ export function AiToolsSettings() {
|
|||||||
{installed.map((tool) => {
|
{installed.map((tool) => {
|
||||||
const isDefault = tool.id === defaultId;
|
const isDefault = tool.id === defaultId;
|
||||||
return (
|
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 items-center gap-3">
|
||||||
<div
|
<div
|
||||||
className={`flex min-w-0 flex-1 items-center gap-3 ${tool.enabled ? "" : "opacity-50"}`}
|
className={`flex min-w-0 flex-1 items-center gap-3 ${tool.enabled ? "" : "opacity-50"}`}
|
||||||
@@ -278,26 +296,53 @@ export function AiToolsSettings() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!tool.enabled || busy !== null}
|
disabled={!tool.enabled || busy !== null}
|
||||||
onClick={() => void run(tool.id, () => update(tool.id, { default: true }))}
|
onClick={() => setDefault(tool)}
|
||||||
className={GHOST_BTN}
|
className={GHOST_BTN}
|
||||||
>
|
>
|
||||||
{t("aiTools.setDefault")}
|
{t("aiTools.setDefault")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ToolSwitch
|
<ToolSwitch tool={tool} disabled={busy !== null} onToggle={() => toggle(tool)} />
|
||||||
tool={tool}
|
|
||||||
disabled={busy !== null}
|
|
||||||
onToggle={() =>
|
|
||||||
void run(tool.id, () => update(tool.id, { enabled: !tool.enabled }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{logFor(tool.id)}
|
{logFor(tool.id)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
|
|
||||||
{available.length > 0 && (
|
{available.length > 0 && (
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
* **Show all models** switch lists every row, and a row added outside the
|
* **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
|
* 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
|
* 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>
|
* @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 { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||||
import { getCurrentLocale } from "../../lib/format";
|
import { getCurrentLocale } from "../../lib/format";
|
||||||
import { Skeleton } from "../Skeleton";
|
import { Skeleton } from "../Skeleton";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
import type { ModelPricing, WSMessage } from "../../lib/types";
|
import type { ModelPricing, WSMessage } from "../../lib/types";
|
||||||
|
|
||||||
interface EditRow {
|
interface EditRow {
|
||||||
@@ -215,6 +217,13 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
|||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
const [resetting, setResetting] = useState(false);
|
const [resetting, setResetting] = useState(false);
|
||||||
const [confirmReset, setConfirmReset] = 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<{
|
const [actionResult, setActionResult] = useState<{
|
||||||
key: string;
|
key: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -663,6 +672,7 @@ export function PricingSettings({ vendor, allowReset = false, id = "pricing", he
|
|||||||
) : (
|
) : (
|
||||||
<tr
|
<tr
|
||||||
key={rule.model_pattern}
|
key={rule.model_pattern}
|
||||||
|
onContextMenu={(e) => ctx.open(e, rule)}
|
||||||
className="hover:bg-surface-4 transition-colors group"
|
className="hover:bg-surface-4 transition-colors group"
|
||||||
>
|
>
|
||||||
<td className="whitespace-nowrap px-3 py-3 text-sm font-mono text-fg-secondary">
|
<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>
|
</table>
|
||||||
</div>
|
</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 && (
|
{lastUpdated && (
|
||||||
<p className="text-xs text-fg-muted mt-3">
|
<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
|
* 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
|
* 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
|
* 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>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -125,6 +126,21 @@ describe("AiToolsSettings", () => {
|
|||||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||||
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
|
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", () => {
|
describe("AiToolsSettings — install and update", () => {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
* @description The shared Model Pricing table: each tool's tab shows its own
|
* @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
|
* 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
|
* 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>
|
* @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).toHaveBeenCalledTimes(1);
|
||||||
expect(api.settings.resetPricing).toHaveBeenCalledWith("claude");
|
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
|
* @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
|
* 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
|
* 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
|
* 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 { useTranslation } from "react-i18next";
|
||||||
import { Workflow, ChevronRight, ChevronDown, Layers, ExternalLink, Loader2 } from "lucide-react";
|
import { Workflow, ChevronRight, ChevronDown, Layers, ExternalLink, Loader2 } from "lucide-react";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { useContextMenu } from "../ContextMenu";
|
||||||
|
import { SessionRowMenu } from "../SessionRowMenu";
|
||||||
import { eventBus } from "../../lib/eventBus";
|
import { eventBus } from "../../lib/eventBus";
|
||||||
import type {
|
import type {
|
||||||
WorkflowRun,
|
WorkflowRun,
|
||||||
@@ -227,6 +230,7 @@ export function WorkflowRunsPanel({
|
|||||||
hideSessionLink,
|
hideSessionLink,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation("workflows");
|
const { t } = useTranslation("workflows");
|
||||||
|
const runCtx = useContextMenu<string>();
|
||||||
const controlled = controlledRuns != null;
|
const controlled = controlledRuns != null;
|
||||||
|
|
||||||
const [fetchedRuns, setFetchedRuns] = useState<WorkflowRun[]>([]);
|
const [fetchedRuns, setFetchedRuns] = useState<WorkflowRun[]>([]);
|
||||||
@@ -337,6 +341,14 @@ export function WorkflowRunsPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<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) => {
|
{runs.map((run) => {
|
||||||
const isOpen = expanded.has(run.run_id);
|
const isOpen = expanded.has(run.run_id);
|
||||||
const running = run.status === "running" || run.status === "working";
|
const running = run.status === "running" || run.status === "working";
|
||||||
@@ -354,6 +366,7 @@ export function WorkflowRunsPanel({
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggle(run.run_id)}
|
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"
|
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}
|
aria-expanded={isOpen}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"viewSource": "View source",
|
"viewSource": "View source",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"copyPath": "Copy path",
|
"copyPath": "Copy path",
|
||||||
|
"copyName": "Copy name",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
"truncated": "(truncated for preview)",
|
"truncated": "(truncated for preview)",
|
||||||
"size": "Size",
|
"size": "Size",
|
||||||
@@ -266,6 +267,7 @@
|
|||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"renameLabel": "Profile name",
|
"renameLabel": "Profile name",
|
||||||
"renameHint": "Click to rename",
|
"renameHint": "Click to rename",
|
||||||
|
"rename": "Rename",
|
||||||
"renameSuccess": "Renamed to \"{{name}}\"."
|
"renameSuccess": "Renamed to \"{{name}}\"."
|
||||||
},
|
},
|
||||||
"explain": {
|
"explain": {
|
||||||
|
|||||||
@@ -38,7 +38,9 @@
|
|||||||
"newButton": "New",
|
"newButton": "New",
|
||||||
"viewButton": "View source",
|
"viewButton": "View source",
|
||||||
"editButton": "Edit",
|
"editButton": "Edit",
|
||||||
"deleteButton": "Delete"
|
"deleteButton": "Delete",
|
||||||
|
"copyPath": "Copy path",
|
||||||
|
"copyName": "Copy name"
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"saveSuccess": "Saved · {{path}}",
|
"saveSuccess": "Saved · {{path}}",
|
||||||
|
|||||||
@@ -402,5 +402,18 @@
|
|||||||
"panes.newTerminal": "New terminal",
|
"panes.newTerminal": "New terminal",
|
||||||
"panes.close": "Close pane (its session keeps running as a tab)",
|
"panes.close": "Close pane (its session keeps running as a tab)",
|
||||||
"panes.resize": "Resize panes",
|
"panes.resize": "Resize panes",
|
||||||
"tabs.home": "Workspace home"
|
"tabs.home": "Workspace home",
|
||||||
|
"sessionMenu.open": "Open",
|
||||||
|
"sessionMenu.openSplit": "Open in split",
|
||||||
|
"sessionMenu.details": "Session details",
|
||||||
|
"sessionMenu.copyId": "Copy session ID",
|
||||||
|
"sessionMenu.archive": "Archive",
|
||||||
|
"panes.show": "Show {{name}} here",
|
||||||
|
"panes.splitNew.right": "Split right with a new terminal",
|
||||||
|
"panes.splitNew.bottom": "Split down with a new terminal",
|
||||||
|
"panes.closeShort": "Close pane",
|
||||||
|
"changes.copyPath": "Copy relative path",
|
||||||
|
"changes.viewCommit": "View commit",
|
||||||
|
"changes.copySha": "Copy commit SHA",
|
||||||
|
"changes.copySubject": "Copy commit message"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,9 @@
|
|||||||
"statusBar": {
|
"statusBar": {
|
||||||
"running": "{{count}} running",
|
"running": "{{count}} running",
|
||||||
"needYou": "{{count}} need you",
|
"needYou": "{{count}} need you",
|
||||||
"today": "Today {{cost}}"
|
"today": "Today {{cost}}",
|
||||||
|
"workspace": "Workspace {{cost}}",
|
||||||
|
"session": "Session {{cost}}"
|
||||||
},
|
},
|
||||||
"version": "Version"
|
"version": "Version"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,7 @@
|
|||||||
"uploading": "Uploading image…",
|
"uploading": "Uploading image…",
|
||||||
"failed": "Could not attach the image",
|
"failed": "Could not attach the image",
|
||||||
"imagesOnly": "Only images can be dropped here",
|
"imagesOnly": "Only images can be dropped here",
|
||||||
"remove": "Remove image"
|
"remove": "Remove image",
|
||||||
|
"view": "View image"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,5 +40,11 @@
|
|||||||
"transcriptNotFound": "Conversation transcript not found for this agent. The transcript file may be missing or not yet linked."
|
"transcriptNotFound": "Conversation transcript not found for this agent. The transcript file may be missing or not yet linked."
|
||||||
},
|
},
|
||||||
"remoteSourceBadgeTitle": "Collected from a remote machine",
|
"remoteSourceBadgeTitle": "Collected from a remote machine",
|
||||||
"toolBadgeTitle": "Recorded by {{tool}}"
|
"toolBadgeTitle": "Recorded by {{tool}}",
|
||||||
|
"menu": {
|
||||||
|
"details": "Open session details",
|
||||||
|
"inWorkspace": "Open in its workspace",
|
||||||
|
"copyId": "Copy session ID",
|
||||||
|
"copyFolder": "Copy folder path"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -735,5 +735,13 @@
|
|||||||
"en": "English",
|
"en": "English",
|
||||||
"vi": "Tiếng Việt"
|
"vi": "Tiếng Việt"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"rowMenu": {
|
||||||
|
"copyHost": "Copy host",
|
||||||
|
"copyUrl": "Copy endpoint",
|
||||||
|
"copyPattern": "Copy model pattern",
|
||||||
|
"copyPath": "Copy path",
|
||||||
|
"enable": "Enable",
|
||||||
|
"disable": "Disable"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"viewSource": "Xem nguồn",
|
"viewSource": "Xem nguồn",
|
||||||
"close": "Đóng",
|
"close": "Đóng",
|
||||||
"copyPath": "Sao chép đường dẫn",
|
"copyPath": "Sao chép đường dẫn",
|
||||||
|
"copyName": "Sao chép tên",
|
||||||
"copied": "Đã sao chép",
|
"copied": "Đã sao chép",
|
||||||
"truncated": "(đã cắt bớt để xem trước)",
|
"truncated": "(đã cắt bớt để xem trước)",
|
||||||
"size": "Kích thước",
|
"size": "Kích thước",
|
||||||
@@ -261,6 +262,7 @@
|
|||||||
"saving": "Đang lưu…",
|
"saving": "Đang lưu…",
|
||||||
"renameLabel": "Tên profile",
|
"renameLabel": "Tên profile",
|
||||||
"renameHint": "Bấm để đổi tên",
|
"renameHint": "Bấm để đổi tên",
|
||||||
|
"rename": "Đổi tên",
|
||||||
"renameSuccess": "Đã đổi tên thành \"{{name}}\"."
|
"renameSuccess": "Đã đổi tên thành \"{{name}}\"."
|
||||||
},
|
},
|
||||||
"explain": {
|
"explain": {
|
||||||
|
|||||||
@@ -38,7 +38,9 @@
|
|||||||
"newButton": "Mới",
|
"newButton": "Mới",
|
||||||
"viewButton": "Xem nguồn",
|
"viewButton": "Xem nguồn",
|
||||||
"editButton": "Sửa",
|
"editButton": "Sửa",
|
||||||
"deleteButton": "Xóa"
|
"deleteButton": "Xóa",
|
||||||
|
"copyPath": "Sao chép đường dẫn",
|
||||||
|
"copyName": "Sao chép tên"
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"saveSuccess": "Đã lưu · {{path}}",
|
"saveSuccess": "Đã lưu · {{path}}",
|
||||||
|
|||||||
@@ -402,5 +402,18 @@
|
|||||||
"panes.newTerminal": "Terminal mới",
|
"panes.newTerminal": "Terminal mới",
|
||||||
"panes.close": "Đóng pane (session vẫn chạy dưới dạng tab)",
|
"panes.close": "Đóng pane (session vẫn chạy dưới dạng tab)",
|
||||||
"panes.resize": "Đổi kích thước pane",
|
"panes.resize": "Đổi kích thước pane",
|
||||||
"tabs.home": "Trang chủ workspace"
|
"tabs.home": "Trang chủ workspace",
|
||||||
|
"sessionMenu.open": "Mở",
|
||||||
|
"sessionMenu.openSplit": "Mở ở pane chia đôi",
|
||||||
|
"sessionMenu.details": "Chi tiết session",
|
||||||
|
"sessionMenu.copyId": "Sao chép ID session",
|
||||||
|
"sessionMenu.archive": "Lưu trữ",
|
||||||
|
"panes.show": "Hiện {{name}} ở đây",
|
||||||
|
"panes.splitNew.right": "Chia sang phải với terminal mới",
|
||||||
|
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
|
||||||
|
"panes.closeShort": "Đóng pane",
|
||||||
|
"changes.copyPath": "Sao chép đường dẫn tương đối",
|
||||||
|
"changes.viewCommit": "Xem commit",
|
||||||
|
"changes.copySha": "Sao chép SHA của commit",
|
||||||
|
"changes.copySubject": "Sao chép commit message"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,9 @@
|
|||||||
"statusBar": {
|
"statusBar": {
|
||||||
"running": "{{count}} đang chạy",
|
"running": "{{count}} đang chạy",
|
||||||
"needYou": "{{count}} cần bạn",
|
"needYou": "{{count}} cần bạn",
|
||||||
"today": "Hôm nay {{cost}}"
|
"today": "Hôm nay {{cost}}",
|
||||||
|
"workspace": "Workspace {{cost}}",
|
||||||
|
"session": "Session {{cost}}"
|
||||||
},
|
},
|
||||||
"version": "Phiên bản"
|
"version": "Phiên bản"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,7 @@
|
|||||||
"uploading": "Đang tải ảnh lên…",
|
"uploading": "Đang tải ảnh lên…",
|
||||||
"failed": "Không đính kèm được ảnh",
|
"failed": "Không đính kèm được ảnh",
|
||||||
"imagesOnly": "Chỉ thả được ảnh vào đây",
|
"imagesOnly": "Chỉ thả được ảnh vào đây",
|
||||||
"remove": "Bỏ ảnh"
|
"remove": "Bỏ ảnh",
|
||||||
|
"view": "Xem ảnh"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,5 +39,11 @@
|
|||||||
"timeline": "Dòng thời gian ({{shown}}/{{total}})",
|
"timeline": "Dòng thời gian ({{shown}}/{{total}})",
|
||||||
"transcriptNotFound": "Không tìm thấy bản ghi hội thoại cho agent này. Tệp transcript có thể bị thiếu hoặc chưa được liên kết."
|
"transcriptNotFound": "Không tìm thấy bản ghi hội thoại cho agent này. Tệp transcript có thể bị thiếu hoặc chưa được liên kết."
|
||||||
},
|
},
|
||||||
"toolBadgeTitle": "Ghi lại bởi {{tool}}"
|
"toolBadgeTitle": "Ghi lại bởi {{tool}}",
|
||||||
|
"menu": {
|
||||||
|
"details": "Mở chi tiết session",
|
||||||
|
"inWorkspace": "Mở trong workspace của nó",
|
||||||
|
"copyId": "Sao chép ID session",
|
||||||
|
"copyFolder": "Sao chép đường dẫn thư mục"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -735,5 +735,13 @@
|
|||||||
"en": "English",
|
"en": "English",
|
||||||
"vi": "Tiếng Việt"
|
"vi": "Tiếng Việt"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"rowMenu": {
|
||||||
|
"copyHost": "Sao chép host",
|
||||||
|
"copyUrl": "Sao chép endpoint",
|
||||||
|
"copyPattern": "Sao chép mẫu mô hình",
|
||||||
|
"copyPath": "Sao chép đường dẫn",
|
||||||
|
"enable": "Bật",
|
||||||
|
"disable": "Tắt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -737,13 +737,15 @@ export const api = {
|
|||||||
* @param id The session id.
|
* @param id The session id.
|
||||||
* @returns `{ session, agents, events, workflows }` for the detail view.
|
* @returns `{ session, agents, events, workflows }` for the detail view.
|
||||||
*/
|
*/
|
||||||
get: (id: string) =>
|
/** `events: false` sends `?events=0`: `events` comes back empty instead of
|
||||||
|
* every event of the session with its full payload. */
|
||||||
|
get: (id: string, opts: { events?: boolean } = {}) =>
|
||||||
request<{
|
request<{
|
||||||
session: Session;
|
session: Session;
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
events: DashboardEvent[];
|
events: DashboardEvent[];
|
||||||
workflows: WorkflowRun[];
|
workflows: WorkflowRun[];
|
||||||
}>(`/sessions/${encodeURIComponent(id)}`),
|
}>(`/sessions/${encodeURIComponent(id)}${opts.events === false ? "?events=0" : ""}`),
|
||||||
/**
|
/**
|
||||||
* GET /api/sessions/:id/stats - per-session rollups for the detail page.
|
* GET /api/sessions/:id/stats - per-session rollups for the detail page.
|
||||||
*
|
*
|
||||||
@@ -1334,11 +1336,13 @@ export const api = {
|
|||||||
*
|
*
|
||||||
* @returns {@link CostResult} — the aggregate cost breakdown across sessions.
|
* @returns {@link CostResult} — the aggregate cost breakdown across sessions.
|
||||||
*/
|
*/
|
||||||
totalCost: () => {
|
totalCost: (opts: { lane?: number } = {}) => {
|
||||||
// Scope the aggregate to the active data-scope, exactly like the sessions /
|
// Scope the aggregate to the active data-scope, exactly like the sessions /
|
||||||
// stats / analytics endpoints — otherwise switching the Data scope selector
|
// stats / analytics endpoints — otherwise switching the Data scope selector
|
||||||
// left the Dashboard "total cost" showing the un-narrowed global total.
|
// left the Dashboard "total cost" showing the un-narrowed global total.
|
||||||
|
// `lane`: only the sessions that workspace owns.
|
||||||
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
|
const qs = new URLSearchParams({ tz_offset: String(new Date().getTimezoneOffset()) });
|
||||||
|
if (opts.lane !== undefined) qs.set("lane", String(opts.lane));
|
||||||
applyScope(qs);
|
applyScope(qs);
|
||||||
return request<CostResult>(`/pricing/cost?${qs.toString()}`);
|
return request<CostResult>(`/pricing/cost?${qs.toString()}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* @file ActivityFeed.tsx
|
* @file ActivityFeed.tsx
|
||||||
* @description Real-time feed of agent events with server-driven filters and
|
* @description Real-time feed of agent events with server-driven filters and
|
||||||
|
* a right-click session menu per row (`SessionRowMenu`),
|
||||||
* batched pagination. Clicking a row toggles the inline EventDetail payload
|
* batched pagination. Clicking a row toggles the inline EventDetail payload
|
||||||
* view; the "View session" Link navigates to the session page. Live events
|
* view; the "View session" Link navigates to the session page. Live events
|
||||||
* trigger a debounced, filter-aware refetch that preserves the user's
|
* trigger a debounced, filter-aware refetch that preserves the user's
|
||||||
@@ -71,6 +72,8 @@ import { useDataScope } from "../lib/dataScope";
|
|||||||
import { AgentStatusBadge } from "../components/StatusBadge";
|
import { AgentStatusBadge } from "../components/StatusBadge";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { EventDetail } from "../components/EventDetail";
|
import { EventDetail } from "../components/EventDetail";
|
||||||
|
import { useContextMenu } from "../components/ContextMenu";
|
||||||
|
import { SessionRowMenu } from "../components/SessionRowMenu";
|
||||||
import {
|
import {
|
||||||
EventFilters,
|
EventFilters,
|
||||||
EMPTY_FILTERS,
|
EMPTY_FILTERS,
|
||||||
@@ -101,6 +104,7 @@ const MAX_REFRESH = 500;
|
|||||||
const REFRESH_DEBOUNCE_MS = 500;
|
const REFRESH_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
export function ActivityFeed() {
|
export function ActivityFeed() {
|
||||||
|
const eventCtx = useContextMenu<string>();
|
||||||
const { t } = useTranslation("activity");
|
const { t } = useTranslation("activity");
|
||||||
const [events, setEvents] = useState<DashboardEvent[]>([]);
|
const [events, setEvents] = useState<DashboardEvent[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -318,6 +322,14 @@ export function ActivityFeed() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
|
{eventCtx.menu && (
|
||||||
|
<SessionRowMenu
|
||||||
|
session={{ id: eventCtx.menu.item }}
|
||||||
|
x={eventCtx.menu.x}
|
||||||
|
y={eventCtx.menu.y}
|
||||||
|
onClose={eventCtx.close}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||||
@@ -423,6 +435,7 @@ export function ActivityFeed() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
|
onContextMenu={(e) => eventCtx.open(e, event.session_id)}
|
||||||
className="flex items-center px-5 py-3.5 gap-4 hover:bg-surface-4 transition-colors cursor-pointer select-none"
|
className="flex items-center px-5 py-3.5 gap-4 hover:bg-surface-4 transition-colors cursor-pointer select-none"
|
||||||
>
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
* An API key is write-only here by design — the server never returns one, so
|
* An API key is write-only here by design — the server never returns one, so
|
||||||
* the form shows whether a key is saved, never the key.
|
* the form shows whether a key is saved, never the key.
|
||||||
*
|
*
|
||||||
|
* A right-click on a profile row opens its Edit / Remove (plus Copy endpoint)
|
||||||
|
* as a menu; the buttons stay.
|
||||||
|
*
|
||||||
* **Test** asks the endpoint itself before the profile is saved onto a lane.
|
* **Test** asks the endpoint itself before the profile is saved onto a lane.
|
||||||
* "Saved" and "works" are different claims, and the gap between them is only
|
* "Saved" and "works" are different claims, and the gap between them is only
|
||||||
* ever discovered inside a lane's session otherwise, where the error surfaces
|
* ever discovered inside a lane's session otherwise, where the error surfaces
|
||||||
@@ -26,6 +29,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Plus, Pencil, Trash2, Lock } from "lucide-react";
|
import { Plus, Pencil, Trash2, Lock } from "lucide-react";
|
||||||
import { api, type AiRouter, type AiRouterInput, type AiRouterModels } from "../lib/api";
|
import { api, type AiRouter, type AiRouterInput, type AiRouterModels } from "../lib/api";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||||
|
|
||||||
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
const TIERS: (keyof AiRouterModels)[] = ["opus", "sonnet", "haiku", "smallFast", "subagent"];
|
||||||
|
|
||||||
@@ -55,6 +59,13 @@ export function AiRouterConfig() {
|
|||||||
// is a claim about a configuration that no longer exists.
|
// is a claim about a configuration that no longer exists.
|
||||||
const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
|
const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
|
const ctx = useContextMenu<AiRouter>();
|
||||||
|
// A menu item: close the menu, then act on the row it was opened on.
|
||||||
|
const pick = (fn: (item: AiRouter) => unknown) => () => {
|
||||||
|
const item = ctx.menu?.item;
|
||||||
|
ctx.close();
|
||||||
|
if (item) fn(item);
|
||||||
|
};
|
||||||
// Distinct from "no profiles": an empty table rendered before the first fetch
|
// Distinct from "no profiles": an empty table rendered before the first fetch
|
||||||
// resolves tells the user their profiles are gone. CodexConfig draws the same
|
// resolves tells the user their profiles are gone. CodexConfig draws the same
|
||||||
// distinction for the same reason.
|
// distinction for the same reason.
|
||||||
@@ -212,7 +223,11 @@ export function AiRouterConfig() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<tr key={item.name} className="border-t border-border">
|
<tr
|
||||||
|
key={item.name}
|
||||||
|
onContextMenu={item.builtin ? undefined : (e) => ctx.open(e, item)}
|
||||||
|
className="border-t border-border"
|
||||||
|
>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span className="flex items-center gap-1.5 font-medium text-fg-primary">
|
<span className="flex items-center gap-1.5 font-medium text-fg-primary">
|
||||||
{item.builtin && <Lock className="h-3 w-3 text-fg-muted" />}
|
{item.builtin && <Lock className="h-3 w-3 text-fg-muted" />}
|
||||||
@@ -269,6 +284,20 @@ export function AiRouterConfig() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{ctx.menu && (
|
||||||
|
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-router-menu">
|
||||||
|
<MenuItem onSelect={pick(startEdit)}>{t("aiRouter.edit")}</MenuItem>
|
||||||
|
{ctx.menu.item.baseUrl && (
|
||||||
|
<MenuItem onSelect={pick((item) => void navigator.clipboard.writeText(item.baseUrl))}>
|
||||||
|
{t("rowMenu.copyUrl")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem danger onSelect={pick((item) => void remove(item))}>
|
||||||
|
{t("aiRouter.remove")}
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
+354
-131
@@ -10,6 +10,10 @@
|
|||||||
* concurrent-write races with the live CLI. Its Overview also holds the
|
* concurrent-write races with the live CLI. Its Overview also holds the
|
||||||
* dashboard's own Claude Code settings (CLAUDE_HOME — editable, so the path
|
* dashboard's own Claude Code settings (CLAUDE_HOME — editable, so the path
|
||||||
* list leaves it out — hooks, pricing: `ClaudeAgentHubSettings`).
|
* list leaves it out — hooks, pricing: `ClaudeAgentHubSettings`).
|
||||||
|
* Every card with action buttons (plugins, marketplaces, MCP servers, memory
|
||||||
|
* files, auth profiles, and the `MdItemList` artifacts) also opens a
|
||||||
|
* right-click menu that repeats those buttons — same handlers, same
|
||||||
|
* confirm path — plus Copy name/path.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -108,7 +112,8 @@ import {
|
|||||||
type ConfirmDeleteState,
|
type ConfirmDeleteState,
|
||||||
} from "../components/config/ConfirmDeleteModal";
|
} from "../components/config/ConfirmDeleteModal";
|
||||||
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
|
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
|
||||||
import { MdItemList } from "../components/config/MdItemList";
|
import { FileItemMenu, MdItemList } from "../components/config/MdItemList";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||||
import { ScopeBadge } from "../components/config/ScopeBadge";
|
import { ScopeBadge } from "../components/config/ScopeBadge";
|
||||||
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
||||||
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
||||||
@@ -1499,6 +1504,13 @@ function PluginsPanel({
|
|||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [available, setAvailable] = useState<CcAvailablePlugin[] | null>(null);
|
const [available, setAvailable] = useState<CcAvailablePlugin[] | null>(null);
|
||||||
const [availableError, setAvailableError] = useState<string | null>(null);
|
const [availableError, setAvailableError] = useState<string | null>(null);
|
||||||
|
const availableCtx = useContextMenu<CcAvailablePlugin>();
|
||||||
|
const install = (a: CcAvailablePlugin) =>
|
||||||
|
void run(
|
||||||
|
a.pluginId,
|
||||||
|
() => api.ccConfig.installPlugin(a.pluginId),
|
||||||
|
t("manage.installed", { name: a.pluginId })
|
||||||
|
);
|
||||||
|
|
||||||
// The available inventory comes from the CLI, which may not be installed —
|
// The available inventory comes from the CLI, which may not be installed —
|
||||||
// a failure here is a normal state, not a page error.
|
// a failure here is a normal state, not a page error.
|
||||||
@@ -1612,6 +1624,7 @@ function PluginsPanel({
|
|||||||
{installable.map((a) => (
|
{installable.map((a) => (
|
||||||
<div
|
<div
|
||||||
key={a.pluginId}
|
key={a.pluginId}
|
||||||
|
onContextMenu={(e) => availableCtx.open(e, a)}
|
||||||
className="rounded-lg border border-border bg-surface-2 px-4 py-2.5 flex items-start gap-3"
|
className="rounded-lg border border-border bg-surface-2 px-4 py-2.5 flex items-start gap-3"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -1629,22 +1642,38 @@ function PluginsPanel({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ActionButton
|
<ActionButton icon={Plus} disabled={busy === a.pluginId} onClick={() => install(a)}>
|
||||||
icon={Plus}
|
|
||||||
disabled={busy === a.pluginId}
|
|
||||||
onClick={() =>
|
|
||||||
void run(
|
|
||||||
a.pluginId,
|
|
||||||
() => api.ccConfig.installPlugin(a.pluginId),
|
|
||||||
t("manage.installed", { name: a.pluginId })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{busy === a.pluginId ? t("manage.working") : t("manage.install")}
|
{busy === a.pluginId ? t("manage.working") : t("manage.install")}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{availableCtx.menu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={availableCtx.menu.x}
|
||||||
|
y={availableCtx.menu.y}
|
||||||
|
onClose={availableCtx.close}
|
||||||
|
testId="available-plugin-menu"
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
disabled={busy === availableCtx.menu.item.pluginId}
|
||||||
|
onSelect={() => {
|
||||||
|
availableCtx.close();
|
||||||
|
install(availableCtx.menu!.item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("manage.install")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
availableCtx.close();
|
||||||
|
void navigator.clipboard.writeText(availableCtx.menu!.item.pluginId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.copyName")}
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1674,6 +1703,33 @@ function PluginCard({
|
|||||||
// Which contribution surface is expanded, if any. One at a time: the card
|
// Which contribution surface is expanded, if any. One at a time: the card
|
||||||
// sits in a list, and two open lists push everything below off the screen.
|
// sits in a list, and two open lists push everything below off the screen.
|
||||||
const [openSurface, setOpenSurface] = useState<string | null>(null);
|
const [openSurface, setOpenSurface] = useState<string | null>(null);
|
||||||
|
const ctx = useContextMenu<true>();
|
||||||
|
const toggle = () =>
|
||||||
|
void run(
|
||||||
|
p.key,
|
||||||
|
() => api.ccConfig.pluginAction(p.key, p.enabled === false ? "enable" : "disable"),
|
||||||
|
t(p.enabled === false ? "manage.enabled" : "manage.disabled", { name: p.key })
|
||||||
|
);
|
||||||
|
const update = () =>
|
||||||
|
void run(
|
||||||
|
p.key,
|
||||||
|
// reload=true: updating one plugin reloads the open sessions
|
||||||
|
// right away. The batch reloads once at the end instead.
|
||||||
|
() => api.ccConfig.pluginAction(p.key, "update", true),
|
||||||
|
t("manage.updated", { name: p.key })
|
||||||
|
);
|
||||||
|
const uninstall = () => {
|
||||||
|
if (!confirmUninstall) {
|
||||||
|
setConfirmUninstall(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmUninstall(false);
|
||||||
|
void run(
|
||||||
|
p.key,
|
||||||
|
() => api.ccConfig.pluginAction(p.key, "uninstall"),
|
||||||
|
t("manage.uninstalled", { name: p.key })
|
||||||
|
);
|
||||||
|
};
|
||||||
const meta = p.contributes?.pluginJson;
|
const meta = p.contributes?.pluginJson;
|
||||||
const description = meta?.description;
|
const description = meta?.description;
|
||||||
const contribCounts: { key: string; count: number; label: string; items?: CcPluginItem[] }[] = [];
|
const contribCounts: { key: string; count: number; label: string; items?: CcPluginItem[] }[] = [];
|
||||||
@@ -1718,7 +1774,55 @@ function PluginCard({
|
|||||||
}
|
}
|
||||||
const opened = contribCounts.find((c) => c.key === openSurface && c.items?.length);
|
const opened = contribCounts.find((c) => c.key === openSurface && c.items?.length);
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3">
|
<div
|
||||||
|
data-testid={`plugin-card-${p.key}`}
|
||||||
|
onContextMenu={(e) => ctx.open(e, true)}
|
||||||
|
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
|
||||||
|
>
|
||||||
|
{ctx.menu && (
|
||||||
|
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="plugin-menu">
|
||||||
|
<MenuItem
|
||||||
|
disabled={busy === p.key}
|
||||||
|
onSelect={() => {
|
||||||
|
ctx.close();
|
||||||
|
toggle();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.enabled === false ? t("manage.enable") : t("manage.disable")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
disabled={busy === p.key}
|
||||||
|
onSelect={() => {
|
||||||
|
ctx.close();
|
||||||
|
update();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("manage.update")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
ctx.close();
|
||||||
|
void navigator.clipboard.writeText(p.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.copyName")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
danger
|
||||||
|
disabled={busy === p.key}
|
||||||
|
testId="plugin-menu-uninstall"
|
||||||
|
// Two clicks here too: the first arms (the card's button shows
|
||||||
|
// it), only the second uninstalls.
|
||||||
|
onSelect={() => {
|
||||||
|
if (confirmUninstall) ctx.close();
|
||||||
|
uninstall();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmUninstall ? t("manage.confirmUninstall") : t("manage.uninstall")}
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -1867,66 +1971,18 @@ function PluginCard({
|
|||||||
)}
|
)}
|
||||||
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
|
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
|
||||||
{p.enabled === false ? (
|
{p.enabled === false ? (
|
||||||
<ActionButton
|
<ActionButton icon={CircleDot} disabled={busy === p.key} onClick={toggle}>
|
||||||
icon={CircleDot}
|
|
||||||
disabled={busy === p.key}
|
|
||||||
onClick={() =>
|
|
||||||
void run(
|
|
||||||
p.key,
|
|
||||||
() => api.ccConfig.pluginAction(p.key, "enable"),
|
|
||||||
t("manage.enabled", { name: p.key })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("manage.enable")}
|
{t("manage.enable")}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
) : (
|
) : (
|
||||||
<ActionButton
|
<ActionButton icon={CircleSlash} disabled={busy === p.key} onClick={toggle}>
|
||||||
icon={CircleSlash}
|
|
||||||
disabled={busy === p.key}
|
|
||||||
onClick={() =>
|
|
||||||
void run(
|
|
||||||
p.key,
|
|
||||||
() => api.ccConfig.pluginAction(p.key, "disable"),
|
|
||||||
t("manage.disabled", { name: p.key })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("manage.disable")}
|
{t("manage.disable")}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
)}
|
)}
|
||||||
<ActionButton
|
<ActionButton icon={RefreshCw} disabled={busy === p.key} onClick={update}>
|
||||||
icon={RefreshCw}
|
|
||||||
disabled={busy === p.key}
|
|
||||||
onClick={() =>
|
|
||||||
void run(
|
|
||||||
p.key,
|
|
||||||
// reload=true: updating one plugin reloads the open sessions
|
|
||||||
// right away. The batch reloads once at the end instead.
|
|
||||||
() => api.ccConfig.pluginAction(p.key, "update", true),
|
|
||||||
t("manage.updated", { name: p.key })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("manage.update")}
|
{t("manage.update")}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton
|
<ActionButton icon={Trash2} danger disabled={busy === p.key} onClick={uninstall}>
|
||||||
icon={Trash2}
|
|
||||||
danger
|
|
||||||
disabled={busy === p.key}
|
|
||||||
onClick={() => {
|
|
||||||
if (!confirmUninstall) {
|
|
||||||
setConfirmUninstall(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setConfirmUninstall(false);
|
|
||||||
void run(
|
|
||||||
p.key,
|
|
||||||
() => api.ccConfig.pluginAction(p.key, "uninstall"),
|
|
||||||
t("manage.uninstalled", { name: p.key })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{busy === p.key
|
{busy === p.key
|
||||||
? t("manage.working")
|
? t("manage.working")
|
||||||
: confirmUninstall
|
: confirmUninstall
|
||||||
@@ -2139,14 +2195,58 @@ function McpCard({
|
|||||||
t: (key: string, opts?: Record<string, unknown>) => string;
|
t: (key: string, opts?: Record<string, unknown>) => string;
|
||||||
}) {
|
}) {
|
||||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
|
const ctx = useContextMenu<true>();
|
||||||
const busyId = `${scope}:${server.name}`;
|
const busyId = `${scope}:${server.name}`;
|
||||||
// The reader decides this: entries from ~/.claude/settings.json have no CLI
|
// The reader decides this: entries from ~/.claude/settings.json have no CLI
|
||||||
// scope that addresses them, so a Remove button would look live and never
|
// scope that addresses them, so a Remove button would look live and never
|
||||||
// work. Reading it off a flag rather than matching `source` keeps a reworded
|
// work. Reading it off a flag rather than matching `source` keeps a reworded
|
||||||
// display string from silently bringing that button back.
|
// display string from silently bringing that button back.
|
||||||
const removable = server.removable !== false;
|
const removable = server.removable !== false;
|
||||||
|
const remove = () => {
|
||||||
|
if (!confirmRemove) {
|
||||||
|
setConfirmRemove(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmRemove(false);
|
||||||
|
void run(
|
||||||
|
busyId,
|
||||||
|
() => api.ccConfig.mcpRemove(server.name, scope),
|
||||||
|
t("manage.mcpRemoved", { name: server.name })
|
||||||
|
);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-surface-2 px-4 py-3">
|
<div
|
||||||
|
onContextMenu={(e) => ctx.open(e, true)}
|
||||||
|
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
|
||||||
|
>
|
||||||
|
{ctx.menu && (
|
||||||
|
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="mcp-menu">
|
||||||
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
ctx.close();
|
||||||
|
void navigator.clipboard.writeText(server.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.copyName")}
|
||||||
|
</MenuItem>
|
||||||
|
{removable && (
|
||||||
|
<>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
danger
|
||||||
|
disabled={busy === busyId}
|
||||||
|
// Two clicks, like the card's Remove button.
|
||||||
|
onSelect={() => {
|
||||||
|
if (confirmRemove) ctx.close();
|
||||||
|
remove();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmRemove ? t("manage.confirmRemove") : t("manage.remove")}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="font-mono text-sm text-fg-primary">{server.name}</span>
|
<span className="font-mono text-sm text-fg-primary">{server.name}</span>
|
||||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
|
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-3 text-fg-secondary border border-border">
|
||||||
@@ -2187,23 +2287,7 @@ function McpCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
{removable ? (
|
{removable ? (
|
||||||
<ActionButton
|
<ActionButton icon={Trash2} danger disabled={busy === busyId} onClick={remove}>
|
||||||
icon={Trash2}
|
|
||||||
danger
|
|
||||||
disabled={busy === busyId}
|
|
||||||
onClick={() => {
|
|
||||||
if (!confirmRemove) {
|
|
||||||
setConfirmRemove(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setConfirmRemove(false);
|
|
||||||
void run(
|
|
||||||
busyId,
|
|
||||||
() => api.ccConfig.mcpRemove(server.name, scope),
|
|
||||||
t("manage.mcpRemoved", { name: server.name })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{busy === busyId
|
{busy === busyId
|
||||||
? t("manage.working")
|
? t("manage.working")
|
||||||
: confirmRemove
|
: confirmRemove
|
||||||
@@ -3270,6 +3354,7 @@ function MemoryPanel({
|
|||||||
onCreateAuto,
|
onCreateAuto,
|
||||||
}: MemoryPanelProps) {
|
}: MemoryPanelProps) {
|
||||||
const { t } = useTranslation("ccConfig");
|
const { t } = useTranslation("ccConfig");
|
||||||
|
const ctx = useContextMenu<CcMemoryItem & { scope: "user" | "project" }>();
|
||||||
|
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
|
|
||||||
@@ -3328,8 +3413,29 @@ function MemoryPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Primary CLAUDE.md memory (user + project) — editable */}
|
{/* Primary CLAUDE.md memory (user + project) — editable */}
|
||||||
|
{ctx.menu && (
|
||||||
|
<FileItemMenu
|
||||||
|
x={ctx.menu.x}
|
||||||
|
y={ctx.menu.y}
|
||||||
|
onClose={ctx.close}
|
||||||
|
path={ctx.menu.item.file}
|
||||||
|
onOpen={() => onOpen(ctx.menu!.item.file)}
|
||||||
|
onEdit={() => {
|
||||||
|
const m = ctx.menu!.item;
|
||||||
|
onEdit("memory", { scope: m.scope, name: "", filePath: m.file });
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
const m = ctx.menu!.item;
|
||||||
|
onDelete("memory", m.scope, undefined, m.file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{primary.map((m) => (
|
{primary.map((m) => (
|
||||||
<div key={m.scope} className="rounded-lg border border-border bg-surface-2">
|
<div
|
||||||
|
key={m.scope}
|
||||||
|
onContextMenu={(e) => ctx.open(e, m)}
|
||||||
|
className="rounded-lg border border-border bg-surface-2"
|
||||||
|
>
|
||||||
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2 flex-wrap">
|
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2 flex-wrap">
|
||||||
<ScopeBadge scope={m.scope} />
|
<ScopeBadge scope={m.scope} />
|
||||||
<span className="font-mono text-[11px] text-fg-muted truncate flex-1 min-w-0">
|
<span className="font-mono text-[11px] text-fg-muted truncate flex-1 min-w-0">
|
||||||
@@ -3461,6 +3567,7 @@ function MemoryProjectGroup({
|
|||||||
|
|
||||||
const indexFiles = files.filter((f) => f.isIndex);
|
const indexFiles = files.filter((f) => f.isIndex);
|
||||||
const factFiles = files.filter((f) => !f.isIndex);
|
const factFiles = files.filter((f) => !f.isIndex);
|
||||||
|
const ctx = useContextMenu<CcMemoryItem>();
|
||||||
|
|
||||||
// Wiring for "click an index entry → jump to its fact file". Fact rows
|
// Wiring for "click an index entry → jump to its fact file". Fact rows
|
||||||
// register their DOM node keyed by filename; the index links look them up.
|
// register their DOM node keyed by filename; the index links look them up.
|
||||||
@@ -3497,6 +3604,17 @@ function MemoryProjectGroup({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border bg-surface-2 overflow-hidden">
|
<div className="rounded-lg border border-border bg-surface-2 overflow-hidden">
|
||||||
|
{ctx.menu && (
|
||||||
|
<FileItemMenu
|
||||||
|
x={ctx.menu.x}
|
||||||
|
y={ctx.menu.y}
|
||||||
|
onClose={ctx.close}
|
||||||
|
path={ctx.menu.item.file}
|
||||||
|
onOpen={() => onOpen(ctx.menu!.item.file)}
|
||||||
|
onEdit={() => onEditAuto(ctx.menu!.item)}
|
||||||
|
onDelete={() => onDeleteAuto(ctx.menu!.item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-1 pr-2 hover:bg-surface-3 transition-colors">
|
<div className="flex items-center gap-1 pr-2 hover:bg-surface-3 transition-colors">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
@@ -3541,6 +3659,7 @@ function MemoryProjectGroup({
|
|||||||
onEditAuto={onEditAuto}
|
onEditAuto={onEditAuto}
|
||||||
onDeleteAuto={onDeleteAuto}
|
onDeleteAuto={onDeleteAuto}
|
||||||
onJump={handleJump}
|
onJump={handleJump}
|
||||||
|
onMenu={ctx.open}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -3561,6 +3680,7 @@ function MemoryProjectGroup({
|
|||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onEditAuto={onEditAuto}
|
onEditAuto={onEditAuto}
|
||||||
onDeleteAuto={onDeleteAuto}
|
onDeleteAuto={onDeleteAuto}
|
||||||
|
onMenu={ctx.open}
|
||||||
highlighted={highlighted === key}
|
highlighted={highlighted === key}
|
||||||
rowRef={(el) => {
|
rowRef={(el) => {
|
||||||
if (el) rowRefs.current.set(key, el);
|
if (el) rowRefs.current.set(key, el);
|
||||||
@@ -3621,10 +3741,17 @@ function MemoryIndexCard({
|
|||||||
onEditAuto,
|
onEditAuto,
|
||||||
onDeleteAuto,
|
onDeleteAuto,
|
||||||
onJump,
|
onJump,
|
||||||
}: MemoryAutoItemProps & { onJump: (target: string) => void }) {
|
onMenu,
|
||||||
|
}: MemoryAutoItemProps & {
|
||||||
|
onJump: (target: string) => void;
|
||||||
|
onMenu: (e: React.MouseEvent, item: CcMemoryItem) => void;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation("ccConfig");
|
const { t } = useTranslation("ccConfig");
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-teal-500/20 bg-teal-500/5">
|
<div
|
||||||
|
onContextMenu={(e) => onMenu(e, item)}
|
||||||
|
className="rounded-md border border-teal-500/20 bg-teal-500/5"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-teal-500/15">
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-teal-500/15">
|
||||||
<BookOpen className="w-3 h-3 text-teal-700 dark:text-teal-300 flex-shrink-0" />
|
<BookOpen className="w-3 h-3 text-teal-700 dark:text-teal-300 flex-shrink-0" />
|
||||||
<span className="font-mono text-[11px] text-fg-secondary truncate flex-1 min-w-0">
|
<span className="font-mono text-[11px] text-fg-secondary truncate flex-1 min-w-0">
|
||||||
@@ -3651,9 +3778,11 @@ function MemoryFactRow({
|
|||||||
onOpen,
|
onOpen,
|
||||||
onEditAuto,
|
onEditAuto,
|
||||||
onDeleteAuto,
|
onDeleteAuto,
|
||||||
|
onMenu,
|
||||||
highlighted,
|
highlighted,
|
||||||
rowRef,
|
rowRef,
|
||||||
}: MemoryAutoItemProps & {
|
}: MemoryAutoItemProps & {
|
||||||
|
onMenu: (e: React.MouseEvent, item: CcMemoryItem) => void;
|
||||||
highlighted?: boolean;
|
highlighted?: boolean;
|
||||||
rowRef?: (el: HTMLDivElement | null) => void;
|
rowRef?: (el: HTMLDivElement | null) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -3661,6 +3790,7 @@ function MemoryFactRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rowRef}
|
ref={rowRef}
|
||||||
|
onContextMenu={(e) => onMenu(e, item)}
|
||||||
className={`flex items-start gap-2.5 rounded-md border px-3 py-2 transition-colors ${
|
className={`flex items-start gap-2.5 rounded-md border px-3 py-2 transition-colors ${
|
||||||
highlighted
|
highlighted
|
||||||
? "border-teal-400/70 bg-teal-500/10 ring-1 ring-teal-400/50"
|
? "border-teal-400/70 bg-teal-500/10 ring-1 ring-teal-400/50"
|
||||||
@@ -3706,6 +3836,25 @@ function MarketplacesPanel({
|
|||||||
const { busy, run } = useConfigAction(onToast, onChanged);
|
const { busy, run } = useConfigAction(onToast, onChanged);
|
||||||
const [source, setSource] = useState("");
|
const [source, setSource] = useState("");
|
||||||
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
|
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
|
||||||
|
const ctx = useContextMenu<CcMarketplacesResponse["items"][number]>();
|
||||||
|
const update = (name: string) =>
|
||||||
|
void run(
|
||||||
|
name,
|
||||||
|
() => api.ccConfig.marketplaceAction(name, "update"),
|
||||||
|
t("manage.marketplaceUpdated", { name })
|
||||||
|
);
|
||||||
|
const remove = (name: string) => {
|
||||||
|
if (confirmRemove !== name) {
|
||||||
|
setConfirmRemove(name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmRemove(null);
|
||||||
|
void run(
|
||||||
|
name,
|
||||||
|
() => api.ccConfig.marketplaceAction(name, "remove"),
|
||||||
|
t("manage.marketplaceRemoved", { name })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const add = useCallback(async () => {
|
const add = useCallback(async () => {
|
||||||
const value = source.trim();
|
const value = source.trim();
|
||||||
@@ -3772,7 +3921,11 @@ function MarketplacesPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((m) => (
|
filtered.map((m) => (
|
||||||
<div key={m.name} className="rounded-lg border border-border bg-surface-2 px-4 py-3">
|
<div
|
||||||
|
key={m.name}
|
||||||
|
onContextMenu={(e) => ctx.open(e, m)}
|
||||||
|
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Store className="w-3.5 h-3.5 text-fg-muted" />
|
<Store className="w-3.5 h-3.5 text-fg-muted" />
|
||||||
<span className="font-mono text-sm text-fg-primary">{m.name}</span>
|
<span className="font-mono text-sm text-fg-primary">{m.name}</span>
|
||||||
@@ -3828,13 +3981,7 @@ function MarketplacesPanel({
|
|||||||
<ActionButton
|
<ActionButton
|
||||||
icon={RefreshCw}
|
icon={RefreshCw}
|
||||||
disabled={busy === m.name}
|
disabled={busy === m.name}
|
||||||
onClick={() =>
|
onClick={() => update(m.name)}
|
||||||
void run(
|
|
||||||
m.name,
|
|
||||||
() => api.ccConfig.marketplaceAction(m.name, "update"),
|
|
||||||
t("manage.marketplaceUpdated", { name: m.name })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{t("manage.update")}
|
{t("manage.update")}
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
@@ -3842,18 +3989,7 @@ function MarketplacesPanel({
|
|||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
danger
|
danger
|
||||||
disabled={busy === m.name}
|
disabled={busy === m.name}
|
||||||
onClick={() => {
|
onClick={() => remove(m.name)}
|
||||||
if (confirmRemove !== m.name) {
|
|
||||||
setConfirmRemove(m.name);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setConfirmRemove(null);
|
|
||||||
void run(
|
|
||||||
m.name,
|
|
||||||
() => api.ccConfig.marketplaceAction(m.name, "remove"),
|
|
||||||
t("manage.marketplaceRemoved", { name: m.name })
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{busy === m.name
|
{busy === m.name
|
||||||
? t("manage.working")
|
? t("manage.working")
|
||||||
@@ -3865,6 +4001,49 @@ function MarketplacesPanel({
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
{ctx.menu && (
|
||||||
|
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="marketplace-menu">
|
||||||
|
<MenuItem
|
||||||
|
disabled={busy === ctx.menu.item.name}
|
||||||
|
onSelect={() => {
|
||||||
|
ctx.close();
|
||||||
|
update(ctx.menu!.item.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("manage.update")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
ctx.close();
|
||||||
|
void navigator.clipboard.writeText(ctx.menu!.item.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.copyName")}
|
||||||
|
</MenuItem>
|
||||||
|
{ctx.menu.item.installLocation && (
|
||||||
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
ctx.close();
|
||||||
|
void navigator.clipboard.writeText(ctx.menu!.item.installLocation!);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.copyPath")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
danger
|
||||||
|
disabled={busy === ctx.menu.item.name}
|
||||||
|
// Two clicks, like the card's Remove button.
|
||||||
|
onSelect={() => {
|
||||||
|
if (confirmRemove === ctx.menu!.item.name) ctx.close();
|
||||||
|
remove(ctx.menu!.item.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmRemove === ctx.menu.item.name ? t("manage.confirmRemove") : t("manage.remove")}
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4254,6 +4433,7 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
|||||||
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
|
const [usageBusy, setUsageBusy] = useState<Record<string, boolean>>({});
|
||||||
/** Names already auto-fetched, so the effect below fires once per profile. */
|
/** Names already auto-fetched, so the effect below fires once per profile. */
|
||||||
const autoFetched = useRef<Set<string>>(new Set());
|
const autoFetched = useRef<Set<string>>(new Set());
|
||||||
|
const ctx = useContextMenu<CcProfile>();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -4272,11 +4452,12 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
// An open right-click menu takes this Escape for itself.
|
||||||
|
if (e.key === "Escape" && !ctx.menu) onClose();
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [onClose]);
|
}, [onClose, ctx.menu]);
|
||||||
|
|
||||||
/** Run one profile mutation, then refresh the list. */
|
/** Run one profile mutation, then refresh the list. */
|
||||||
const run = useCallback(
|
const run = useCallback(
|
||||||
@@ -4339,6 +4520,31 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
|||||||
/** How many saved profiles hold the same account as `p`. */
|
/** How many saved profiles hold the same account as `p`. */
|
||||||
const copies = (p: CcProfile) =>
|
const copies = (p: CcProfile) =>
|
||||||
items?.filter((q) => q.accountUuid && q.accountUuid === p.accountUuid).length ?? 1;
|
items?.filter((q) => q.accountUuid && q.accountUuid === p.accountUuid).length ?? 1;
|
||||||
|
/** Deletable unless it is the ONLY saved copy of the live account — a
|
||||||
|
* redundant copy has to be removable, or an accidental duplicate could
|
||||||
|
* never be cleaned up. */
|
||||||
|
const deletable = (p: CcProfile) => !p.active || copies(p) > 1;
|
||||||
|
const startRename = (p: CcProfile) => {
|
||||||
|
setDraft(p.label);
|
||||||
|
setRenaming(p.name);
|
||||||
|
};
|
||||||
|
const switchTo = (p: CcProfile) =>
|
||||||
|
void run(
|
||||||
|
`switch:${p.name}`,
|
||||||
|
() => api.ccConfig.activateProfile(p.name),
|
||||||
|
t("profiles.switchSuccess", { name: p.label })
|
||||||
|
);
|
||||||
|
const remove = (p: CcProfile) =>
|
||||||
|
void run(
|
||||||
|
`del:${p.name}`,
|
||||||
|
() => api.ccConfig.deleteProfile(p.name),
|
||||||
|
t("profiles.deleteSuccess", { name: p.label })
|
||||||
|
);
|
||||||
|
const pick = (fn: (p: CcProfile) => void) => () => {
|
||||||
|
const p = ctx.menu!.item;
|
||||||
|
ctx.close();
|
||||||
|
fn(p);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -4415,7 +4621,12 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{items?.map((p) => (
|
{items?.map((p) => (
|
||||||
<div key={p.name} className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
|
<div
|
||||||
|
key={p.name}
|
||||||
|
data-testid={`profile-row-${p.name}`}
|
||||||
|
onContextMenu={(e) => ctx.open(e, p)}
|
||||||
|
className="rounded-lg border border-border bg-surface-2 px-3 py-2.5"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{renaming === p.name ? (
|
{renaming === p.name ? (
|
||||||
<input
|
<input
|
||||||
@@ -4441,10 +4652,7 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => startRename(p)}
|
||||||
setDraft(p.label);
|
|
||||||
setRenaming(p.name);
|
|
||||||
}}
|
|
||||||
title={t("profiles.renameHint")}
|
title={t("profiles.renameHint")}
|
||||||
className="text-xs font-medium text-fg-primary hover:text-accent inline-flex items-center gap-1 group"
|
className="text-xs font-medium text-fg-primary hover:text-accent inline-flex items-center gap-1 group"
|
||||||
>
|
>
|
||||||
@@ -4463,13 +4671,7 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
|||||||
{!p.active && (
|
{!p.active && (
|
||||||
<button
|
<button
|
||||||
disabled={busy !== null}
|
disabled={busy !== null}
|
||||||
onClick={() =>
|
onClick={() => switchTo(p)}
|
||||||
void run(
|
|
||||||
`switch:${p.name}`,
|
|
||||||
() => api.ccConfig.activateProfile(p.name),
|
|
||||||
t("profiles.switchSuccess", { name: p.label })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent disabled:opacity-50"
|
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{busy === `switch:${p.name}`
|
{busy === `switch:${p.name}`
|
||||||
@@ -4477,19 +4679,10 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
|||||||
: t("profiles.switchButton")}
|
: t("profiles.switchButton")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Deletable unless it is the ONLY saved copy of the live
|
{deletable(p) && (
|
||||||
account — a redundant copy has to be removable, or an
|
|
||||||
accidental duplicate could never be cleaned up. */}
|
|
||||||
{(!p.active || copies(p) > 1) && (
|
|
||||||
<button
|
<button
|
||||||
disabled={busy !== null}
|
disabled={busy !== null}
|
||||||
onClick={() =>
|
onClick={() => remove(p)}
|
||||||
void run(
|
|
||||||
`del:${p.name}`,
|
|
||||||
() => api.ccConfig.deleteProfile(p.name),
|
|
||||||
t("profiles.deleteSuccess", { name: p.label })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
title={t("profiles.deleteButton")}
|
title={t("profiles.deleteButton")}
|
||||||
aria-label={t("profiles.deleteButton")}
|
aria-label={t("profiles.deleteButton")}
|
||||||
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
||||||
@@ -4514,6 +4707,36 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{ctx.menu && (
|
||||||
|
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
|
||||||
|
<MenuItem onSelect={pick(startRename)}>{t("profiles.rename")}</MenuItem>
|
||||||
|
{!ctx.menu.item.active && (
|
||||||
|
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
|
||||||
|
{t("profiles.switchButton")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem
|
||||||
|
disabled={busy !== null || !!usageBusy[ctx.menu.item.name]}
|
||||||
|
onSelect={pick((p) => void checkUsage(p.name))}
|
||||||
|
>
|
||||||
|
{t("profiles.usageCheck")}
|
||||||
|
</MenuItem>
|
||||||
|
{deletable(ctx.menu.item) && (
|
||||||
|
<>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
danger
|
||||||
|
disabled={busy !== null}
|
||||||
|
testId="profile-menu-delete"
|
||||||
|
onSelect={pick(remove)}
|
||||||
|
>
|
||||||
|
{t("profiles.deleteButton")}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[10px] text-fg-muted mb-1">{t("profiles.loginHint")}</div>
|
<div className="text-[10px] text-fg-muted mb-1">{t("profiles.loginHint")}</div>
|
||||||
<CommandSnippet command="claude auth login" />
|
<CommandSnippet command="claude auth login" />
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
* dashboard's own Codex settings: CODEX_HOME (editable, so the overview's path
|
* dashboard's own Codex settings: CODEX_HOME (editable, so the overview's path
|
||||||
* list leaves it out), AgentHub's Codex hooks (`CodexHooksSettings`) and Model
|
* list leaves it out), AgentHub's Codex hooks (`CodexHooksSettings`) and Model
|
||||||
* Pricing for OpenAI models (the shared `PricingSettings` table).
|
* Pricing for OpenAI models (the shared `PricingSettings` table).
|
||||||
|
* Every card with action buttons also opens a right-click menu repeating
|
||||||
|
* them (same handlers — Delete still asks for confirmation) plus Copy
|
||||||
|
* name/path.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -52,6 +55,14 @@ import {
|
|||||||
} from "../components/config/ConfirmDeleteModal";
|
} from "../components/config/ConfirmDeleteModal";
|
||||||
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
|
import { ToastNotice, type Toast } from "../components/config/ToastNotice";
|
||||||
import { ScopeBadge } from "../components/config/ScopeBadge";
|
import { ScopeBadge } from "../components/config/ScopeBadge";
|
||||||
|
import { FileItemMenu } from "../components/config/MdItemList";
|
||||||
|
import {
|
||||||
|
ContextMenu,
|
||||||
|
MenuItem,
|
||||||
|
MenuSeparator,
|
||||||
|
useContextMenu,
|
||||||
|
type MenuPoint,
|
||||||
|
} from "../components/ContextMenu";
|
||||||
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
||||||
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
||||||
import { HomeDirSetting } from "../components/settings/HomeDirSetting";
|
import { HomeDirSetting } from "../components/settings/HomeDirSetting";
|
||||||
@@ -961,6 +972,8 @@ function ArtifactList({
|
|||||||
return blob.toLowerCase().includes(q);
|
return blob.toLowerCase().includes(q);
|
||||||
});
|
});
|
||||||
}, [items, search]);
|
}, [items, search]);
|
||||||
|
const { t } = useTranslation("codexConfig");
|
||||||
|
const ctx = useContextMenu<NamedArtifactItem>();
|
||||||
|
|
||||||
if (!filtered) return <SkeletonRows n={6} />;
|
if (!filtered) return <SkeletonRows n={6} />;
|
||||||
if (filtered.length === 0) return <Empty />;
|
if (filtered.length === 0) return <Empty />;
|
||||||
@@ -975,21 +988,50 @@ function ArtifactList({
|
|||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
|
onMenu={ctx.open}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{ctx.menu && (
|
||||||
|
<FileItemMenu
|
||||||
|
x={ctx.menu.x}
|
||||||
|
y={ctx.menu.y}
|
||||||
|
onClose={ctx.close}
|
||||||
|
path={ctx.menu.item.file}
|
||||||
|
labels={fileMenuLabels(t)}
|
||||||
|
onOpen={() => onOpen(ctx.menu!.item.file)}
|
||||||
|
onEdit={() => {
|
||||||
|
const it = ctx.menu!.item;
|
||||||
|
onEdit(kind, { scope: it.scope, name: it.name, filePath: it.file });
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
const it = ctx.menu!.item;
|
||||||
|
onDelete(kind, it.scope, it.name, it.file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The Codex page's labels for the shared file-card menu — its buttons' own. */
|
||||||
|
const fileMenuLabels = (t: (key: string) => string) => ({
|
||||||
|
open: t("list.viewButton"),
|
||||||
|
edit: t("list.editButton"),
|
||||||
|
delete: t("list.deleteButton"),
|
||||||
|
copyPath: t("list.copyPath"),
|
||||||
|
});
|
||||||
|
|
||||||
function ArtifactCard({
|
function ArtifactCard({
|
||||||
item,
|
item,
|
||||||
kind,
|
kind,
|
||||||
onOpen,
|
onOpen,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onMenu,
|
||||||
}: {
|
}: {
|
||||||
item: NamedArtifactItem;
|
item: NamedArtifactItem;
|
||||||
kind: "skills" | "agents" | "rules";
|
kind: "skills" | "agents" | "rules";
|
||||||
|
onMenu: (e: React.MouseEvent, item: NamedArtifactItem) => void;
|
||||||
onOpen: (path: string) => void;
|
onOpen: (path: string) => void;
|
||||||
onEdit: (
|
onEdit: (
|
||||||
type: CodexArtifactType,
|
type: CodexArtifactType,
|
||||||
@@ -1011,7 +1053,10 @@ function ArtifactCard({
|
|||||||
.trim()
|
.trim()
|
||||||
.slice(0, 200);
|
.slice(0, 200);
|
||||||
return (
|
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="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -1081,6 +1126,7 @@ function ScopeFilePanel({
|
|||||||
onCreate: (type: CodexArtifactType, scope: "user" | "project") => void;
|
onCreate: (type: CodexArtifactType, scope: "user" | "project") => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("codexConfig");
|
const { t } = useTranslation("codexConfig");
|
||||||
|
const ctx = useContextMenu<CodexMemoryFile | CodexConfigFile>();
|
||||||
if (!items) return <SkeletonRows n={2} />;
|
if (!items) return <SkeletonRows n={2} />;
|
||||||
|
|
||||||
const present = items.filter((m) => m.exists);
|
const present = items.filter((m) => m.exists);
|
||||||
@@ -1090,10 +1136,32 @@ function ScopeFilePanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{ctx.menu && (
|
||||||
|
<FileItemMenu
|
||||||
|
x={ctx.menu.x}
|
||||||
|
y={ctx.menu.y}
|
||||||
|
onClose={ctx.close}
|
||||||
|
path={ctx.menu.item.file}
|
||||||
|
labels={fileMenuLabels(t)}
|
||||||
|
onOpen={() => onOpen(ctx.menu!.item.file)}
|
||||||
|
onEdit={() => {
|
||||||
|
const m = ctx.menu!.item;
|
||||||
|
onEdit(type, { scope: m.scope, name: "", filePath: m.file });
|
||||||
|
}}
|
||||||
|
onDelete={() => {
|
||||||
|
const m = ctx.menu!.item;
|
||||||
|
onDelete(type, m.scope, undefined, m.file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{present.map((m) => {
|
{present.map((m) => {
|
||||||
const preview = "preview" in m ? m.preview : "content" in m ? m.content : "";
|
const preview = "preview" in m ? m.preview : "content" in m ? m.content : "";
|
||||||
return (
|
return (
|
||||||
<div key={m.scope} className="rounded-lg border border-border bg-surface-2">
|
<div
|
||||||
|
key={m.scope}
|
||||||
|
onContextMenu={(e) => ctx.open(e, m)}
|
||||||
|
className="rounded-lg border border-border bg-surface-2"
|
||||||
|
>
|
||||||
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2 flex-wrap">
|
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2 flex-wrap">
|
||||||
<ScopeBadge scope={m.scope} />
|
<ScopeBadge scope={m.scope} />
|
||||||
<span className="font-mono text-[11px] text-fg-muted truncate flex-1 min-w-0">
|
<span className="font-mono text-[11px] text-fg-muted truncate flex-1 min-w-0">
|
||||||
@@ -1211,6 +1279,50 @@ function CliUnavailable({ message }: { message: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A CLI-backed row as its right-click menu sees it: its name, and its
|
||||||
|
* Remove button's own action and label. */
|
||||||
|
type CliRow = { label: string; real: boolean; removeLabel: string; remove: () => void };
|
||||||
|
|
||||||
|
/** Copy the row's name, and run its Remove button's action — disabled exactly
|
||||||
|
* when the button is. */
|
||||||
|
function CliRowMenu({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
item,
|
||||||
|
busy,
|
||||||
|
onClose,
|
||||||
|
}: MenuPoint & { item: CliRow; busy: string | null; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation("codexConfig");
|
||||||
|
return (
|
||||||
|
<ContextMenu x={x} y={y} onClose={onClose} testId="codex-row-menu">
|
||||||
|
{item.real && (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
onClose();
|
||||||
|
void navigator.clipboard.writeText(item.label);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("list.copyName")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<MenuItem
|
||||||
|
danger
|
||||||
|
disabled={busy === item.label || !item.real}
|
||||||
|
testId="codex-row-menu-remove"
|
||||||
|
onSelect={() => {
|
||||||
|
onClose();
|
||||||
|
item.remove();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.removeLabel}
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function KvRows({ item, skip }: { item: Record<string, unknown>; skip: string[] }) {
|
function KvRows({ item, skip }: { item: Record<string, unknown>; skip: string[] }) {
|
||||||
const entries = Object.entries(item).filter(
|
const entries = Object.entries(item).filter(
|
||||||
([k, v]) =>
|
([k, v]) =>
|
||||||
@@ -1245,6 +1357,7 @@ function McpPanel({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("codexConfig");
|
const { t } = useTranslation("codexConfig");
|
||||||
const { busy, run } = useConfigAction(onToast, onChanged);
|
const { busy, run } = useConfigAction(onToast, onChanged);
|
||||||
|
const ctx = useContextMenu<CliRow>();
|
||||||
const [mode, setMode] = useState<"command" | "url">("command");
|
const [mode, setMode] = useState<"command" | "url">("command");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [command, setCommand] = useState("");
|
const [command, setCommand] = useState("");
|
||||||
@@ -1360,21 +1473,27 @@ function McpPanel({
|
|||||||
) : (
|
) : (
|
||||||
filtered.map((s, i) => {
|
filtered.map((s, i) => {
|
||||||
const { label, real } = itemLabel(s, i, t("tabs.mcp"));
|
const { label, real } = itemLabel(s, i, t("tabs.mcp"));
|
||||||
|
const remove = () =>
|
||||||
|
void run(
|
||||||
|
label,
|
||||||
|
() => api.codexConfig.mcpRemove(label),
|
||||||
|
t("mcp.removed", { name: label })
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div key={label + i} className="rounded-lg border border-border bg-surface-2 px-4 py-3">
|
<div
|
||||||
|
key={label + i}
|
||||||
|
onContextMenu={(e) =>
|
||||||
|
ctx.open(e, { label, real, remove, removeLabel: t("mcp.removeButton") })
|
||||||
|
}
|
||||||
|
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
|
||||||
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<span className="font-mono text-sm text-fg-primary truncate">{label}</span>
|
<span className="font-mono text-sm text-fg-primary truncate">{label}</span>
|
||||||
<KvRows item={s} skip={["name"]} />
|
<KvRows item={s} skip={["name"]} />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={remove}
|
||||||
void run(
|
|
||||||
label,
|
|
||||||
() => api.codexConfig.mcpRemove(label),
|
|
||||||
t("mcp.removed", { name: label })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={busy === label || !real}
|
disabled={busy === label || !real}
|
||||||
title={real ? undefined : t("manage.noIdentifier")}
|
title={real ? undefined : t("manage.noIdentifier")}
|
||||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
|
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
|
||||||
@@ -1387,6 +1506,7 @@ function McpPanel({
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
{ctx.menu && <CliRowMenu {...ctx.menu} busy={busy} onClose={ctx.close} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1408,6 +1528,7 @@ function PluginsPanel({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("codexConfig");
|
const { t } = useTranslation("codexConfig");
|
||||||
const { busy, run } = useConfigAction(onToast, onChanged);
|
const { busy, run } = useConfigAction(onToast, onChanged);
|
||||||
|
const ctx = useContextMenu<CliRow>();
|
||||||
const [spec, setSpec] = useState("");
|
const [spec, setSpec] = useState("");
|
||||||
const [mktUrl, setMktUrl] = useState("");
|
const [mktUrl, setMktUrl] = useState("");
|
||||||
|
|
||||||
@@ -1482,9 +1603,18 @@ function PluginsPanel({
|
|||||||
) : (
|
) : (
|
||||||
filteredPlugins.map((p, i) => {
|
filteredPlugins.map((p, i) => {
|
||||||
const { label, real } = itemLabel(p, i, t("tabs.plugins"));
|
const { label, real } = itemLabel(p, i, t("tabs.plugins"));
|
||||||
|
const remove = () =>
|
||||||
|
void run(
|
||||||
|
label,
|
||||||
|
() => api.codexConfig.pluginAction({ spec: label, action: "remove" }),
|
||||||
|
t("plugins.removed", { name: label })
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={label + i}
|
key={label + i}
|
||||||
|
onContextMenu={(e) =>
|
||||||
|
ctx.open(e, { label, real, remove, removeLabel: t("plugins.removeButton") })
|
||||||
|
}
|
||||||
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
|
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -1493,13 +1623,7 @@ function PluginsPanel({
|
|||||||
<KvRows item={p} skip={["name"]} />
|
<KvRows item={p} skip={["name"]} />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={remove}
|
||||||
void run(
|
|
||||||
label,
|
|
||||||
() => api.codexConfig.pluginAction({ spec: label, action: "remove" }),
|
|
||||||
t("plugins.removed", { name: label })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={busy === label || !real}
|
disabled={busy === label || !real}
|
||||||
title={real ? undefined : t("manage.noIdentifier")}
|
title={real ? undefined : t("manage.noIdentifier")}
|
||||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
|
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
|
||||||
@@ -1560,9 +1684,18 @@ function PluginsPanel({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filteredMarketplaces.map((m, i) => {
|
{filteredMarketplaces.map((m, i) => {
|
||||||
const { label, real } = itemLabel(m, i, t("plugins.marketplacesTitle"));
|
const { label, real } = itemLabel(m, i, t("plugins.marketplacesTitle"));
|
||||||
|
const remove = () =>
|
||||||
|
void run(
|
||||||
|
label,
|
||||||
|
() => api.codexConfig.marketplaceAction({ name: label, action: "remove" }),
|
||||||
|
t("plugins.marketplaceRemoved", { name: label })
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={label + i}
|
key={label + i}
|
||||||
|
onContextMenu={(e) =>
|
||||||
|
ctx.open(e, { label, real, remove, removeLabel: t("plugins.removeButton") })
|
||||||
|
}
|
||||||
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
|
className="rounded-lg border border-border bg-surface-2 px-4 py-3"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
@@ -1571,14 +1704,7 @@ function PluginsPanel({
|
|||||||
<KvRows item={m} skip={["name"]} />
|
<KvRows item={m} skip={["name"]} />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={remove}
|
||||||
void run(
|
|
||||||
label,
|
|
||||||
() =>
|
|
||||||
api.codexConfig.marketplaceAction({ name: label, action: "remove" }),
|
|
||||||
t("plugins.marketplaceRemoved", { name: label })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={busy === label || !real}
|
disabled={busy === label || !real}
|
||||||
title={real ? undefined : t("manage.noIdentifier")}
|
title={real ? undefined : t("manage.noIdentifier")}
|
||||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
|
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-status-danger/30 bg-status-danger/10 hover:bg-status-danger/20 text-status-danger inline-flex items-center gap-1.5 disabled:opacity-50 flex-shrink-0"
|
||||||
@@ -1600,6 +1726,7 @@ function PluginsPanel({
|
|||||||
{t("plugins.noPlugins")}
|
{t("plugins.noPlugins")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{ctx.menu && <CliRowMenu {...ctx.menu} busy={busy} onClose={ctx.close} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file Dashboard.tsx
|
* @file Dashboard.tsx
|
||||||
* @description Main dashboard page showing real-time stats, active agents, recent activity feed, an Analytics tab, and a System Health tab for Claude Code sessions.
|
* @description Main dashboard page showing real-time stats, active agents, recent activity feed, an Analytics tab, and a System Health tab for Claude Code sessions; a recent event's right-click opens its session's `SessionRowMenu`.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -96,6 +96,8 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
|||||||
import { useDataScope } from "../lib/dataScope";
|
import { useDataScope } from "../lib/dataScope";
|
||||||
import { StatCard } from "../components/StatCard";
|
import { StatCard } from "../components/StatCard";
|
||||||
import { AgentCard } from "../components/AgentCard";
|
import { AgentCard } from "../components/AgentCard";
|
||||||
|
import { useContextMenu } from "../components/ContextMenu";
|
||||||
|
import { SessionRowMenu } from "../components/SessionRowMenu";
|
||||||
import { AgentStatusBadge } from "../components/StatusBadge";
|
import { AgentStatusBadge } from "../components/StatusBadge";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { Tip } from "../components/Tip";
|
import { Tip } from "../components/Tip";
|
||||||
@@ -2358,6 +2360,7 @@ function SystemHealthTab() {
|
|||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation("dashboard");
|
const { t } = useTranslation("dashboard");
|
||||||
|
const eventCtx = useContextMenu<string>();
|
||||||
|
|
||||||
// Persistent Tab State
|
// Persistent Tab State
|
||||||
const [tabParams] = useSearchParams();
|
const [tabParams] = useSearchParams();
|
||||||
@@ -2865,11 +2868,20 @@ export function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="card divide-y divide-border">
|
<div className="card divide-y divide-border">
|
||||||
|
{eventCtx.menu && (
|
||||||
|
<SessionRowMenu
|
||||||
|
session={{ id: eventCtx.menu.item }}
|
||||||
|
x={eventCtx.menu.x}
|
||||||
|
y={eventCtx.menu.y}
|
||||||
|
onClose={eventCtx.close}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{recentEvents.slice(0, visibleActivityCount).map((event, i) => (
|
{recentEvents.slice(0, visibleActivityCount).map((event, i) => (
|
||||||
<div
|
<div
|
||||||
key={event.id ?? i}
|
key={event.id ?? i}
|
||||||
className="px-4 py-3 flex items-center gap-3 hover:bg-surface-4 transition-colors cursor-pointer"
|
className="px-4 py-3 flex items-center gap-3 hover:bg-surface-4 transition-colors cursor-pointer"
|
||||||
onClick={() => navigate(`/sessions/${event.session_id}`)}
|
onClick={() => navigate(`/sessions/${event.session_id}`)}
|
||||||
|
onContextMenu={(e) => eventCtx.open(e, event.session_id)}
|
||||||
>
|
>
|
||||||
<AgentStatusBadge
|
<AgentStatusBadge
|
||||||
status={
|
status={
|
||||||
|
|||||||
@@ -5,13 +5,15 @@
|
|||||||
* host, shared by everyone using this install. Lists them, replaces a token,
|
* host, shared by everyone using this install. Lists them, replaces a token,
|
||||||
* adds one, deletes one. A token is write-only here: the server never returns
|
* adds one, deletes one. A token is write-only here: the server never returns
|
||||||
* one, so the form starts blank and the list shows only that one is saved.
|
* one, so the form starts blank and the list shows only that one is saved.
|
||||||
* The git bar's login form saves into the same store.
|
* The git bar's login form saves into the same store. A right-click on a row
|
||||||
|
* opens the same Replace / Remove (plus Copy host) as a menu.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
import { api, type GitCredential } from "../lib/api";
|
import { api, type GitCredential } from "../lib/api";
|
||||||
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||||
|
|
||||||
const INPUT =
|
const INPUT =
|
||||||
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
|
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
|
||||||
@@ -21,6 +23,13 @@ export function GitCredentialsConfig() {
|
|||||||
const [items, setItems] = useState<GitCredential[]>([]);
|
const [items, setItems] = useState<GitCredential[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState<{ url: string; username: string; token: string } | null>(null);
|
const [form, setForm] = useState<{ url: string; username: string; token: string } | null>(null);
|
||||||
|
const ctx = useContextMenu<GitCredential>();
|
||||||
|
// A menu item: close the menu, then act on the row it was opened on.
|
||||||
|
const pick = (fn: (c: GitCredential) => unknown) => () => {
|
||||||
|
const c = ctx.menu?.item;
|
||||||
|
ctx.close();
|
||||||
|
if (c) fn(c);
|
||||||
|
};
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -57,6 +66,9 @@ export function GitCredentialsConfig() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const replace = (c: GitCredential) =>
|
||||||
|
setForm({ url: `${c.protocol}://${c.host}`, username: c.username, token: "" });
|
||||||
|
|
||||||
const remove = async (c: GitCredential) => {
|
const remove = async (c: GitCredential) => {
|
||||||
if (!window.confirm(t("gitCreds.removeConfirm"))) return;
|
if (!window.confirm(t("gitCreds.removeConfirm"))) return;
|
||||||
try {
|
try {
|
||||||
@@ -150,7 +162,11 @@ export function GitCredentialsConfig() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((c) => (
|
{items.map((c) => (
|
||||||
<tr key={`${c.protocol}://${c.host}`} className="border-t border-border">
|
<tr
|
||||||
|
key={`${c.protocol}://${c.host}`}
|
||||||
|
onContextMenu={(e) => ctx.open(e, c)}
|
||||||
|
className="border-t border-border"
|
||||||
|
>
|
||||||
<td className="px-3 py-2 font-mono text-[11px] text-fg-primary">{c.host}</td>
|
<td className="px-3 py-2 font-mono text-[11px] text-fg-primary">{c.host}</td>
|
||||||
<td className="px-3 py-2 text-fg-secondary">{c.username}</td>
|
<td className="px-3 py-2 text-fg-secondary">{c.username}</td>
|
||||||
<td className="px-3 py-2 text-fg-secondary">
|
<td className="px-3 py-2 text-fg-secondary">
|
||||||
@@ -162,13 +178,7 @@ export function GitCredentialsConfig() {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={`${t("gitCreds.replace")} ${c.host}`}
|
aria-label={`${t("gitCreds.replace")} ${c.host}`}
|
||||||
title={t("gitCreds.replace")}
|
title={t("gitCreds.replace")}
|
||||||
onClick={() =>
|
onClick={() => replace(c)}
|
||||||
setForm({
|
|
||||||
url: `${c.protocol}://${c.host}`,
|
|
||||||
username: c.username,
|
|
||||||
token: "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
@@ -196,6 +206,18 @@ export function GitCredentialsConfig() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{ctx.menu && (
|
||||||
|
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="git-cred-menu">
|
||||||
|
<MenuItem onSelect={pick(replace)}>{t("gitCreds.replace")}</MenuItem>
|
||||||
|
<MenuItem onSelect={pick((c) => void navigator.clipboard.writeText(c.host))}>
|
||||||
|
{t("rowMenu.copyHost")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem danger onSelect={pick((c) => void remove(c))}>
|
||||||
|
{t("gitCreds.remove")}
|
||||||
|
</MenuItem>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ export function SessionDetail() {
|
|||||||
const eventApiParamsRef = useRef<Record<string, unknown> | null>(null);
|
const eventApiParamsRef = useRef<Record<string, unknown> | null>(null);
|
||||||
const eventsLoadedCountRef = useRef(0);
|
const eventsLoadedCountRef = useRef(0);
|
||||||
const eventsRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const eventsRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const loadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
eventsLoadedCountRef.current = events.length;
|
eventsLoadedCountRef.current = events.length;
|
||||||
const goBack = useCallback(() => {
|
const goBack = useCallback(() => {
|
||||||
const historyState =
|
const historyState =
|
||||||
@@ -240,7 +241,9 @@ export function SessionDetail() {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
try {
|
try {
|
||||||
const [data, costData] = await Promise.all([
|
const [data, costData] = await Promise.all([
|
||||||
api.sessions.get(id),
|
// The page reads its timeline through the paginated events call; the
|
||||||
|
// whole-session list (MBs on a long session) is not shipped here.
|
||||||
|
api.sessions.get(id, { events: false }),
|
||||||
api.pricing.sessionCost(id).catch(() => null),
|
api.pricing.sessionCost(id).catch(() => null),
|
||||||
]);
|
]);
|
||||||
setSession(data.session);
|
setSession(data.session);
|
||||||
@@ -505,12 +508,21 @@ export function SessionDetail() {
|
|||||||
}, EVENTS_REFRESH_DEBOUNCE_MS);
|
}, EVENTS_REFRESH_DEBOUNCE_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Only this session's messages: every hook of every session broadcasts,
|
||||||
|
// and a reload per message re-read this page's whole session each time.
|
||||||
|
const d = msg.data as { id?: string; session_id?: string } | undefined;
|
||||||
|
const mine = d?.session_id === id || (msg.type === "session_updated" && d?.id === id);
|
||||||
|
if (!mine) return;
|
||||||
if (
|
if (
|
||||||
msg.type === "agent_created" ||
|
msg.type === "agent_created" ||
|
||||||
msg.type === "agent_updated" ||
|
msg.type === "agent_updated" ||
|
||||||
msg.type === "session_updated"
|
msg.type === "session_updated"
|
||||||
) {
|
) {
|
||||||
load();
|
if (loadTimerRef.current) clearTimeout(loadTimerRef.current);
|
||||||
|
loadTimerRef.current = setTimeout(() => {
|
||||||
|
loadTimerRef.current = null;
|
||||||
|
load();
|
||||||
|
}, EVENTS_REFRESH_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
if (msg.type === "new_event") {
|
if (msg.type === "new_event") {
|
||||||
// Debounce bursts into one filter-aware refetch that preserves the
|
// Debounce bursts into one filter-aware refetch that preserves the
|
||||||
@@ -524,12 +536,13 @@ export function SessionDetail() {
|
|||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
if (loadTimerRef.current) clearTimeout(loadTimerRef.current);
|
||||||
if (eventsRefreshTimerRef.current) {
|
if (eventsRefreshTimerRef.current) {
|
||||||
clearTimeout(eventsRefreshTimerRef.current);
|
clearTimeout(eventsRefreshTimerRef.current);
|
||||||
eventsRefreshTimerRef.current = null;
|
eventsRefreshTimerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [load, refreshEventsWithPagination]);
|
}, [id, load, refreshEventsWithPagination]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file Sessions.tsx
|
* @file Sessions.tsx
|
||||||
* @description Displays a list of all recorded sessions with filtering, searching, and pagination features. Sessions are updated in real-time based on events received from the event bus.
|
* @description Displays a list of all recorded sessions with filtering, searching, and pagination features; a row's right-click opens `SessionRowMenu`. Sessions are updated in real-time based on events received from the event bus.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -75,6 +75,8 @@ import { SessionStatusBadge } from "../components/StatusBadge";
|
|||||||
import { SessionToolBadge } from "../components/SessionToolBadge";
|
import { SessionToolBadge } from "../components/SessionToolBadge";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { TableRowSkeleton } from "../components/Skeleton";
|
import { TableRowSkeleton } from "../components/Skeleton";
|
||||||
|
import { useContextMenu } from "../components/ContextMenu";
|
||||||
|
import { SessionRowMenu } from "../components/SessionRowMenu";
|
||||||
import { formatDateTime, formatDuration, truncate, fmtCost } from "../lib/format";
|
import { formatDateTime, formatDuration, truncate, fmtCost } from "../lib/format";
|
||||||
import {
|
import {
|
||||||
effectiveSessionStatus,
|
effectiveSessionStatus,
|
||||||
@@ -87,6 +89,7 @@ const PAGE_SIZE = 10;
|
|||||||
|
|
||||||
export function Sessions() {
|
export function Sessions() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const rowCtx = useContextMenu<Session>();
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -271,6 +274,14 @@ export function Sessions() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in">
|
<div className="animate-fade-in">
|
||||||
|
{rowCtx.menu && (
|
||||||
|
<SessionRowMenu
|
||||||
|
session={rowCtx.menu.item}
|
||||||
|
x={rowCtx.menu.x}
|
||||||
|
y={rowCtx.menu.y}
|
||||||
|
onClose={rowCtx.close}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-8">
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||||
@@ -427,6 +438,7 @@ export function Sessions() {
|
|||||||
<tr
|
<tr
|
||||||
key={session.id}
|
key={session.id}
|
||||||
onClick={() => navigate(`/sessions/${session.id}`)}
|
onClick={() => navigate(`/sessions/${session.id}`)}
|
||||||
|
onContextMenu={(e) => rowCtx.open(e, session)}
|
||||||
className="hover:bg-surface-4 transition-colors cursor-pointer group"
|
className="hover:bg-surface-4 transition-colors cursor-pointer group"
|
||||||
>
|
>
|
||||||
<td className="px-5 py-4">
|
<td className="px-5 py-4">
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ import { keepIfSame } from "../lib/keepIfSame";
|
|||||||
import { useLanes } from "../hooks/useLanes";
|
import { useLanes } from "../hooks/useLanes";
|
||||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||||
import { ConsoleTabs } from "../components/run/ConsoleTabs";
|
import { ConsoleTabs } from "../components/run/ConsoleTabs";
|
||||||
import { type LayoutUpdate, PaneGrid, usePaneLayout } from "../components/run/PaneGrid";
|
import { type LayoutUpdate, PaneGrid, SplitMenus, usePaneLayout } from "../components/run/PaneGrid";
|
||||||
import {
|
import {
|
||||||
type Edge,
|
type Edge,
|
||||||
type PaneLayout,
|
type PaneLayout,
|
||||||
@@ -298,7 +298,7 @@ function ConsoleArea({
|
|||||||
onRunsChanged();
|
onRunsChanged();
|
||||||
if (!r.run_id || laneRef.current !== laneId) return;
|
if (!r.run_id || laneRef.current !== laneId) return;
|
||||||
pending.current.set(slot, activeRuns);
|
pending.current.set(slot, activeRuns);
|
||||||
change((l) => moveInLayout(l, slot, target, edge));
|
change((l) => moveInLayout(l.root ? l : view, slot, target, edge));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSplitError(err instanceof Error ? err.message : String(err));
|
setSplitError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
@@ -327,8 +327,25 @@ function ConsoleArea({
|
|||||||
}}
|
}}
|
||||||
onClosed={(runId) => ended(slotOf(runId))}
|
onClosed={(runId) => ended(slotOf(runId))}
|
||||||
onHome={onNewSession}
|
onHome={onNewSession}
|
||||||
// Per-workspace actions (mcp sync, sync-base check, readme).
|
// Per-workspace actions (mcp sync, sync-base check, readme), and
|
||||||
actions={<LaneToolsMenu lane={lane} />}
|
// while one pane is shown its split menus (a split pane has its own).
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{shown.length === 1 && shown[0] !== undefined && (
|
||||||
|
<SplitMenus
|
||||||
|
slot={shown[0]}
|
||||||
|
shown={shown}
|
||||||
|
runs={tabs}
|
||||||
|
label={label}
|
||||||
|
// A layout never saved is the one-pane `view`: split that.
|
||||||
|
onChange={(fn) => change((l) => fn(l.root ? l : view))}
|
||||||
|
canStart={free !== null}
|
||||||
|
onNewSplit={(target, edge) => void newSplit(target, edge)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<LaneToolsMenu lane={lane} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{splitError && (
|
{splitError && (
|
||||||
|
|||||||
@@ -233,4 +233,25 @@ describe("AiRouterConfig — testing an endpoint before saving it", () => {
|
|||||||
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "2");
|
await user.type(screen.getByLabelText("aiRouter.fieldBaseUrl"), "2");
|
||||||
await waitFor(() => expect(screen.queryByTestId("router-test-result")).toBeNull());
|
await waitFor(() => expect(screen.queryByTestId("router-test-result")).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens a profile's Edit and Remove from a right-click; the built-in row has no menu", async () => {
|
||||||
|
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||||
|
const confirm = vi.spyOn(window, "confirm").mockReturnValueOnce(false);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AiRouterConfig />);
|
||||||
|
const row = (await screen.findByText("9Router")).closest("tr")!;
|
||||||
|
|
||||||
|
await user.pointer({ keys: "[MouseRight]", target: screen.getByText(native.label) });
|
||||||
|
expect(screen.queryByRole("menu")).toBeNull();
|
||||||
|
|
||||||
|
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||||
|
await user.click(screen.getByRole("menuitem", { name: "aiRouter.remove" }));
|
||||||
|
expect(confirm).toHaveBeenCalledWith("aiRouter.removeConfirm:9Router");
|
||||||
|
expect(api.aiRouters.remove).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||||
|
await user.click(screen.getByRole("menuitem", { name: "aiRouter.edit" }));
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
confirm.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* @file Covers the right-click menus on the Claude Code config page: a menu
|
||||||
|
* item runs the SAME action as the card's button — a plugin's uninstall still
|
||||||
|
* takes two clicks before it reaches the API, and a profile's delete calls the
|
||||||
|
* same endpoint the trash button does.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { render, screen, act } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
|
||||||
|
const ccConfigMock = vi.hoisted(() => {
|
||||||
|
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||||
|
return {
|
||||||
|
overview: r({
|
||||||
|
roots: {
|
||||||
|
claudeHome: "/home/.claude",
|
||||||
|
projectClaudeDir: "/repo/.claude",
|
||||||
|
projectRoot: "/repo",
|
||||||
|
},
|
||||||
|
counts: {
|
||||||
|
skills: { user: 0, project: 0 },
|
||||||
|
agents: { user: 0, project: 0 },
|
||||||
|
commands: { user: 0, project: 0 },
|
||||||
|
outputStyles: { user: 0, project: 0 },
|
||||||
|
mcpServers: { user: 0, project: 0 },
|
||||||
|
hooks: {},
|
||||||
|
plugins: 1,
|
||||||
|
marketplaces: 0,
|
||||||
|
keybindings: 0,
|
||||||
|
settingsFiles: 0,
|
||||||
|
memory: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
skills: r({ items: [] }),
|
||||||
|
agents: r({ items: [] }),
|
||||||
|
commands: r({ items: [] }),
|
||||||
|
outputStyles: r({ items: [] }),
|
||||||
|
memory: r({ items: [] }),
|
||||||
|
plugins: r({
|
||||||
|
manifestPath: "/home/.claude/plugins/installed_plugins.json",
|
||||||
|
manifestExists: true,
|
||||||
|
plugins: [{ key: "ponytail@ponytail", name: "ponytail", enabled: true, version: "4.9.0" }],
|
||||||
|
}),
|
||||||
|
marketplaces: r({ items: [] }),
|
||||||
|
mcp: r({ user: [], projectScoped: [] }),
|
||||||
|
hooks: r({ hooks: [] }),
|
||||||
|
settings: r({ sources: [] }),
|
||||||
|
keybindings: r({ groups: [] }),
|
||||||
|
statusline: r({}),
|
||||||
|
hookScripts: r({ items: [] }),
|
||||||
|
backups: r({ items: [] }),
|
||||||
|
file: r({ ok: true, file: "", size: 0, mtime: 0, truncated: false, text: "" }),
|
||||||
|
pluginsAvailable: r({ installed: [], available: [] }),
|
||||||
|
updateAllStatus: r({ batch: null }),
|
||||||
|
pluginAction: r({ ok: true }),
|
||||||
|
profiles: r({
|
||||||
|
items: [
|
||||||
|
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
|
||||||
|
{ name: "spare", label: "Spare", active: false, accountUuid: "u2", email: "d@e.f" },
|
||||||
|
],
|
||||||
|
active: { accountUuid: "u1", email: "a@b.c" },
|
||||||
|
}),
|
||||||
|
profileUsage: r({ name: "x", refreshed: false, fiveHour: null, sevenDay: null }),
|
||||||
|
deleteProfile: r({ ok: true }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../lib/eventBus", () => ({
|
||||||
|
eventBus: {
|
||||||
|
subscribe: () => () => {},
|
||||||
|
publish: () => {},
|
||||||
|
onConnection: () => () => {},
|
||||||
|
connected: true,
|
||||||
|
setConnected: () => {},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CcConfig } from "../CcConfig";
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((res) => setTimeout(res, 0));
|
||||||
|
await new Promise((res) => setTimeout(res, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
describe("CcConfig — right-click menus", () => {
|
||||||
|
it("uninstalls a plugin from its menu only on the second click, like the button", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CcConfig />);
|
||||||
|
await settle();
|
||||||
|
await user.click(screen.getByRole("tab", { name: /plugins/i }));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
await user.pointer({
|
||||||
|
keys: "[MouseRight]",
|
||||||
|
target: screen.getByTestId("plugin-card-ponytail@ponytail"),
|
||||||
|
});
|
||||||
|
const item = screen.getByTestId("plugin-menu-uninstall");
|
||||||
|
await user.click(item);
|
||||||
|
expect(ccConfigMock.pluginAction).not.toHaveBeenCalled();
|
||||||
|
expect(item).toHaveTextContent(/click again/i);
|
||||||
|
|
||||||
|
await user.click(item);
|
||||||
|
await settle();
|
||||||
|
expect(ccConfigMock.pluginAction).toHaveBeenCalledWith("ponytail@ponytail", "uninstall");
|
||||||
|
expect(screen.queryByTestId("plugin-menu")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a saved profile from its menu through the same API as the trash button", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CcConfig />);
|
||||||
|
await settle();
|
||||||
|
await user.click(screen.getByRole("button", { name: /profile/i }));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-spare") });
|
||||||
|
await user.click(screen.getByTestId("profile-menu-delete"));
|
||||||
|
await settle();
|
||||||
|
expect(ccConfigMock.deleteProfile).toHaveBeenCalledWith("spare");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no delete for the only saved copy of the live account", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<CcConfig />);
|
||||||
|
await settle();
|
||||||
|
await user.click(screen.getByRole("button", { name: /profile/i }));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("profile-row-work") });
|
||||||
|
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file Tests for the Git credentials settings tab (issue #45): lists saved
|
* @file Tests for the Git credentials settings tab (issue #45): lists saved
|
||||||
* logins without ever rendering a token, replaces the token for a host, adds
|
* logins without ever rendering a token, replaces the token for a host, adds
|
||||||
* one from a URL, and deletes only after a confirm.
|
* one from a URL, and deletes only after a confirm — from the button or the
|
||||||
|
* row's right-click menu.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -98,4 +99,24 @@ describe("GitCredentialsConfig", () => {
|
|||||||
);
|
);
|
||||||
confirm.mockRestore();
|
confirm.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers Replace and Remove on a right-click, Remove behind the same confirm", async () => {
|
||||||
|
const confirm = vi.spyOn(window, "confirm").mockReturnValueOnce(true);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<GitCredentialsConfig />);
|
||||||
|
const row = (await screen.findByText("git.smartgift.io.vn")).closest("tr")!;
|
||||||
|
|
||||||
|
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||||
|
await user.click(screen.getByRole("menuitem", { name: "gitCreds.replace" }));
|
||||||
|
expect(screen.queryByRole("menu")).toBeNull();
|
||||||
|
expect(screen.getByLabelText("gitCreds.fieldHost")).toHaveValue("https://git.smartgift.io.vn");
|
||||||
|
|
||||||
|
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||||
|
await user.click(screen.getByRole("menuitem", { name: "gitCreds.remove" }));
|
||||||
|
expect(confirm).toHaveBeenCalledWith("gitCreds.removeConfirm");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.gitCredentials.remove).toHaveBeenCalledWith("https", "git.smartgift.io.vn")
|
||||||
|
);
|
||||||
|
confirm.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -763,6 +763,39 @@ describe("split panes", () => {
|
|||||||
expect(screen.getByTestId("pane-0").style.left).toBe("50%");
|
expect(screen.getByTestId("pane-0").style.left).toBe("50%");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("right-click on a tab opens it in a split or closes it; on a pane header, swaps, splits or closes", async () => {
|
||||||
|
withRuns([live(0), live(1, "shell"), live(2)]);
|
||||||
|
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const right = (target: Element) => user.pointer({ keys: "[MouseRight]", target });
|
||||||
|
|
||||||
|
await right(screen.getByTestId("console-tab-1"));
|
||||||
|
await user.click(within(screen.getByTestId("console-tab-menu-1")).getByText("Open in split"));
|
||||||
|
await settle();
|
||||||
|
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||||
|
|
||||||
|
await right(screen.getByTestId("pane-header-0"));
|
||||||
|
await user.click(within(screen.getByTestId("pane-menu-0")).getByText(/Show Terminal here/));
|
||||||
|
await settle();
|
||||||
|
expect(screen.getByTestId("pane-1").style.left).toBe("0%");
|
||||||
|
|
||||||
|
await right(screen.getByTestId("pane-header-1"));
|
||||||
|
await user.click(screen.getByTestId("pane-menu-split-bottom"));
|
||||||
|
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 3, shell: true });
|
||||||
|
|
||||||
|
await right(screen.getByTestId("pane-header-1"));
|
||||||
|
await user.click(within(screen.getByTestId("pane-menu-1")).getByText("Close pane"));
|
||||||
|
await settle();
|
||||||
|
expect(shownPanes()).toEqual(["pane-0"]);
|
||||||
|
expect(api.run.kill).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await right(screen.getByTestId("console-tab-2"));
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByTestId("console-tab-menu-2")).getByText("Close tab (stops its session)")
|
||||||
|
);
|
||||||
|
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
|
||||||
|
});
|
||||||
|
|
||||||
it("a split menu starts a new terminal in the first free slot", async () => {
|
it("a split menu starts a new terminal in the first free slot", async () => {
|
||||||
withRuns([live(0), live(1)]);
|
withRuns([live(0), live(1)]);
|
||||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||||
@@ -773,6 +806,23 @@ describe("split panes", () => {
|
|||||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 2, shell: true });
|
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: 2, shell: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("with one pane, the split menus sit beside Tools and split it", async () => {
|
||||||
|
withRuns([live(0), live(1)]);
|
||||||
|
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||||
|
expect(shownPanes()).toEqual(["pane-0"]);
|
||||||
|
expect(screen.queryByTestId("pane-header-0")).toBeNull();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.click(screen.getByTestId("pane-split-right-0"));
|
||||||
|
await user.click(screen.getAllByRole("menuitem")[0] as HTMLElement);
|
||||||
|
await settle();
|
||||||
|
expect(shownPanes()).toEqual(["pane-0", "pane-1"]);
|
||||||
|
// Split: the tab bar's copy goes, each pane header has its own.
|
||||||
|
expect(screen.getAllByTestId("pane-split-right-0")).toHaveLength(1);
|
||||||
|
expect(screen.getByTestId("pane-header-0")).toContainElement(
|
||||||
|
screen.getByTestId("pane-split-right-0")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("a pane whose run exits closes, and the URL follows the pane that takes its place", async () => {
|
it("a pane whose run exits closes, and the URL follows the pane that takes its place", async () => {
|
||||||
withRuns([live(0), live(1)]);
|
withRuns([live(0), live(1)]);
|
||||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||||
|
|||||||
+3
-3
@@ -1324,7 +1324,7 @@ Returns `{ session }`.
|
|||||||
GET /api/sessions/:id
|
GET /api/sessions/:id
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns single session details.
|
Returns single session details: `{session, agents, events, workflows}`. `events` is every event of the session with its full payload — on a long session hundreds of MB — so `?events=0` answers it as `[]`; the web page uses that and reads its timeline through the paginated events call (the CLI's `agenthub session <id>` still takes them all).
|
||||||
|
|
||||||
**Path Parameters:**
|
**Path Parameters:**
|
||||||
|
|
||||||
@@ -1867,10 +1867,10 @@ curl -X DELETE http://localhost:4820/api/pricing/gpt-5.1-codex
|
|||||||
#### Total Cost
|
#### Total Cost
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>
|
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>
|
||||||
```
|
```
|
||||||
|
|
||||||
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`; the status bar's **Today** is the entry for today). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total.
|
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`; the status bar's **Today** is the entry for today). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl "http://localhost:4820/api/pricing/cost?tz_offset=-420"
|
curl "http://localhost:4820/api/pricing/cost?tz_offset=-420"
|
||||||
|
|||||||
+11
-7
@@ -784,16 +784,20 @@ graph TB
|
|||||||
|
|
||||||
### SQLite Pragmas
|
### SQLite Pragmas
|
||||||
|
|
||||||
|
What `server/db.js` actually sets on every open:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// db.js - Performance tuning
|
db.pragma("journal_mode = WAL"); // readers never block the writer
|
||||||
db.pragma('journal_mode = WAL'); // Write-Ahead Logging
|
db.pragma("foreign_keys = ON");
|
||||||
db.pragma('synchronous = NORMAL'); // Faster writes (safe with WAL)
|
db.pragma("busy_timeout = 5000"); // two dashboards may share the file
|
||||||
db.pragma('cache_size = -64000'); // 64MB cache
|
db.pragma("synchronous = NORMAL"); // safe with WAL: a power cut loses only the last commits, never the file
|
||||||
db.pragma('temp_store = MEMORY'); // Temp tables in memory
|
db.pragma("journal_size_limit = 67108864"); // a checkpoint truncates the WAL back to 64 MB
|
||||||
db.pragma('mmap_size = 30000000000'); // Memory-mapped I/O (30GB)
|
|
||||||
db.pragma('page_size = 4096'); // Optimal page size
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Upkeep.** `maintainDatabase()` (exported by `server/db.js`) runs hourly from `server/index.js` (`DASHBOARD_DB_MAINTAIN_MS`, `0` = off): a `wal_checkpoint(PASSIVE)` — it never waits on a reader, such as the other dashboard — after which the next write shrinks the WAL to `journal_size_limit`, and `PRAGMA optimize`. Nothing runs `VACUUM`: it rewrites the whole file under an exclusive lock, so space freed by a cleanup is reused for new rows instead of returned to the disk.
|
||||||
|
|
||||||
|
**Growth.** `events.data` keeps each hook's full payload (a `PostToolUse` carries the tool's whole output), and no data is pruned automatically — `events` is ~95% of a long-lived database (on the order of 300–500 MB a month of heavy use). The only deletions are the user's: Settings → clear data / cleanup, a workspace's purge, `scripts/clear-data.js`. Clear data runs in ONE transaction (foreign keys off around it, so workflows are deleted explicitly), cleanup deletes in 500-id chunks inside one transaction. `GET /api/settings/export` streams `events` in keyset pages of 500 instead of loading the table.
|
||||||
|
|
||||||
### Prepared Statements
|
### Prepared Statements
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
|
|||||||
@@ -393,6 +393,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
|||||||
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar.
|
- **The sidebar's workspace list picks the workspace** — on every page, below the nav (`client/src/components/WorkspaceTree.tsx`), shaped like Claude Desktop's project list: every workspace on ONE level (no repo grouping, no worktree rows), in your stored order. A row (`WorkspaceRow.tsx`) is a chevron, a status dot (green = active, pulsing while running; amber row = needs you; red = dead or error; grey = idle), the title or folder name and the tool's icon, with `+` (open it for a new session) and `⋯` on hover. Clicking the name opens `/workspace?lane=<id>` and expands the row: the URL is the selection, so a notification link, F5 and the back button all land on the same workspace, and a bare `/workspace` reopens the last one shown (`agenthub.workspace.selected`) or the first. The header has a **tool filter** (shown once workspaces of two tools exist; kept in `agenthub.sidebar.projects` → `tool`, a stored tool no workspace has filters nothing), search and `+` (Add workspace). Search matches workspace names and paths — such a workspace keeps all its sessions — and session names: a workspace whose name does not match still shows when one of its sessions does, with only the matching sessions under it (every list stays mounted, hidden, to load and count). In the collapsed rail the list is a single icon that opens the sidebar.
|
||||||
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a tab in slots 1–3 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
|
- **A workspace's sessions** — the chevron expands the row to the sessions the workspace OWNS and has not archived (`WorkspaceSessionList.tsx`: `GET /api/sessions?lane=<id>&archived=0`, the newest 20, re-read when the workspace's status or session moves; which rows are expanded is kept in `agenthub.sidebar.projects` → `expanded`). A live AI tab whose session has no row yet — a tab opened bare from `+`, before its first prompt — is listed at the top as **New session** straight from the run (its session id is known from the start), so a new tab shows in its workspace at once; the row becomes the real session when its first hook lands. The sidebar's live panes follow the Workspace page's own run-list poll (`LIVE_RUNS_EVENT`), since a tab in slots 1–3 moves no workspace column. A workspace owns a session its pane held — `bindRunSession` stamps `sessions.lane_id` from the pane's run, first writer wins, a nested `claude -p` excluded — and lists nothing else: a `claude` typed into a terminal in the same folder, or history from before the workspace existed, belongs to no workspace and is found on the Sessions page. Sessions older than this ownership column are not guessed: only those `dashboard_runs` still named were backfilled. A session started by a tool with no hooks (Codex without its hooks, any catalog tool) is never stamped. A row's dot is the session's state (`sessionDot`: amber waiting for you, red failed, green a live pane, hollow at rest); double-click renames it (`PATCH {name}`), and its hover button **archives** it (`PATCH {archived: true}` — hidden from the workspace, nothing deleted; the home lists the archived ones folded with **Unarchive**). A filled dot marks a session that still has a live pane (a `GET /api/run` handle carrying its session id — every pane the dashboard starts carries `AGENTHUB_RUN_ID=<its tmux name>`, the hook handler forwards it as `run_id`, and `bindRunSession` in `server/routes/hooks.js` records the hook's session on that run: the first session always, afterwards — for a Claude pane — only a `SessionStart` that is not a fresh `startup` (`/clear`, `/resume`), so a `claude -p` the agent runs inside the pane never takes it over; the rule is Claude-only, and a Codex or Antigravity pane follows every new session): clicking it opens `/workspace?lane=<id>&run=<runId>`, which ATTACHES to that tmux session (`attachRequest` on `LaneConsolePane`) — it starts nothing and resumes nothing. The session opens on its own tab (the tab of its tmux slot): in the pane already showing it (focused), else in the focused pane; **Ctrl/Cmd+click** opens it in a split beside the focused pane (see **Split panes** below). Any other session opens in its workspace the way Claude Desktop opens an old one: `/workspace?lane=<id>&session=<id>` shows its conversation, read-only (`SessionReplay.tsx`, the console hidden — never unmounted — behind it), with a prompt box under it. Opening it resumes nothing; **sending** a message resumes it (`POST /api/lanes/:id/start {slot, resumeSessionId, prompt}` in the first free pane slot, on the tool the session ran on) and opens that pane — a resume re-reads the whole transcript into context, so it waits for that explicit send. The session's analysis page (`/sessions/<id>`: agents, events, cost, transcript) is the **Analyst** button in the session view's right panel, beside Files and Changes (disabled while the pane's run has reported no session yet).
|
||||||
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
- **Row menu** — right-click a row, or its `⋯` button: Start/Stop (slot 0), Rename, the Model (AI Router) select, Create/update readme file (Claude only), New workspace here…, Copy path, Reset worktree… (managed only) and Delete workspace…, each the call the rest of the UI already makes. Delete is record-only exactly as before (see "Removing a workspace").
|
||||||
|
- **Right-click menus** — every item that has actions opens them on a right-click too, in one shell (`client/src/components/ContextMenu.tsx`: placed at the pointer, kept inside the viewport, closed by Escape, a click outside or an item). The buttons stay; the menu repeats what they do and nothing more. A session row — in the sidebar, the home's Recents and the archived fold (`lanes/SessionMenu.tsx`) — offers Open, Open in split (live), Rename and Archive (sidebar), Unarchive (archived), Session details, Copy session ID and Close tab (live); the sidebar's **New session** row Open, Open in split and Close tab. A console tab offers Open, Open in split, Session details, Copy session ID and Close tab; a pane header (split view) Show <session> here, Split right/down with a new terminal and Close pane. In the Changes panel a changed file (overview row or review header) offers Open, Stage/Unstage, Discard — two clicks, as on its trash button — or the conflict's Keep mine / Take theirs / Mark resolved, and Copy relative path; a Files tree entry Open and Copy relative path; a History commit View commit, Copy commit SHA and Copy commit message. The home's title opens the row menu, and a Context file or extra folder offers Copy path and Remove. Outside the workspace the same shell serves the Sessions table, the Dashboard's agent cards and recent events, the Activity feed and Workflow runs (`components/SessionRowMenu.tsx`: Open session details, Open in its workspace when it has one, Copy session ID, Copy folder path), an attached prompt image (View / Remove), the managed workspace card (Reset), every Settings list (Git credentials, AI Router, Pricing, alert rules, webhooks, remote sources, AI tools) and every Claude Code / Codex config list (skills, agents, commands, output styles and memory files via `FileItemMenu` in `components/config/MdItemList.tsx`; plugins, MCP servers, marketplaces, auth profiles). A delete or remove there takes the button's own confirm — a modal, or two clicks. Text areas (the terminal, a conversation, a diff) keep the browser's own menu. The shell stops the menu's clicks from reaching the card it was opened from.
|
||||||
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
- **The workspace home** — `/workspace?lane=<id>` opens on the workspace's home (`client/src/components/lanes/WorkspaceHome.tsx`), shaped like a Claude Desktop project page: the name (rename from its `⋯` menu, which is the sidebar row menu) with the repository it belongs to under it (root, the main worktree's branch, the worktree count, from `GET /api/projects` — the facts Settings → Projects used to show), a prompt box — the one place a session starts WITH a prompt (a pane has no run form; the session view's tab bar `+` only starts a bare CLI or a shell) — whose fields run in order **AI** (the session's own tool: every installed, enabled one, the workspace's by default), **AI Router** (Native or a saved profile; Native alone, disabled, for a tool that cannot use one), **Model** (the tool's own list, or on a router that router's models), **Permission** (Claude's permission mode; Codex's sandbox and approval; a catalog tool's per-session bypass switch) and **Effort** (Claude and Codex), and whose **Start** opens a NEW session in the first pane slot with no live run (0–3, `POST /api/lanes/:id/start {slot}`; all four busy is refused) and then shows it, and **Recents** — the workspace's own sessions (`?lane=<id>`) with their cost and time, a live one opening its pane and any other its conversation (`&session=`), exactly like the sidebar. The transcript page (`/sessions/<id>`) of a Claude session started in a workspace's own folder carries an explicit **Resume** (`client/src/components/lanes/SessionResumeButton.tsx`, the one place that resumes) and beside it **Fork**, which resumes the history into a NEW session id (`{forkSession: true}` → `--fork-session`, the original untouched, offered even while the original has a live pane): it warns that a resume re-reads the whole transcript into context, re-reads the live panes, then starts it in the workspace's first free pane (`POST /api/lanes/:id/start {slot, resumeSessionId}`) and opens it. A session from a SUBFOLDER gets no Resume — the pane starts in the workspace's folder and Claude finds a session by the folder it ran in — and a session that still has a live pane shows **Open pane** instead. On the right sit the workspace's own context: **Instructions** (appended to every new session's system prompt), **Memory** (read-only: the folder's CLAUDE.md / AGENTS.md family and Claude's auto memory), **Context** (uploaded files, ≤ 10 MB each, stored by AgentHub) and **Folder** (the workspace's own folder plus extra ones, with the repo's untracked worktrees suggested). Only Claude and Codex panes can take them (`--add-dir`, `--append-system-prompt-file=` / `-c developer_instructions=`); for any other tool the page says so and its panes start without them. See `docs/API.md` → Workspace context. The home HIDES the session view with CSS — the console never unmounts behind it.
|
||||||
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
|
- **A session in its own worktree** — Claude Desktop's worktree toggle: the row under the home's prompt box has a **Local / New worktree** select (Claude workspaces in a git repo). Claude Code itself makes a worktree only when started with `--worktree` or when asked mid-session — and a worktree it enters mid-session is invisible to AgentHub, which reads a session's folder once, at its start, and the new session's pane runs `claude --worktree=<name>`, so CLAUDE CODE makes `<repo>/.claude/worktrees/<name>` on branch `worktree-<name>`, copies `.worktreeinclude`, runs your `WorktreeCreate` hook, and on exit removes a worktree with no changes (or asks). AgentHub adds `/.claude/worktrees/` to the repo's `info/exclude` (Claude does not, and the main repo would list it). The session stays this workspace's own — listed with a branch icon and the worktree's name — and the session view's Changes panel works in ITS worktree (every git route with `?session=`, resolved from the session row: owned by the workspace, registered by `git worktree list`; `ENOWORKTREE` otherwise, never the main folder). Resume and Fork start in that worktree. **Archive** of a worktree session with no live pane asks first and removes the worktree like Claude Desktop — `git worktree remove` without force (git refuses unsaved work, and then nothing is archived) and `git branch -d` (unmerged work keeps its branch); a lock held by a live process refuses, a stale one is cleared. The workspace's own folder is never removed.
|
||||||
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Files / Changes panel, with that run's tab active. The sidebar's workspace row opens a workspace's tabs when it has live ones — on the pane it last focused (`agenthub.workspace.paneLayout`) — and its home when it has none; the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
|
- **The session view** — `/workspace?lane=<id>&run=<runId>` (a live session picked in the sidebar or Recents, or one just started) shows the console's tabs and the Files / Changes panel, with that run's tab active. The sidebar's workspace row opens a workspace's tabs when it has live ones — on the pane it last focused (`agenthub.workspace.paneLayout`) — and its home when it has none; the **Home** button at the left end of the tab bar opens the home of a workspace whose tabs stay running. Every workspace keeps its own tabs, panes and sessions: another workspace's tab is never shown. A browser notification for a workspace with a live pane opens it here directly.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
const { rotateIfLarge } = require("./log-rotate");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { execFileSync, spawnSync } = require("child_process");
|
const { execFileSync, spawnSync } = require("child_process");
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ const state = { status: "running", started_at: new Date().toISOString(), steps:
|
|||||||
|
|
||||||
function log(line) {
|
function log(line) {
|
||||||
try {
|
try {
|
||||||
|
rotateIfLarge(LOG_FILE);
|
||||||
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`);
|
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`);
|
||||||
} catch {
|
} catch {
|
||||||
/* best effort */
|
/* best effort */
|
||||||
|
|||||||
@@ -1029,16 +1029,27 @@ function importSubagentFromJsonl(dbModule, sessionId, mainAgentId, subData) {
|
|||||||
// Per-tool-call events under the subagent's own agent_id so the UI can attribute
|
// Per-tool-call events under the subagent's own agent_id so the UI can attribute
|
||||||
// them to the subagent. Idempotent by (agent_id, event_type, tool_use_id).
|
// them to the subagent. Idempotent by (agent_id, event_type, tool_use_id).
|
||||||
if (Array.isArray(subData.toolEvents) && subData.toolEvents.length > 0) {
|
if (Array.isArray(subData.toolEvents) && subData.toolEvents.length > 0) {
|
||||||
const eventExists = db.prepare(
|
// Every tool_use_id this agent already has, read ONCE: a `data LIKE` per
|
||||||
"SELECT 1 FROM events WHERE agent_id = ? AND event_type = ? AND data LIKE ? LIMIT 1"
|
// tool event re-scanned the agent's payloads for each one, and this runs
|
||||||
|
// on every SubagentStop — quadratic in a long subagent's tool calls.
|
||||||
|
const seen = new Set(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`SELECT event_type, CASE WHEN json_valid(data) THEN json_extract(data, '$.tool_use_id') END AS id
|
||||||
|
FROM events WHERE agent_id = ? AND event_type IN ('PreToolUse', 'PostToolUse')`
|
||||||
|
)
|
||||||
|
.all(targetAgentId)
|
||||||
|
.filter((r) => r.id)
|
||||||
|
.map((r) => `${r.event_type}\u0001${r.id}`)
|
||||||
);
|
);
|
||||||
|
const has = (type, id) => seen.has(`${type}\u0001${id}`);
|
||||||
for (const tev of subData.toolEvents) {
|
for (const tev of subData.toolEvents) {
|
||||||
if (!tev.tool_use_id) continue;
|
if (!tev.tool_use_id) continue;
|
||||||
const useIdMarker = `%"tool_use_id":${JSON.stringify(tev.tool_use_id)}%`;
|
|
||||||
const ts = tev.pre_timestamp || subData.startedAt;
|
const ts = tev.pre_timestamp || subData.startedAt;
|
||||||
const truncatedInput = truncateForEvent(tev.tool_input);
|
const truncatedInput = truncateForEvent(tev.tool_input);
|
||||||
|
|
||||||
if (!eventExists.get(targetAgentId, "PreToolUse", useIdMarker)) {
|
if (!has("PreToolUse", tev.tool_use_id)) {
|
||||||
|
seen.add(`PreToolUse\u0001${tev.tool_use_id}`);
|
||||||
insertEvent.run(
|
insertEvent.run(
|
||||||
sessionId,
|
sessionId,
|
||||||
targetAgentId,
|
targetAgentId,
|
||||||
@@ -1057,7 +1068,8 @@ function importSubagentFromJsonl(dbModule, sessionId, mainAgentId, subData) {
|
|||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tev.post_timestamp && !eventExists.get(targetAgentId, "PostToolUse", useIdMarker)) {
|
if (tev.post_timestamp && !has("PostToolUse", tev.tool_use_id)) {
|
||||||
|
seen.add(`PostToolUse\u0001${tev.tool_use_id}`);
|
||||||
insertEvent.run(
|
insertEvent.run(
|
||||||
sessionId,
|
sessionId,
|
||||||
targetAgentId,
|
targetAgentId,
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* @file One-step rotation for AgentHub's append-only runtime logs (server.log,
|
||||||
|
* bootstrap.log, client-build.log, plugin-update.log, cc-update-all.log): a
|
||||||
|
* file past `max` bytes becomes `<file>.1`, replacing the previous one, so
|
||||||
|
* each log costs at most twice `max` however long the install lives. Pure fs,
|
||||||
|
* never throws — a log must never stop what it records.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
|
const DEFAULT_MAX = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
function rotateIfLarge(file, max = DEFAULT_MAX) {
|
||||||
|
try {
|
||||||
|
if (fs.statSync(file).size > max) fs.renameSync(file, `${file}.1`);
|
||||||
|
} catch {
|
||||||
|
/* missing file, or not ours to move: keep appending */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { rotateIfLarge, DEFAULT_MAX };
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
// Self-check: node scripts/log-rotate.js
|
||||||
|
const os = require("os");
|
||||||
|
const path = require("path");
|
||||||
|
const assert = require("assert");
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "log-rotate-"));
|
||||||
|
const f = path.join(dir, "x.log");
|
||||||
|
fs.writeFileSync(f, "a".repeat(20));
|
||||||
|
rotateIfLarge(f, 10);
|
||||||
|
assert.ok(!fs.existsSync(f) && fs.statSync(`${f}.1`).size === 20);
|
||||||
|
fs.writeFileSync(f, "b".repeat(5));
|
||||||
|
rotateIfLarge(f, 10);
|
||||||
|
assert.equal(fs.readFileSync(f, "utf8"), "bbbbb");
|
||||||
|
rotateIfLarge(path.join(dir, "missing.log"), 10);
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
console.log("log-rotate ok");
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
const { rotateIfLarge } = require("./log-rotate");
|
||||||
const os = require("os");
|
const os = require("os");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
@@ -469,6 +470,7 @@ function waitForPortsFree(timeoutMs = 10000) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startDashboard(rt = runtimeDir(), root = PLUGIN_ROOT) {
|
function startDashboard(rt = runtimeDir(), root = PLUGIN_ROOT) {
|
||||||
|
rotateIfLarge(path.join(rt, "server.log"));
|
||||||
const logFile = fs.openSync(path.join(rt, "server.log"), "a");
|
const logFile = fs.openSync(path.join(rt, "server.log"), "a");
|
||||||
const child = spawn(process.execPath, [path.join(root, "server", "index.js")], {
|
const child = spawn(process.execPath, [path.join(root, "server", "index.js")], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
@@ -490,6 +492,7 @@ function startDashboard(rt = runtimeDir(), root = PLUGIN_ROOT) {
|
|||||||
function log(rt, line) {
|
function log(rt, line) {
|
||||||
const stamped = `[${new Date().toISOString()}] ${line}\n`;
|
const stamped = `[${new Date().toISOString()}] ${line}\n`;
|
||||||
try {
|
try {
|
||||||
|
rotateIfLarge(path.join(rt, "bootstrap.log"));
|
||||||
fs.appendFileSync(path.join(rt, "bootstrap.log"), stamped);
|
fs.appendFileSync(path.join(rt, "bootstrap.log"), stamped);
|
||||||
} catch {
|
} catch {
|
||||||
/* best effort */
|
/* best effort */
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
const { rotateIfLarge } = require("./log-rotate");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { spawnSync } = require("child_process");
|
const { spawnSync } = require("child_process");
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ function buildClient({
|
|||||||
const dist = path.join(rt, "client-dist");
|
const dist = path.join(rt, "client-dist");
|
||||||
if (!force && fs.existsSync(path.join(dist, "index.html"))) return "ready";
|
if (!force && fs.existsSync(path.join(dist, "index.html"))) return "ready";
|
||||||
|
|
||||||
|
if (logPath) rotateIfLarge(logPath);
|
||||||
const logFd = logPath ? fs.openSync(logPath, "a") : null;
|
const logFd = logPath ? fs.openSync(logPath, "a") : null;
|
||||||
const stdio = logFd === null ? "inherit" : ["ignore", logFd, logFd];
|
const stdio = logFd === null ? "inherit" : ["ignore", logFd, logFd];
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
const { rotateIfLarge } = require("./log-rotate");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { execFileSync, spawnSync } = require("child_process");
|
const { execFileSync, spawnSync } = require("child_process");
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ const SAFE_NAME = /^[A-Za-z0-9][\w.-]*$/;
|
|||||||
|
|
||||||
function log(line) {
|
function log(line) {
|
||||||
try {
|
try {
|
||||||
|
rotateIfLarge(LOG_FILE);
|
||||||
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`);
|
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${line}\n`);
|
||||||
} catch {
|
} catch {
|
||||||
/* best effort */
|
/* best effort */
|
||||||
|
|||||||
@@ -1813,13 +1813,57 @@ describe("Database Integrity", () => {
|
|||||||
|
|
||||||
assert.ok(indexes.includes("idx_agents_session"));
|
assert.ok(indexes.includes("idx_agents_session"));
|
||||||
assert.ok(indexes.includes("idx_agents_status"));
|
assert.ok(indexes.includes("idx_agents_status"));
|
||||||
assert.ok(indexes.includes("idx_events_session"));
|
// (session_id, created_at) replaces the bare session_id index, a prefix of
|
||||||
|
// idx_events_session_type that only cost disk.
|
||||||
|
assert.ok(indexes.includes("idx_events_session_created"));
|
||||||
|
assert.ok(!indexes.includes("idx_events_session"));
|
||||||
assert.ok(indexes.includes("idx_events_type"));
|
assert.ok(indexes.includes("idx_events_type"));
|
||||||
assert.ok(indexes.includes("idx_events_created"));
|
assert.ok(indexes.includes("idx_events_created"));
|
||||||
assert.ok(indexes.includes("idx_sessions_status"));
|
assert.ok(indexes.includes("idx_sessions_status"));
|
||||||
assert.ok(indexes.includes("idx_sessions_started"));
|
assert.ok(indexes.includes("idx_sessions_started"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the WAL bounded and fsyncs at checkpoints, not every commit", () => {
|
||||||
|
assert.equal(db.pragma("synchronous", { simple: true }), 1); // NORMAL
|
||||||
|
assert.equal(db.pragma("journal_size_limit", { simple: true }), 64 * 1024 * 1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exports every event in time order, streamed in pages", async () => {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT OR IGNORE INTO sessions (id, name, status, started_at, updated_at) VALUES ('exp-s', 'e', 'completed', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')"
|
||||||
|
).run();
|
||||||
|
const ins = db.prepare(
|
||||||
|
"INSERT INTO events (session_id, event_type, summary, data, created_at) VALUES ('exp-s', 'PostToolUse', ?, '{}', ?)"
|
||||||
|
);
|
||||||
|
db.transaction(() => {
|
||||||
|
// 1203 rows, with ties on created_at, across the 500-row page edges.
|
||||||
|
for (let i = 0; i < 1203; i++) {
|
||||||
|
const ts = new Date(Date.UTC(2026, 0, 1, 0, 0, Math.floor(i / 3))).toISOString();
|
||||||
|
ins.run(`exp-${i}`, ts);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const all = db.prepare("SELECT COUNT(*) AS c FROM events").get().c;
|
||||||
|
const res = await fetch("/api/settings/export");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.match(res.headers["content-disposition"], /attachment/);
|
||||||
|
const { events, sessions, agents, token_usage, model_pricing, exported_at } = res.body;
|
||||||
|
assert.ok(exported_at && sessions && agents && token_usage && model_pricing);
|
||||||
|
assert.equal(events.length, all);
|
||||||
|
assert.equal(new Set(events.map((e) => e.id)).size, all);
|
||||||
|
for (let i = 1; i < events.length; i++) {
|
||||||
|
assert.ok(events[i - 1].created_at >= events[i].created_at);
|
||||||
|
}
|
||||||
|
assert.equal(events.filter((e) => e.session_id === "exp-s").length, 1203);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upkeep checkpoints without waiting and optimizes", () => {
|
||||||
|
const { maintainDatabase } = require("../db");
|
||||||
|
const out = maintainDatabase();
|
||||||
|
assert.equal(out.checkpointError, undefined);
|
||||||
|
assert.equal(out.optimizeError, undefined);
|
||||||
|
assert.equal(out.checkpoint[0].busy, 0);
|
||||||
|
});
|
||||||
|
|
||||||
it("should use WAL journal mode", () => {
|
it("should use WAL journal mode", () => {
|
||||||
const mode = db.pragma("journal_mode", { simple: true });
|
const mode = db.pragma("journal_mode", { simple: true });
|
||||||
assert.equal(mode, "wal");
|
assert.equal(mode, "wal");
|
||||||
|
|||||||
@@ -154,6 +154,31 @@ describe("GET /api/pricing/cost daily split", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GET /api/pricing/cost?lane=", () => {
|
||||||
|
it("prices only the sessions a workspace owns, including one its run holds unstamped", async () => {
|
||||||
|
const lane = require("../lib/lanes").createLane({
|
||||||
|
title: "w",
|
||||||
|
cwd: fs.mkdtempSync(path.join(ROOT, "lane-")),
|
||||||
|
tool: "claude",
|
||||||
|
});
|
||||||
|
session("owned", "2026-08-01T00:00:00.000Z");
|
||||||
|
session("held", "2026-08-01T00:00:00.000Z");
|
||||||
|
session("other", "2026-08-01T00:00:00.000Z");
|
||||||
|
db.prepare("UPDATE sessions SET lane_id = ? WHERE id = 'owned'").run(lane.id);
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO dashboard_runs (id, lane_id, session_id, cwd, status) VALUES ('run-held', ?, 'held', '/x', 'exited')"
|
||||||
|
).run(lane.id);
|
||||||
|
total("owned", 2 * M);
|
||||||
|
total("held", 1 * M);
|
||||||
|
total("other", 5 * M);
|
||||||
|
|
||||||
|
const r = await call("GET", `/api/pricing/cost?tz_offset=0&lane=${lane.id}`);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.total_cost, 3);
|
||||||
|
assert.equal((await call("GET", "/api/pricing/cost?lane=x")).status, 400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("TranscriptCache tokensByHour", () => {
|
describe("TranscriptCache tokensByHour", () => {
|
||||||
it("splits by message hour, counts a message once, and survives an incremental read", () => {
|
it("splits by message hour, counts a message once, and survives an incremental read", () => {
|
||||||
const file = path.join(ROOT, "t.jsonl");
|
const file = path.join(ROOT, "t.jsonl");
|
||||||
|
|||||||
@@ -375,6 +375,18 @@ describe("test probe + clear-data", () => {
|
|||||||
assert.equal(stmts.lastWebhookDeliveryForTarget.get(created.body.target.id), undefined);
|
assert.equal(stmts.lastWebhookDeliveryForTarget.get(created.body.target.id), undefined);
|
||||||
assert.ok(stmts.getWebhookTarget.get(created.body.target.id)); // target survives
|
assert.ok(stmts.getWebhookTarget.get(created.body.target.id)); // target survives
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clear-data leaves no workflow run behind its cleared session, and turns foreign keys back on", async () => {
|
||||||
|
db.prepare(
|
||||||
|
"INSERT OR IGNORE INTO sessions (id, name, status, started_at, updated_at) VALUES ('wf-s', 'w', 'completed', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')"
|
||||||
|
).run();
|
||||||
|
db.prepare("INSERT INTO workflows (run_id, session_id) VALUES ('wf-run', 'wf-s')").run();
|
||||||
|
const res = await post("/api/settings/clear-data");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM workflows").get().c, 0);
|
||||||
|
assert.equal(db.prepare("SELECT COUNT(*) AS c FROM sessions").get().c, 0);
|
||||||
|
assert.equal(db.pragma("foreign_keys", { simple: true }), 1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("provider registry", () => {
|
describe("provider registry", () => {
|
||||||
|
|||||||
+44
-2
@@ -144,6 +144,13 @@ const db = new Database(DB_PATH);
|
|||||||
db.pragma("journal_mode = WAL");
|
db.pragma("journal_mode = WAL");
|
||||||
db.pragma("foreign_keys = ON");
|
db.pragma("foreign_keys = ON");
|
||||||
db.pragma("busy_timeout = 5000");
|
db.pragma("busy_timeout = 5000");
|
||||||
|
// WAL + NORMAL never corrupts the file; a power cut can lose only the last
|
||||||
|
// commits, which hooks re-send. FULL fsynced every commit — several per hook.
|
||||||
|
db.pragma("synchronous = NORMAL");
|
||||||
|
// A checkpoint truncates the WAL back to this. Unset, one big transaction
|
||||||
|
// (an import, a cleanup) left a WAL of hundreds of MB that never shrank while
|
||||||
|
// any dashboard kept the database open. See also `maintainDatabase`.
|
||||||
|
db.pragma("journal_size_limit = 67108864");
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
@@ -298,7 +305,12 @@ db.exec(`
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_agents_session ON agents(session_id);
|
CREATE INDEX IF NOT EXISTS idx_agents_session ON agents(session_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status);
|
CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
|
-- (session_id, created_at): a session's newest event and its time order,
|
||||||
|
-- read by the watchdog every 15 s and by every lane payload. Without it each
|
||||||
|
-- read walked the rows' overflow chains (created_at sits after data).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_session_created ON events(session_id, created_at);
|
||||||
|
-- A prefix of idx_events_session_type: never needed, ~1% of the file.
|
||||||
|
DROP INDEX IF EXISTS idx_events_session;
|
||||||
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
|
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_events_created ON events(created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
||||||
@@ -2127,4 +2139,34 @@ const stmts = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing, topUpDefaultPricing };
|
/**
|
||||||
|
* Periodic upkeep, run hourly by the server: a PASSIVE checkpoint (never waits
|
||||||
|
* on a reader — the other dashboard's), after which the next write truncates
|
||||||
|
* the WAL to `journal_size_limit`; and `PRAGMA optimize`, which refreshes the
|
||||||
|
* planner's statistics only where they went stale. Never VACUUM: it rewrites
|
||||||
|
* the whole file under an exclusive lock.
|
||||||
|
*/
|
||||||
|
function maintainDatabase() {
|
||||||
|
const out = {};
|
||||||
|
try {
|
||||||
|
out.checkpoint = db.pragma("wal_checkpoint(PASSIVE)", { simple: false });
|
||||||
|
} catch (err) {
|
||||||
|
out.checkpointError = err.message;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.pragma("optimize");
|
||||||
|
} catch (err) {
|
||||||
|
out.optimizeError = err.message;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
db,
|
||||||
|
stmts,
|
||||||
|
DB_PATH,
|
||||||
|
DEFAULT_PRICING,
|
||||||
|
applyIntroPricing,
|
||||||
|
topUpDefaultPricing,
|
||||||
|
maintainDatabase,
|
||||||
|
};
|
||||||
|
|||||||
@@ -347,6 +347,16 @@ function startBackgroundServices() {
|
|||||||
.then(() => require("./lib/ai-tools").refresh())
|
.then(() => require("./lib/ai-tools").refresh())
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Hourly DB upkeep: a non-blocking checkpoint (the WAL then shrinks to
|
||||||
|
// journal_size_limit) and PRAGMA optimize. Off with DASHBOARD_DB_MAINTAIN_MS=0.
|
||||||
|
{
|
||||||
|
const every = Number(process.env.DASHBOARD_DB_MAINTAIN_MS ?? 60 * 60 * 1000);
|
||||||
|
if (every > 0) {
|
||||||
|
const t = setInterval(() => require("./db").maintainDatabase(), every);
|
||||||
|
if (t.unref) t.unref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Rates for models seen in token_usage with no pricing row of their own.
|
// Rates for models seen in token_usage with no pricing row of their own.
|
||||||
require("./lib/pricing-sync").startPricingSync(require("./db").db);
|
require("./lib/pricing-sync").startPricingSync(require("./db").db);
|
||||||
// The AI Tools catalog of the newest release, when it is newer than this build.
|
// The AI Tools catalog of the newest release, when it is newer than this build.
|
||||||
|
|||||||
+25
-9
@@ -1367,6 +1367,14 @@ router.post("/event", (req, res) => {
|
|||||||
return res.json({ ok: true, duplicate: true });
|
return res.json({ ok: true, duplicate: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse the transcript BEFORE the write transaction: `processEvent` takes the
|
||||||
|
// write lock at its first statement and reads the transcript inside it, so a
|
||||||
|
// cold multi-MB parse held the lock and the other dashboard on this DB hit
|
||||||
|
// SQLITE_BUSY. Warmed here, the read inside is a stat-check cache hit.
|
||||||
|
if (tool === "claude" && typeof data.transcript_path === "string" && data.transcript_path) {
|
||||||
|
transcriptCache.extract(data.transcript_path);
|
||||||
|
}
|
||||||
|
|
||||||
const result = processEvent(hook_type, data, tool);
|
const result = processEvent(hook_type, data, tool);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -1482,6 +1490,12 @@ router.post("/event", (req, res) => {
|
|||||||
// sessions every 15s to detect errors that hooks missed.
|
// sessions every 15s to detect errors that hooks missed.
|
||||||
const WATCHDOG_INTERVAL_MS = 15_000;
|
const WATCHDOG_INTERVAL_MS = 15_000;
|
||||||
const STALE_THRESHOLD_MS = 10_000; // only check sessions idle for >10s
|
const STALE_THRESHOLD_MS = 10_000; // only check sessions idle for >10s
|
||||||
|
// The watchdog's fallback for a session with no transcript_path column value.
|
||||||
|
const lastTranscriptEvent = db.prepare(
|
||||||
|
`SELECT data FROM events WHERE session_id = ?
|
||||||
|
AND event_type IN ('SessionStart','UserPromptSubmit','PreToolUse','Stop','Notification')
|
||||||
|
ORDER BY created_at DESC LIMIT 1`
|
||||||
|
);
|
||||||
|
|
||||||
// Idle-working timeout for the no-marker interrupt case (Esc pressed before the
|
// Idle-working timeout for the no-marker interrupt case (Esc pressed before the
|
||||||
// model emits anything → Claude Code writes no interrupt entry and fires no
|
// model emits anything → Claude Code writes no interrupt entry and fires no
|
||||||
@@ -1541,11 +1555,11 @@ function watchdogCheck() {
|
|||||||
// lifecycle is owned by server/lib/codex-usage.js.
|
// lifecycle is owned by server/lib/codex-usage.js.
|
||||||
const staleSessions = db
|
const staleSessions = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT s.id, s.status, s.cwd,
|
// transcript_path is the session's own column (every hook keeps it);
|
||||||
(SELECT MAX(e.created_at) FROM events e WHERE e.session_id = s.id) as last_event,
|
// reading it out of the newest event's `data` walked a multi-KB payload
|
||||||
(SELECT e.data FROM events e WHERE e.session_id = s.id
|
// per session, every 15 s, in every dashboard on this database.
|
||||||
AND e.event_type IN ('SessionStart','UserPromptSubmit','PreToolUse','Stop','Notification')
|
`SELECT s.id, s.status, s.cwd, s.transcript_path,
|
||||||
ORDER BY e.created_at DESC LIMIT 1) as last_data
|
(SELECT MAX(e.created_at) FROM events e WHERE e.session_id = s.id) as last_event
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
WHERE s.status IN ('active', 'error') AND s.updated_at < ?
|
WHERE s.status IN ('active', 'error') AND s.updated_at < ?
|
||||||
AND (s.source = 'local' OR s.source IS NULL) AND s.tool = 'claude'`
|
AND (s.source = 'local' OR s.source IS NULL) AND s.tool = 'claude'`
|
||||||
@@ -1553,11 +1567,13 @@ function watchdogCheck() {
|
|||||||
.all(cutoff);
|
.all(cutoff);
|
||||||
|
|
||||||
for (const sess of staleSessions) {
|
for (const sess of staleSessions) {
|
||||||
// Try to get transcript_path from event data first
|
// The session's recorded transcript first, else the newest hook payload
|
||||||
let tPath = null;
|
// that named one (a row from before the column was filled).
|
||||||
if (sess.last_data) {
|
let tPath = sess.transcript_path || null;
|
||||||
|
if (!tPath) {
|
||||||
|
const lastData = lastTranscriptEvent.get(sess.id)?.data;
|
||||||
try {
|
try {
|
||||||
tPath = JSON.parse(sess.last_data).transcript_path;
|
tPath = lastData ? JSON.parse(lastData).transcript_path : null;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
// Fall back: derive from Claude's standard path layout
|
// Fall back: derive from Claude's standard path layout
|
||||||
|
|||||||
@@ -463,15 +463,31 @@ router.delete("/:pattern", (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/pricing/cost - Get total cost across all sessions.
|
// GET /api/pricing/cost - Get total cost across all sessions (`?lane=<id>`: the
|
||||||
|
// sessions that workspace owns).
|
||||||
// Honors the global data-scope: `?sources=<csv>` narrows the aggregate to those
|
// Honors the global data-scope: `?sources=<csv>` narrows the aggregate to those
|
||||||
// origin machines (see server/lib/source-filter.js), matching the sessions /
|
// origin machines (see server/lib/source-filter.js), matching the sessions /
|
||||||
// stats / analytics endpoints so the Dashboard "total cost" tracks the selector.
|
// stats / analytics endpoints so the Dashboard "total cost" tracks the selector.
|
||||||
router.get("/cost", (req, res) => {
|
router.get("/cost", (req, res) => {
|
||||||
const rawOffset = parseInt(req.query.tz_offset, 10);
|
const rawOffset = parseInt(req.query.tz_offset, 10);
|
||||||
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
|
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
|
||||||
const { clause, params } = sourceColumnClause(parseSources(req), "s.source");
|
const lane = req.query.lane;
|
||||||
const whereClause = clause ? `WHERE ${clause}` : "";
|
if (lane !== undefined && !/^\d+$/.test(String(lane))) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: { code: "INVALID_INPUT", message: "lane must be a lane id" } });
|
||||||
|
}
|
||||||
|
const scope = sourceColumnClause(parseSources(req), "s.source");
|
||||||
|
const params = [...scope.params];
|
||||||
|
const where = scope.clause ? [scope.clause] : [];
|
||||||
|
if (lane !== undefined) {
|
||||||
|
// The sessions the workspace owns — the same rule as GET /api/sessions?lane=.
|
||||||
|
where.push(
|
||||||
|
"(s.lane_id = ? OR (s.lane_id IS NULL AND s.id IN (SELECT session_id FROM dashboard_runs WHERE lane_id = ?)))"
|
||||||
|
);
|
||||||
|
params.push(Number(lane), Number(lane));
|
||||||
|
}
|
||||||
|
const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||||
|
|
||||||
const dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
|
const dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
|
||||||
const rules = stmts.listPricing.all();
|
const rules = stmts.listPricing.all();
|
||||||
|
|||||||
@@ -300,7 +300,9 @@ router.get("/:id", (req, res) => {
|
|||||||
// Each agent's OWN cost (from its metadata token buckets) so subagent cards on
|
// Each agent's OWN cost (from its metadata token buckets) so subagent cards on
|
||||||
// the session-detail tree show their real cost, not the session total.
|
// the session-detail tree show their real cost, not the session total.
|
||||||
const agents = attachAgentCosts(stmts.listAgentsBySession.all(req.params.id));
|
const agents = attachAgentCosts(stmts.listAgentsBySession.all(req.params.id));
|
||||||
const events = stmts.listEventsBySession.all(req.params.id);
|
// `?events=0`: the web page pages its timeline separately, and a long
|
||||||
|
// session's full list is hundreds of MB of payload. The CLI still asks.
|
||||||
|
const events = req.query.events === "0" ? [] : stmts.listEventsBySession.all(req.params.id);
|
||||||
// Workflow-tool runs launched within this session (issue #167). Parse the
|
// Workflow-tool runs launched within this session (issue #167). Parse the
|
||||||
// JSON-blob columns so the client gets arrays, not strings.
|
// JSON-blob columns so the client gets arrays, not strings.
|
||||||
const workflows = stmts.listWorkflowsBySession.all(req.params.id).map((w) => {
|
const workflows = stmts.listWorkflowsBySession.all(req.params.id).map((w) => {
|
||||||
|
|||||||
+93
-44
@@ -141,7 +141,19 @@ router.get("/info", (req, res) => {
|
|||||||
// POST /api/settings/clear-data — delete all sessions, agents, events, tokens
|
// POST /api/settings/clear-data — delete all sessions, agents, events, tokens
|
||||||
router.post("/clear-data", (_req, res) => {
|
router.post("/clear-data", (_req, res) => {
|
||||||
const counts = getTableCounts();
|
const counts = getTableCounts();
|
||||||
|
// foreign_keys cannot change inside a transaction, so it is turned off
|
||||||
|
// around one: all of it lands, or none of it (a crash midway used to leave
|
||||||
|
// events without sessions).
|
||||||
db.pragma("foreign_keys = OFF");
|
db.pragma("foreign_keys = OFF");
|
||||||
|
try {
|
||||||
|
db.transaction(clearAll)();
|
||||||
|
} finally {
|
||||||
|
db.pragma("foreign_keys = ON");
|
||||||
|
}
|
||||||
|
res.json({ ok: true, cleared: counts });
|
||||||
|
});
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
db.prepare("DELETE FROM token_usage").run();
|
db.prepare("DELETE FROM token_usage").run();
|
||||||
db.prepare("DELETE FROM token_usage_hourly").run();
|
db.prepare("DELETE FROM token_usage_hourly").run();
|
||||||
db.prepare("DELETE FROM events").run();
|
db.prepare("DELETE FROM events").run();
|
||||||
@@ -157,9 +169,10 @@ router.post("/clear-data", (_req, res) => {
|
|||||||
// Webhook delivery log is an audit trail of those fired alerts — wipe it too.
|
// Webhook delivery log is an audit trail of those fired alerts — wipe it too.
|
||||||
// Webhook *targets* survive, like alert rules and pricing.
|
// Webhook *targets* survive, like alert rules and pricing.
|
||||||
db.prepare("DELETE FROM webhook_deliveries").run();
|
db.prepare("DELETE FROM webhook_deliveries").run();
|
||||||
db.pragma("foreign_keys = ON");
|
// Workflow runs belong to the sessions; with foreign keys off they would
|
||||||
res.json({ ok: true, cleared: counts });
|
// not cascade and stay as orphans.
|
||||||
});
|
db.prepare("DELETE FROM workflows").run();
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/settings/reimport — re-import legacy sessions from ~/.claude/
|
// POST /api/settings/reimport — re-import legacy sessions from ~/.claude/
|
||||||
router.post("/reimport", async (_req, res) => {
|
router.post("/reimport", async (_req, res) => {
|
||||||
@@ -257,27 +270,53 @@ router.post("/reset-pricing", async (req, res) => {
|
|||||||
res.json({ ok: true, pricing });
|
res.json({ ok: true, pricing });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/settings/export — export all data as JSON
|
// GET /api/settings/export — export all data as JSON. Same document as ever,
|
||||||
router.get("/export", (_req, res) => {
|
// but written as it is read: `events` alone is GBs on a long-lived install,
|
||||||
const sessions = db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all();
|
// and one `.all()` + `res.json` held it in memory twice (past V8's string
|
||||||
const agents = db.prepare("SELECT * FROM agents ORDER BY started_at DESC").all();
|
// limit). Events go out in keyset pages, waiting for the socket to drain.
|
||||||
const events = db.prepare("SELECT * FROM events ORDER BY created_at DESC").all();
|
const EXPORT_PAGE = 500;
|
||||||
const tokenUsage = db.prepare("SELECT * FROM token_usage").all();
|
router.get("/export", async (req, res) => {
|
||||||
const pricing = stmts.listPricing.all();
|
|
||||||
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
res.setHeader("Content-Type", "application/json");
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
`attachment; filename="agent-monitor-export-${new Date().toISOString().slice(0, 10)}.json"`
|
`attachment; filename="agent-monitor-export-${new Date().toISOString().slice(0, 10)}.json"`
|
||||||
);
|
);
|
||||||
res.json({
|
let gone = false;
|
||||||
exported_at: new Date().toISOString(),
|
req.on("close", () => (gone = true));
|
||||||
sessions,
|
const write = async (chunk) => {
|
||||||
agents,
|
if (!res.write(chunk)) await new Promise((r) => res.once("drain", r));
|
||||||
events,
|
};
|
||||||
token_usage: tokenUsage,
|
const field = (name, value) => `,${JSON.stringify(name)}:${JSON.stringify(value)}`;
|
||||||
model_pricing: pricing,
|
try {
|
||||||
});
|
await write(`{"exported_at":${JSON.stringify(new Date().toISOString())}`);
|
||||||
|
await write(
|
||||||
|
field("sessions", db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all())
|
||||||
|
);
|
||||||
|
await write(field("agents", db.prepare("SELECT * FROM agents ORDER BY started_at DESC").all()));
|
||||||
|
await write(`,"events":[`);
|
||||||
|
const first = db.prepare(
|
||||||
|
`SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT ${EXPORT_PAGE}`
|
||||||
|
);
|
||||||
|
const next = db.prepare(
|
||||||
|
`SELECT * FROM events WHERE created_at < ? OR (created_at = ? AND id < ?)
|
||||||
|
ORDER BY created_at DESC, id DESC LIMIT ${EXPORT_PAGE}`
|
||||||
|
);
|
||||||
|
let page = first.all();
|
||||||
|
let sep = "";
|
||||||
|
while (page.length && !gone) {
|
||||||
|
await write(sep + page.map((e) => JSON.stringify(e)).join(","));
|
||||||
|
sep = ",";
|
||||||
|
const last = page[page.length - 1];
|
||||||
|
page = next.all(last.created_at, last.created_at, last.id);
|
||||||
|
}
|
||||||
|
await write("]");
|
||||||
|
await write(field("token_usage", db.prepare("SELECT * FROM token_usage").all()));
|
||||||
|
await write(field("model_pricing", stmts.listPricing.all()) + "}");
|
||||||
|
res.end();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("export failed:", err.message);
|
||||||
|
res.destroy(err);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// The two editable home directories share one shape: GET answers the value and
|
// The two editable home directories share one shape: GET answers the value and
|
||||||
@@ -319,6 +358,7 @@ homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaud
|
|||||||
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
|
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
|
||||||
|
|
||||||
// POST /api/settings/cleanup — abandon stale sessions, purge old data
|
// POST /api/settings/cleanup — abandon stale sessions, purge old data
|
||||||
|
const PURGE_CHUNK = 500;
|
||||||
router.post("/cleanup", (req, res) => {
|
router.post("/cleanup", (req, res) => {
|
||||||
const { abandon_hours, purge_days } = req.body;
|
const { abandon_hours, purge_days } = req.body;
|
||||||
const result = { abandoned: 0, purged_sessions: 0, purged_events: 0, purged_agents: 0 };
|
const result = { abandoned: 0, purged_sessions: 0, purged_events: 0, purged_agents: 0 };
|
||||||
@@ -337,15 +377,19 @@ router.post("/cleanup", (req, res) => {
|
|||||||
)
|
)
|
||||||
.all(cutoff, cutoff);
|
.all(cutoff, cutoff);
|
||||||
|
|
||||||
for (const row of stale) {
|
const abandon = db.prepare(
|
||||||
db.prepare(
|
"UPDATE sessions SET status = 'abandoned', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
|
||||||
"UPDATE sessions SET status = 'abandoned', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
|
);
|
||||||
).run(row.id);
|
// Also complete any lingering agents
|
||||||
// Also complete any lingering agents
|
const complete = db.prepare(
|
||||||
db.prepare(
|
"UPDATE agents SET status = 'completed', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE session_id = ? AND status IN ('waiting','working')"
|
||||||
"UPDATE agents SET status = 'completed', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE session_id = ? AND status IN ('waiting','working')"
|
);
|
||||||
).run(row.id);
|
db.transaction(() => {
|
||||||
}
|
for (const row of stale) {
|
||||||
|
abandon.run(row.id);
|
||||||
|
complete.run(row.id);
|
||||||
|
}
|
||||||
|
})();
|
||||||
result.abandoned = stale.length;
|
result.abandoned = stale.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,23 +402,28 @@ router.post("/cleanup", (req, res) => {
|
|||||||
)
|
)
|
||||||
.all(cutoff);
|
.all(cutoff);
|
||||||
|
|
||||||
if (toDelete.length > 0) {
|
// One transaction, in chunks: a single IN (...) list of every id would
|
||||||
|
// pass SQLite's bound-variable limit on a large history.
|
||||||
|
db.transaction(() => {
|
||||||
const ids = toDelete.map((r) => r.id);
|
const ids = toDelete.map((r) => r.id);
|
||||||
const placeholders = ids.map(() => "?").join(",");
|
for (let i = 0; i < ids.length; i += PURGE_CHUNK) {
|
||||||
// Cascading deletes handle agents/events, but token_usage FK might not cascade on all setups
|
const chunk = ids.slice(i, i + PURGE_CHUNK);
|
||||||
result.purged_events = db
|
const placeholders = chunk.map(() => "?").join(",");
|
||||||
.prepare(`DELETE FROM events WHERE session_id IN (${placeholders})`)
|
// Cascading deletes handle agents/events, but token_usage FK might not cascade on all setups
|
||||||
.run(...ids).changes;
|
result.purged_events += db
|
||||||
result.purged_agents = db
|
.prepare(`DELETE FROM events WHERE session_id IN (${placeholders})`)
|
||||||
.prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`)
|
.run(...chunk).changes;
|
||||||
.run(...ids).changes;
|
result.purged_agents += db
|
||||||
db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(...ids);
|
.prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`)
|
||||||
db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run(
|
.run(...chunk).changes;
|
||||||
...ids
|
db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(...chunk);
|
||||||
);
|
db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run(
|
||||||
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...ids);
|
...chunk
|
||||||
result.purged_sessions = toDelete.length;
|
);
|
||||||
}
|
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...chunk);
|
||||||
|
}
|
||||||
|
result.purged_sessions = ids.length;
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ ok: true, ...result });
|
res.json({ ok: true, ...result });
|
||||||
|
|||||||
Reference in New Issue
Block a user