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
+89 -11
View File
@@ -41,6 +41,7 @@ import {
type CodexSandbox,
type EffortLevel,
type LaneMemoryFile,
type LaneSource,
type LaneSourcesView,
type ModelChoice,
type PermissionMode,
@@ -57,6 +58,8 @@ import { Select } from "../Select";
import { MarkdownPreview } from "../MarkdownPreview";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
import { workspaceName } from "./WorkspaceRow";
import { SessionMenu } from "./SessionMenu";
import { ContextMenu, MenuItem, MenuSeparator, menuPoint, useContextMenu } from "../ContextMenu";
import {
liveRunFor,
SessionFold,
@@ -162,12 +165,15 @@ function HomeBody({
const [editing, setEditing] = useState<string | null>(null);
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
const [memoryOpen, setMemoryOpen] = useState(false);
const [memoryTab, setMemoryTab] = useState(0);
const [folderOpen, setFolderOpen] = useState(false);
const [folderPath, setFolderPath] = useState("");
const [suggestions, setSuggestions] = useState<string[]>([]);
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
const [title, setTitle] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const recentCtx = useContextMenu<Session>();
const sourceCtx = useContextMenu<LaneSource>();
// A new pane, a pane gone, a session id learned — all move these lane fields.
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
@@ -342,6 +348,7 @@ function HomeBody({
{title === null ? (
<h1
title={lane.cwd}
onContextMenu={(e) => setMenuAt(menuPoint(e))}
className="min-w-0 flex-1 truncate text-2xl font-semibold text-fg-primary"
>
{name}
@@ -582,6 +589,7 @@ function HomeBody({
data-testid={`workspace-recent-${s.id}`}
data-live={live ? "true" : undefined}
onClick={() => openSession(s)}
onContextMenu={(e) => recentCtx.open(e, s)}
className="flex w-full items-center gap-3 py-2.5 text-left hover:bg-surface-3"
>
<span className={`h-2 w-2 shrink-0 rounded-full ${sessionDot(s, live)}`} />
@@ -619,6 +627,7 @@ function HomeBody({
actionIcon={<ArchiveRestore className="h-3.5 w-3.5" />}
onAction={(s) => api.sessions.patch(s.id, { archived: false })}
onDone={() => setVersion((v) => v + 1)}
laneId={lane.id}
/>
</div>
</div>
@@ -690,7 +699,10 @@ function HomeBody({
<h3 className="text-sm font-medium text-fg-primary">{t("home.memory")}</h3>
<button
type="button"
onClick={() => setMemoryOpen(true)}
onClick={() => {
setMemoryTab(0);
setMemoryOpen(true);
}}
disabled={!memory?.length}
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
>
@@ -743,7 +755,11 @@ function HomeBody({
) : (
<ul className="mt-2 space-y-1">
{files.map((f) => (
<li key={f.id} className="group flex items-center gap-2 text-xs">
<li
key={f.id}
className="group flex items-center gap-2 text-xs"
onContextMenu={(e) => sourceCtx.open(e, f)}
>
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
@@ -790,7 +806,11 @@ function HomeBody({
</span>
</li>
{folders.map((f) => (
<li key={f.id} className="group flex items-center gap-2 text-xs">
<li
key={f.id}
className="group flex items-center gap-2 text-xs"
onContextMenu={(e) => sourceCtx.open(e, f)}
>
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
{f.name}
@@ -872,7 +892,7 @@ function HomeBody({
aria-label={t("home.memory")}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.key === "Escape" && setMemoryOpen(false)}
className="max-h-[85vh] w-full max-w-3xl overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
@@ -886,18 +906,76 @@ function HomeBody({
<X className="h-4 w-4" />
</button>
</div>
{memory.map((f) => (
<section key={f.path} className="mb-5">
<p title={f.path} className="mb-1 font-mono text-xs text-fg-muted">
<div
role="tablist"
aria-label={t("home.memory")}
className="mb-3 flex gap-1.5 overflow-x-auto border-b border-border/60 pb-2 no-scrollbar"
>
{memory.map((f, i) => (
<button
key={f.path}
type="button"
role="tab"
title={f.path}
aria-selected={i === memoryTab}
onClick={() => setMemoryTab(i)}
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
i === memoryTab
? "border-accent/30 bg-accent/15 text-accent"
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{f.label}
</p>
<MarkdownPreview text={f.content} />
</section>
))}
</button>
))}
</div>
{memory[memoryTab] && (
<div role="tabpanel" className="min-h-0 flex-1 overflow-y-auto">
<MarkdownPreview text={memory[memoryTab].content} />
</div>
)}
</div>
</div>
)}
{recentCtx.menu && (
<SessionMenu
x={recentCtx.menu.x}
y={recentCtx.menu.y}
laneId={lane.id}
session={recentCtx.menu.item}
run={liveRunFor(lane, liveRuns, recentCtx.menu.item.id)}
onClose={recentCtx.close}
/>
)}
{sourceCtx.menu && (
<ContextMenu
x={sourceCtx.menu.x}
y={sourceCtx.menu.y}
onClose={sourceCtx.close}
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
>
<MenuItem
onSelect={() => {
void navigator.clipboard.writeText(sourceCtx.menu!.item.path);
sourceCtx.close();
}}
>
{t("menu.copyPath")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
onSelect={() => {
const id = sourceCtx.menu!.item.id;
sourceCtx.close();
void run(() => api.lanes.removeSource(lane.id, id));
}}
>
{t("home.remove")}
</MenuItem>
</ContextMenu>
)}
{menuAt && (
<WorkspaceRowMenu
lane={lane}