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