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

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

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

Database, no data dropped: synchronous=NORMAL, journal_size_limit 64 MB,
hourly PASSIVE checkpoint + PRAGMA optimize, idx_events_session_created
(replacing the redundant idx_events_session), watchdog reads
sessions.transcript_path, transcript parsed before the hook's write
transaction, GET /api/sessions/:id?events=0 for the web page (reloads only
on its own session, debounced), streamed export, transactional clear-data
and chunked cleanup, subagent import reads tool_use_ids once, runtime logs
rotated past 5 MB.
This commit is contained in:
2026-10-05 09:37:48 +07:00
parent 65fe680d38
commit ad6e80192d
80 changed files with 2914 additions and 489 deletions
+18 -1
View File
@@ -1,7 +1,7 @@
/**
* @file One lane's card: CI/PR facts, the "needs you"
* banner sourced from Claude Code's Notification hook, and (for managed
* lanes only) a ⋯ menu holding the rare `reset worktree` action behind
* lanes only) a ⋯ menu — also on a right-click — holding the rare `reset worktree` action behind
* DestructiveLaneModal. A dead lane (its driving session went silent while
* it should have been working) is called out loudly — that is the failure
* this view exists to catch. Stage, kind, and the "auto: <stage>" chip are
@@ -23,6 +23,7 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { DestructiveLaneModal } from "./DestructiveLaneModal";
import { ContextMenu, MenuItem, menuPoint, type MenuPoint } from "../ContextMenu";
import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { keepIfSame } from "../../lib/keepIfSame";
@@ -271,6 +272,7 @@ export default function LaneCard({
const { t } = useTranslation(["lanes"]);
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [menuAt, setMenuAt] = useState<MenuPoint | null>(null);
const [runtimeBump, setRuntimeBump] = useState(0);
const [bootLine, setBootLine] = useState<string | null>(null);
const runtime = useLaneRuntime(lane.id, runtimeBump);
@@ -301,6 +303,8 @@ export default function LaneCard({
<>
<div
data-testid={`lane-card-${lane.id}`}
// Right-click: the ⋯ menu's actions at the pointer (managed only).
onContextMenu={lane.kind === "managed" ? (e) => setMenuAt(menuPoint(e)) : undefined}
// Every row below is conditional: a lane with nothing to report
// renders no frame at all rather than an empty box.
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light empty:hidden"
@@ -462,6 +466,19 @@ export default function LaneCard({
)}
</div>
{menuAt && (
<ContextMenu x={menuAt.x} y={menuAt.y} onClose={() => setMenuAt(null)}>
<MenuItem
danger
onSelect={() => {
setMenuAt(null);
setDestructiveAction("reset");
}}
>
{t("action.reset")}
</MenuItem>
</ContextMenu>
)}
{destructiveAction && (
<DestructiveLaneModal
lane={lane}
@@ -63,6 +63,7 @@ import { api, gitTarget } from "../../lib/api";
import { pasteToLane } from "../../lib/panePaste";
import { LaneDiffView, LaneFileView, type DiffLayout, type LaneDiffChange } from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
import type {
@@ -151,6 +152,7 @@ function TreeNodes({
expanded,
onToggleDir,
onOpenFile,
onMenu,
active,
}: {
path: string;
@@ -159,6 +161,8 @@ function TreeNodes({
expanded: Set<string>;
onToggleDir: (path: string) => void;
onOpenFile: (path: string) => void;
/** Right-click on an entry. */
onMenu?: (e: React.MouseEvent, entry: LaneTreeEntry) => void;
/** The file open beside the tree, drawn selected. */
active?: string | null;
}) {
@@ -174,6 +178,7 @@ function TreeNodes({
type="button"
data-tree-status={mark?.letter ?? (e.changed ? "dir" : undefined)}
onClick={() => (e.dir ? onToggleDir(e.path) : onOpenFile(e.path))}
onContextMenu={onMenu && ((ev) => onMenu(ev, e))}
style={{ paddingLeft: `${depth * 0.9 + 0.25}rem` }}
aria-current={active === e.path ? "true" : undefined}
className={`flex w-full items-center gap-1 rounded px-1 py-0.5 text-left font-mono text-[11px] hover:bg-surface-2 ${
@@ -217,6 +222,7 @@ function TreeNodes({
expanded={expanded}
onToggleDir={onToggleDir}
onOpenFile={onOpenFile}
onMenu={onMenu}
active={active}
/>
</ul>
@@ -372,10 +378,12 @@ function ChangeRow({
onAct,
onArm,
onResolve,
onMenu,
}: {
f: LaneChangeFile;
range: LaneChangeRange;
onResolve: Resolve;
onMenu: (e: React.MouseEvent, f: LaneChangeFile) => void;
busy: boolean;
/** This row's discard has been clicked once and awaits the second click. */
armed: boolean;
@@ -385,7 +393,7 @@ function ChangeRow({
}) {
const { t } = useTranslation(["lanes"]);
return (
<li className="group flex items-baseline gap-1">
<li className="group flex items-baseline gap-1" onContextMenu={(e) => onMenu(e, f)}>
<button
type="button"
onClick={() => onOpen(f.path)}
@@ -844,6 +852,136 @@ export function LaneChangesPanel({
};
const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side });
const fileCtx = useContextMenu<LaneChangeFile>();
const treeCtx = useContextMenu<LaneTreeEntry>();
const commitCtx = useContextMenu<LaneCommit>();
const viewCommit = (sha: string) => {
setSha(sha);
setRange("commit");
setMode("review");
};
const copy = (text: string) => void navigator.clipboard.writeText(text);
// A menu item: close its menu, then act.
const pick = (close: () => void, fn: () => void) => () => {
close();
fn();
};
const menuFile = fileCtx.menu?.item;
const menuArmed = !!menuFile && confirmDiscard === menuFile.path;
const menus = (
<>
{fileCtx.menu && menuFile && (
<ContextMenu
x={fileCtx.menu.x}
y={fileCtx.menu.y}
onClose={fileCtx.close}
testId="lane-changes-file-menu"
>
<MenuItem onSelect={pick(fileCtx.close, () => show(menuFile.path))}>
{t("changes.openFile", { path: menuFile.path.split("/").pop() })}
</MenuItem>
{range === "work" && menuFile.conflicted && (
<>
<MenuSeparator />
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "ours"))}
>
{t("changes.takeOurs")}
</MenuItem>
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "theirs"))}
>
{t("changes.takeTheirs")}
</MenuItem>
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => resolve(menuFile.path, "done"))}
>
{t("changes.markResolved")}
</MenuItem>
</>
)}
{range === "work" && !menuFile.conflicted && (
<>
<MenuSeparator />
{(menuFile.unstaged || menuFile.untracked) && (
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => void act("stage", menuFile.path))}
>
{t("changes.stage")}
</MenuItem>
)}
{menuFile.staged && (
<MenuItem
disabled={busy}
onSelect={pick(fileCtx.close, () => void act("unstage", menuFile.path))}
>
{t("changes.unstage")}
</MenuItem>
)}
<MenuItem
danger
disabled={busy}
testId="lane-changes-menu-discard"
// Two clicks here too: the first arms (the row's trash
// shows it), only the second reaches the server.
onSelect={() =>
menuArmed
? pick(fileCtx.close, () => void act("discard", menuFile.path, true))()
: setConfirmDiscard(menuFile.path)
}
>
{menuArmed ? t("changes.discardConfirm") : t("changes.discard")}
</MenuItem>
</>
)}
<MenuSeparator />
<MenuItem onSelect={pick(fileCtx.close, () => copy(menuFile.path))}>
{t("changes.copyPath")}
</MenuItem>
</ContextMenu>
)}
{treeCtx.menu && (
<ContextMenu
x={treeCtx.menu.x}
y={treeCtx.menu.y}
onClose={treeCtx.close}
testId="lane-changes-tree-menu"
>
{!treeCtx.menu.item.dir && (
<MenuItem onSelect={pick(treeCtx.close, () => openWhole(treeCtx.menu!.item.path))}>
{t("changes.openFile", { path: treeCtx.menu.item.name })}
</MenuItem>
)}
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
{t("changes.copyPath")}
</MenuItem>
</ContextMenu>
)}
{commitCtx.menu && (
<ContextMenu
x={commitCtx.menu.x}
y={commitCtx.menu.y}
onClose={commitCtx.close}
testId="lane-changes-commit-menu"
>
<MenuItem onSelect={pick(commitCtx.close, () => viewCommit(commitCtx.menu!.item.sha))}>
{t("changes.viewCommit")}
</MenuItem>
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.sha))}>
{t("changes.copySha")}
</MenuItem>
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.subject))}>
{t("changes.copySubject")}
</MenuItem>
</ContextMenu>
)}
</>
);
// One change block, named by its hunk's `@@` line and its index inside that
// hunk, matched against a fresh diff at the context this diff was read
// with — so the server never applies a stale patch.
@@ -1038,6 +1176,7 @@ export function LaneChangesPanel({
expanded={expandedDirs}
onToggleDir={toggleDir}
onOpenFile={openWhole}
onMenu={treeCtx.open}
active={full && mode === "file" ? openFile : null}
/>
</ul>
@@ -1050,6 +1189,7 @@ export function LaneChangesPanel({
data-testid="lane-changes"
className={`flex min-h-0 flex-1 ${full ? "gap-3" : "flex-col"}`}
>
{menus}
{/* Full width has room for an explorer: the tree stays beside the file
it opened, instead of being a mode you leave to read one. */}
{full && (
@@ -1278,11 +1418,8 @@ export function LaneChangesPanel({
<li key={c.sha}>
<button
type="button"
onClick={() => {
setSha(c.sha);
setRange("commit");
setMode("review");
}}
onClick={() => viewCommit(c.sha)}
onContextMenu={(e) => commitCtx.open(e, c)}
className="w-full rounded px-1 py-0.5 text-left hover:bg-surface-2"
>
<span className="flex items-baseline gap-1.5">
@@ -1432,6 +1569,7 @@ export function LaneChangesPanel({
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
onResolve={resolve}
onMenu={fileCtx.open}
/>
))}
</ul>
@@ -1603,6 +1741,7 @@ export function LaneChangesPanel({
role="button"
tabIndex={0}
onClick={() => toggleCollapsed(f.path)}
onContextMenu={(e) => fileCtx.open(e, f)}
onKeyDown={(e) => {
// Only the header's own keys: Enter on the path button
// inside bubbles here and must open, not fold.
+102
View File
@@ -0,0 +1,102 @@
/**
* @file SessionMenu — a session row's right-click menu (sidebar, the workspace
* home's Recents, the archived fold), and a bare live tab's (`session` null —
* the sidebar's **New session** row). Every item is something the row already
* does: open it the way its click does (a live pane attaches, an ended session
* opens its conversation — never a resume), open a live one in a split,
* rename / archive / unarchive when the list offers them, copy its id, open
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
* tab's X does).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { api, type RunHandle } from "../../lib/api";
import type { Session } from "../../lib/types";
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu";
export function SessionMenu({
laneId,
session,
run,
x,
y,
onClose,
onRename,
onArchive,
onUnarchive,
}: MenuPoint & {
laneId: number;
session: Session | null;
/** The live pane holding it, if any. */
run: RunHandle | null;
onClose: () => void;
onRename?: () => void;
onArchive?: () => void;
onUnarchive?: () => void;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const runUrl = run && `/workspace?lane=${laneId}&run=${encodeURIComponent(run.id)}`;
const pick = (fn: () => unknown) => () => {
onClose();
fn();
};
const closeTab = async () => {
if (!run) return;
setError(null);
try {
await api.run.kill(run.id);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
return (
<ContextMenu x={x} y={y} onClose={onClose} testId={`session-menu-${session?.id ?? run?.id}`}>
<MenuItem
onSelect={pick(() =>
navigate(
runUrl ?? `/workspace?lane=${laneId}&session=${encodeURIComponent(session?.id ?? "")}`
)
)}
>
{t("sessionMenu.open")}
</MenuItem>
{runUrl && (
<MenuItem onSelect={pick(() => navigate(runUrl, { state: { split: true } }))}>
{t("sessionMenu.openSplit")}
</MenuItem>
)}
{onRename && <MenuItem onSelect={pick(onRename)}>{t("menu.rename")}</MenuItem>}
{session && (
<>
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
{t("sessionMenu.details")}
</MenuItem>
<MenuItem onSelect={pick(() => void navigator.clipboard.writeText(session.id))}>
{t("sessionMenu.copyId")}
</MenuItem>
</>
)}
{(run || onArchive || onUnarchive) && <MenuSeparator />}
{onUnarchive && <MenuItem onSelect={pick(onUnarchive)}>{t("tree.unarchive")}</MenuItem>}
{onArchive && <MenuItem onSelect={pick(onArchive)}>{t("sessionMenu.archive")}</MenuItem>}
{run && (
<MenuItem danger onSelect={() => void closeTab()}>
{t("tabs.close")}
</MenuItem>
)}
{error && (
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
{error}
</p>
)}
</ContextMenu>
);
}
+89 -11
View File
@@ -41,6 +41,7 @@ import {
type CodexSandbox,
type EffortLevel,
type LaneMemoryFile,
type LaneSource,
type LaneSourcesView,
type ModelChoice,
type PermissionMode,
@@ -57,6 +58,8 @@ import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
import { workspaceName } from "./WorkspaceRow";
import { SessionMenu } from "./SessionMenu";
import { ContextMenu, MenuItem, MenuSeparator, menuPoint, useContextMenu } from "../ContextMenu";
import {
liveRunFor,
SessionFold,
@@ -162,12 +165,15 @@ function HomeBody({
const [editing, setEditing] = useState<string | null>(null);
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
const [memoryOpen, setMemoryOpen] = useState(false);
const [memoryTab, setMemoryTab] = useState(0);
const [folderOpen, setFolderOpen] = useState(false);
const [folderPath, setFolderPath] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
const [title, setTitle] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const recentCtx = useContextMenu<Session>();
const sourceCtx = useContextMenu<LaneSource>();
// A new pane, a pane gone, a session id learned — all move these lane fields.
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
@@ -342,6 +348,7 @@ function HomeBody({
{title === null ? (
<h1
title={lane.cwd}
onContextMenu={(e) => setMenuAt(menuPoint(e))}
className="min-w-0 flex-1 truncate text-2xl font-semibold text-fg-primary"
>
{name}
@@ -582,6 +589,7 @@ function HomeBody({
data-testid={`workspace-recent-${s.id}`}
data-live={live ? "true" : undefined}
onClick={() => openSession(s)}
onContextMenu={(e) => recentCtx.open(e, s)}
className="flex w-full items-center gap-3 py-2.5 text-left hover:bg-surface-3"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${sessionDot(s, live)}`} />
@@ -619,6 +627,7 @@ function HomeBody({
actionIcon={<ArchiveRestore className="h-3.5 w-3.5" />}
onAction={(s) => api.sessions.patch(s.id, { archived: false })}
onDone={() => setVersion((v) => v + 1)}
laneId={lane.id}
/>
</div>
</div>
@@ -690,7 +699,10 @@ function HomeBody({
<h3 className="text-sm font-medium text-fg-primary">{t("home.memory")}</h3>
<button
type="button"
onClick={() => setMemoryOpen(true)}
onClick={() => {
setMemoryTab(0);
setMemoryOpen(true);
}}
disabled={!memory?.length}
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
>
@@ -743,7 +755,11 @@ function HomeBody({
) : (
<ul className="mt-2 space-y-1">
{files.map((f) => (
<li key={f.id} className="group flex items-center gap-2 text-xs">
<li
key={f.id}
className="group flex items-center gap-2 text-xs"
onContextMenu={(e) => sourceCtx.open(e, f)}
>
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
@@ -790,7 +806,11 @@ function HomeBody({
</span>
</li>
{folders.map((f) => (
<li key={f.id} className="group flex items-center gap-2 text-xs">
<li
key={f.id}
className="group flex items-center gap-2 text-xs"
onContextMenu={(e) => sourceCtx.open(e, f)}
>
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
@@ -872,7 +892,7 @@ function HomeBody({
aria-label={t("home.memory")}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.key === "Escape" && setMemoryOpen(false)}
className="max-h-[85vh] w-full max-w-3xl overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
@@ -886,18 +906,76 @@ function HomeBody({
<X className="h-4 w-4" />
</button>
</div>
{memory.map((f) => (
<section key={f.path} className="mb-5">
<p title={f.path} className="mb-1 font-mono text-xs text-fg-muted">
<div
role="tablist"
aria-label={t("home.memory")}
className="mb-3 flex gap-1.5 overflow-x-auto border-b border-border/60 pb-2 no-scrollbar"
>
{memory.map((f, i) => (
<button
key={f.path}
type="button"
role="tab"
title={f.path}
aria-selected={i === memoryTab}
onClick={() => setMemoryTab(i)}
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
i === memoryTab
? "border-accent/30 bg-accent/15 text-accent"
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{f.label}
</p>
<MarkdownPreview text={f.content} />
</section>
))}
</button>
))}
</div>
{memory[memoryTab] && (
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto">
<MarkdownPreview text={memory[memoryTab].content} />
</div>
)}
</div>
</div>
)}
{recentCtx.menu && (
<SessionMenu
x={recentCtx.menu.x}
y={recentCtx.menu.y}
laneId={lane.id}
session={recentCtx.menu.item}
run={liveRunFor(lane, liveRuns, recentCtx.menu.item.id)}
onClose={recentCtx.close}
/>
)}
{sourceCtx.menu && (
<ContextMenu
x={sourceCtx.menu.x}
y={sourceCtx.menu.y}
onClose={sourceCtx.close}
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
>
<MenuItem
onSelect={() => {
void navigator.clipboard.writeText(sourceCtx.menu!.item.path);
sourceCtx.close();
}}
>
{t("menu.copyPath")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
onSelect={() => {
const id = sourceCtx.menu!.item.id;
sourceCtx.close();
void run(() => api.lanes.removeSource(lane.id, id));
}}
>
{t("home.remove")}
</MenuItem>
</ContextMenu>
)}
{menuAt && (
<WorkspaceRowMenu
lane={lane}
@@ -6,15 +6,16 @@
* the lane Tools menu starts), New workspace here (the Add modal prefilled),
* Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* managed lane. The server's guards are the real ones; the menu only hides
* what the lane's kind or tool makes meaningless. Closes on Escape, an outside
* click or after an item runs.
* what the lane's kind or tool makes meaningless. The shell (position,
* Escape / outside-click close) is `ContextMenu`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove" | "started";
@@ -33,24 +34,8 @@ export function WorkspaceRowMenu({
onAction: (action: WorkspaceMenuAction) => void;
}) {
const { t } = useTranslation("lanes");
const ref = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [onClose]);
const run = async (fn: () => Promise<unknown>, then?: WorkspaceMenuAction) => {
setError(null);
try {
@@ -62,41 +47,21 @@ export function WorkspaceRowMenu({
}
};
const live = !!lane.run_id || lane.status === "running";
const item =
"block w-full rounded px-2 py-1 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
return (
<div
ref={ref}
role="menu"
data-testid={`workspace-row-menu-${lane.id}`}
style={{
left: Math.min(x, window.innerWidth - 232),
top: Math.min(y, window.innerHeight - 320),
}}
className="fixed z-50 w-56 rounded-lg border border-border bg-surface-2 p-1 shadow-lg"
>
<button
type="button"
role="menuitem"
className={item}
onClick={() =>
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
<MenuItem
onSelect={() =>
live
? void run(() => api.lanes.action(lane.id, "stop"))
: void run(() => api.lanes.action(lane.id, "start", { slot: 0, prompt: "" }), "started")
}
>
{live ? t("menu.stop") : t("menu.start")}
</button>
<button type="button" role="menuitem" className={item} onClick={() => onAction("rename")}>
{t("menu.rename")}
</button>
</MenuItem>
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
{lane.tool === "claude" && (
<button
type="button"
role="menuitem"
className={item}
onClick={() =>
<MenuItem
onSelect={() =>
void run(
() =>
api.run.start({
@@ -109,43 +74,26 @@ export function WorkspaceRowMenu({
}
>
{t("actions.writeDocs")}
</button>
</MenuItem>
)}
<button type="button" role="menuitem" className={item} onClick={() => onAction("new-here")}>
{t("menu.newHere")}
</button>
<button
type="button"
role="menuitem"
className={item}
onClick={() => void run(() => navigator.clipboard.writeText(lane.cwd))}
>
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
<MenuItem onSelect={() => void run(() => navigator.clipboard.writeText(lane.cwd))}>
{t("menu.copyPath")}
</button>
<div className="my-1 border-t border-border" />
</MenuItem>
<MenuSeparator />
{lane.kind === "managed" && (
<button
type="button"
role="menuitem"
className={`${item} !text-status-danger`}
onClick={() => onAction("reset")}
>
<MenuItem danger onSelect={() => onAction("reset")}>
{t("menu.reset")}
</button>
</MenuItem>
)}
<button
type="button"
role="menuitem"
className={`${item} !text-status-danger`}
onClick={() => onAction("remove")}
>
<MenuItem danger onSelect={() => onAction("remove")}>
{t("menu.remove")}
</button>
</MenuItem>
{error && (
<p role="alert" className="px-2 py-1 text-[11px] text-status-danger">
{error}
</p>
)}
</div>
</ContextMenu>
);
}
@@ -6,7 +6,8 @@
* shows its state (`sessionDot`: live pane, needs you, error) and its right
* edge the AI tool the session ran on (`sessions.tool`); double-click
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
* which deletes nothing). A session with a LIVE pane (a `GET /api/run` handle
* which deletes nothing); a right-click opens the same actions and more
* (`SessionMenu`). A session with a LIVE pane (a `GET /api/run` handle
* carrying its session id) opens that tmux pane on the Workspace page
* (`?lane=<id>&run=<runId>`, its tab in the session view; a live tab whose
* session has no row yet — a bare tab before its first prompt — is listed as
@@ -29,6 +30,8 @@ import { timeAgo } from "../../lib/format";
import { ConfirmModal } from "../ConfirmModal";
import { AiToolIcon } from "../AiToolIcon";
import { useAiTools } from "../../hooks/useAiTools";
import { useContextMenu } from "../ContextMenu";
import { SessionMenu } from "./SessionMenu";
export const SESSION_LIMIT = 20;
@@ -96,6 +99,7 @@ export function SessionFold({
onAction,
onDone,
indent = "",
laneId,
}: {
testId: string;
page: SessionPage | null;
@@ -107,9 +111,12 @@ export function SessionFold({
onDone: () => void;
/** Left padding of the rows, to line up with the list above. */
indent?: string;
/** The rows' workspace: given, a right-click opens the session menu. */
laneId?: number;
}) {
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const ctx = useContextMenu<Session>();
if (!page || page.total === 0) return null;
const act = async (s: Session) => {
setError(null);
@@ -134,7 +141,11 @@ export function SessionFold({
{open && (
<ul className="space-y-px">
{page.sessions.map((s) => (
<li key={s.id} className={`flex items-center gap-1 pr-1 ${indent}`}>
<li
key={s.id}
className={`flex items-center gap-1 pr-1 ${indent}`}
onContextMenu={laneId === undefined ? undefined : (e) => ctx.open(e, s)}
>
<Link
to={`/sessions/${s.id}`}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}`}
@@ -161,6 +172,17 @@ export function SessionFold({
{error}
</p>
)}
{ctx.menu && laneId !== undefined && (
<SessionMenu
x={ctx.menu.x}
y={ctx.menu.y}
laneId={laneId}
session={ctx.menu.item}
run={null}
onClose={ctx.close}
onUnarchive={() => void act(ctx.menu!.item)}
/>
)}
</div>
);
}
@@ -201,6 +223,8 @@ export function WorkspaceSessionList({
// worktree too, so that is confirmed first.
const [archiving, setArchiving] = useState<Session | null>(null);
const [busy, setBusy] = useState(false);
const ctx = useContextMenu<RunHandle>();
const sessionCtx = useContextMenu<Session>();
// This workspace's live AI tabs. A tab started bare (no prompt) has its
// session id from the start, but no session row until a hook creates one.
@@ -246,6 +270,10 @@ export function WorkspaceSessionList({
// Archive: a worktree session with no live pane loses its worktree first
// (never forced — git refuses unsaved work, which then stops the archive);
// one Claude already cleaned up is just archived.
const startRename = (s: Session) => {
setDraft(s.name || "");
setRenaming(s.id);
};
const archive = (s: Session) =>
sessionWorktreeName(lane, s) && !runFor(s)
? setArchiving(s)
@@ -311,6 +339,7 @@ export function WorkspaceSessionList({
e.ctrlKey || e.metaKey ? { state: { split: true } } : undefined
)
}
onContextMenu={(e) => ctx.open(e, r)}
title={`${t("tree.newSession")}\n${t("tree.splitHint")}`}
className={`flex w-full items-center gap-2 rounded-md py-1 pl-7 pr-2 text-left text-[12px] ${
r.id === selectedRunId
@@ -370,10 +399,8 @@ export function WorkspaceSessionList({
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
)
}
onDoubleClick={() => {
setDraft(s.name || "");
setRenaming(s.id);
}}
onDoubleClick={() => startRename(s)}
onContextMenu={(e) => sessionCtx.open(e, s)}
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
sessionWorktreeName(lane, s)
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
@@ -418,6 +445,28 @@ export function WorkspaceSessionList({
</ul>
{failure}
{confirmArchive}
{ctx.menu && (
<SessionMenu
x={ctx.menu.x}
y={ctx.menu.y}
laneId={lane.id}
session={null}
run={ctx.menu.item}
onClose={ctx.close}
/>
)}
{sessionCtx.menu && (
<SessionMenu
x={sessionCtx.menu.x}
y={sessionCtx.menu.y}
laneId={lane.id}
session={sessionCtx.menu.item}
run={runFor(sessionCtx.menu.item)}
onClose={sessionCtx.close}
onRename={() => startRename(sessionCtx.menu!.item)}
onArchive={() => archive(sessionCtx.menu!.item)}
/>
)}
</>
);
}
@@ -468,6 +468,39 @@ describe("LaneChangesPanel", () => {
});
});
it("right-click on a changed file offers stage, a two-click discard and copy path", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
const list = await screen.findByTestId("lane-changes-overview");
const row = within(list).getByText("server/lib/git-ops.js");
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
await right();
await user.click(
within(screen.getByTestId("lane-changes-file-menu")).getByText("Stage this file")
);
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
paths: ["server/lib/git-ops.js"],
});
vi.mocked(api.lanes.gitAction).mockClear();
await right();
await user.click(screen.getByTestId("lane-changes-menu-discard"));
expect(api.lanes.gitAction).not.toHaveBeenCalled();
expect(screen.getByTestId("lane-changes-menu-discard")).toHaveTextContent("Click again");
await user.click(screen.getByTestId("lane-changes-menu-discard"));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
paths: ["server/lib/git-ops.js"],
confirm: true,
});
await right();
await user.click(
within(screen.getByTestId("lane-changes-file-menu")).getByText("Copy relative path")
);
expect(await navigator.clipboard.readText()).toBe("server/lib/git-ops.js");
});
it("disarms a pending discard when the lane changes, so one click cannot discard in another lane", async () => {
const user = userEvent.setup();
const { rerender } = render(<LaneChangesPanel laneId={7} />);
@@ -439,6 +439,43 @@ describe("WorkspaceHome", () => {
expect(within(dialog).getByText("Rules here")).toBeInTheDocument();
expect(within(dialog).queryByRole("textbox")).toBeNull();
});
it("shows one memory file per tab", async () => {
vi.mocked(api.lanes.memory).mockResolvedValueOnce({
files: [
{
label: "CLAUDE.md",
path: "/r/CLAUDE.md",
content: "# Rules here",
size: 0,
mtime: "",
truncated: false,
},
{
label: "memory/a.md",
path: "/m/a.md",
content: "# Note A",
size: 0,
mtime: "",
truncated: false,
},
],
});
const user = userEvent.setup();
renderHome();
expect(await screen.findByText("2 files the agent reads")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "View memory" }));
const dialog = screen.getByRole("dialog", { name: "Memory" });
expect(within(dialog).getByRole("tab", { name: "CLAUDE.md" })).toHaveAttribute(
"aria-selected",
"true"
);
expect(within(dialog).getByText("Rules here")).toBeInTheDocument();
expect(within(dialog).queryByText("Note A")).toBeNull();
await user.click(within(dialog).getByRole("tab", { name: "memory/a.md" }));
expect(within(dialog).getByText("Note A")).toBeInTheDocument();
expect(within(dialog).queryByText("Rules here")).toBeNull();
});
});
describe("WorkspaceHome across workspaces", () => {