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:
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user