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
@@ -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>
);
}