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
@@ -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.