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
+141 -57
View File
@@ -9,8 +9,9 @@
* header: drag it to move the pane (drop on another pane's edge to split
* there, on its middle to swap), pick another live session for it, split it
* right or down, or close the pane (its session keeps running as a tab).
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). Dividers
* resize by pointer or by arrow keys.
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
* right-click on a header offers the swap, a split with a new terminal and
* the close as one menu. Dividers resize by pointer or by arrow keys.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -27,6 +28,7 @@ import {
X,
} from "lucide-react";
import type { RunHandle } from "../../lib/api";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import {
EMPTY_LAYOUT,
type Edge,
@@ -171,6 +173,90 @@ function PaneMenu({
const ITEM =
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
/** A pane's split-right / split-down menus: move a hidden run beside
* `slot`, or start a new terminal there. In a pane header when the view is
* split, beside Tools in the tab bar while one pane is shown. */
export function SplitMenus({
slot,
shown,
runs,
label,
onChange,
canStart,
onNewSplit,
}: {
slot: number;
/** The slots on screen. */
shown: number[];
runs: RunHandle[];
label: (r: RunHandle) => string;
onChange: LayoutUpdate;
canStart: boolean;
onNewSplit: (target: number, edge: Edge) => void;
}) {
const { t } = useTranslation("lanes");
const full = shown.length >= MAX_PANES;
return (
<>
{(["right", "bottom"] as const).map((edge) => (
<PaneMenu
key={edge}
testId={`pane-split-${edge}-${slot}`}
label={full ? t("panes.full") : t(`panes.split.${edge}`)}
disabled={full}
icon={
edge === "right" ? (
<Columns2 className="h-3.5 w-3.5" />
) : (
<Rows2 className="h-3.5 w-3.5" />
)
}
>
{(close) => (
<>
{runs
.filter((x) => !shown.includes(x.slot ?? 0))
.map((x) => (
<button
key={x.id}
type="button"
role="menuitem"
className={ITEM}
onClick={() => {
close();
onChange((l) => moveInLayout(l, x.slot ?? 0, slot, edge));
}}
>
<RunIcon run={x} label={label(x)} />
<span className="min-w-0 truncate">{label(x)}</span>
</button>
))}
{runs.some((x) => !shown.includes(x.slot ?? 0)) && (
<div className="my-1.5 border-t border-border" />
)}
<button
type="button"
role="menuitem"
data-testid={`pane-split-new-${edge}-${slot}`}
disabled={!canStart}
title={canStart ? undefined : t("tabs.full")}
className={`${ITEM} disabled:opacity-40 disabled:hover:bg-transparent`}
onClick={() => {
close();
onNewSplit(slot, edge);
}}
>
<Plus className="h-4 w-4" />
{t("panes.newTerminal")}
</button>
</>
)}
</PaneMenu>
))}
</>
);
}
export function PaneGrid({
layout,
mounted,
@@ -203,6 +289,13 @@ export function PaneGrid({
const multi = panes.length > 1;
const full = shown.length >= MAX_PANES;
const runOf = (slot: number) => runs.find((r) => (r.slot ?? 0) === slot);
const paneCtx = useContextMenu<number>();
// A header-menu item: close the menu, then act on the pane it was opened on.
const pickPane = (fn: (slot: number) => void) => () => {
const slot = paneCtx.menu?.item;
paneCtx.close();
if (slot !== undefined) fn(slot);
};
useEffect(() => {
const end = () => {
@@ -310,6 +403,7 @@ export function PaneGrid({
data-testid={`pane-header-${slot}`}
draggable
onDragStart={(e) => startPaneDrag(e, slot)}
onContextMenu={(e) => paneCtx.open(e, slot)}
title={t("panes.drag")}
className={`relative flex h-8 shrink-0 cursor-grab items-center gap-1 border-b border-border bg-surface-1 pl-1.5 pr-1 text-xs ${
focused ? "text-fg-primary" : "text-fg-muted"
@@ -349,61 +443,15 @@ export function PaneGrid({
}
</PaneMenu>
<div className="ml-auto flex items-center">
{(["right", "bottom"] as const).map((edge) => (
<PaneMenu
key={edge}
testId={`pane-split-${edge}-${slot}`}
label={full ? t("panes.full") : t(`panes.split.${edge}`)}
disabled={full}
icon={
edge === "right" ? (
<Columns2 className="h-3.5 w-3.5" />
) : (
<Rows2 className="h-3.5 w-3.5" />
)
}
>
{(close) => (
<>
{runs
.filter((x) => !shown.includes(x.slot ?? 0))
.map((x) => (
<button
key={x.id}
type="button"
role="menuitem"
className={ITEM}
onClick={() => {
close();
onChange((l) => moveInLayout(l, x.slot ?? 0, slot, edge));
}}
>
<RunIcon run={x} label={label(x)} />
<span className="min-w-0 truncate">{label(x)}</span>
</button>
))}
{runs.some((x) => !shown.includes(x.slot ?? 0)) && (
<div className="my-1.5 border-t border-border" />
)}
<button
type="button"
role="menuitem"
data-testid={`pane-split-new-${edge}-${slot}`}
disabled={!canStart}
title={canStart ? undefined : t("tabs.full")}
className={`${ITEM} disabled:opacity-40 disabled:hover:bg-transparent`}
onClick={() => {
close();
onNewSplit(slot, edge);
}}
>
<Plus className="h-4 w-4" />
{t("panes.newTerminal")}
</button>
</>
)}
</PaneMenu>
))}
<SplitMenus
slot={slot}
shown={shown}
runs={runs}
label={label}
onChange={onChange}
canStart={canStart}
onNewSplit={onNewSplit}
/>
<button
type="button"
data-testid={`pane-close-${slot}`}
@@ -423,6 +471,42 @@ export function PaneGrid({
</div>
);
})}
{paneCtx.menu && (
<ContextMenu
x={paneCtx.menu.x}
y={paneCtx.menu.y}
onClose={paneCtx.close}
testId={`pane-menu-${paneCtx.menu.item}`}
className="w-64"
>
{runs
.filter((x) => (x.slot ?? 0) !== paneCtx.menu?.item)
.map((x) => (
<MenuItem
key={x.id}
onSelect={pickPane((slot) => onChange((l) => swapInLayout(l, x.slot ?? 0, slot)))}
>
<RunIcon run={x} label={label(x)} />
<span className="min-w-0 truncate">{t("panes.show", { name: label(x) })}</span>
</MenuItem>
))}
{runs.length > 1 && <MenuSeparator />}
{(["right", "bottom"] as const).map((edge) => (
<MenuItem
key={edge}
testId={`pane-menu-split-${edge}`}
disabled={full || !canStart}
onSelect={pickPane((slot) => onNewSplit(slot, edge))}
>
{t(`panes.splitNew.${edge}`)}
</MenuItem>
))}
<MenuSeparator />
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
{t("panes.closeShort")}
</MenuItem>
</ContextMenu>
)}
{dividers.map((d) => (
<Divider
key={d.path}