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,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)}
/>
)}
</>
);
}