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
+25 -9
View File
@@ -1367,6 +1367,14 @@ router.post("/event", (req, res) => {
return res.json({ ok: true, duplicate: true });
}
// Parse the transcript BEFORE the write transaction: `processEvent` takes the
// write lock at its first statement and reads the transcript inside it, so a
// cold multi-MB parse held the lock and the other dashboard on this DB hit
// SQLITE_BUSY. Warmed here, the read inside is a stat-check cache hit.
if (tool === "claude" && typeof data.transcript_path === "string" && data.transcript_path) {
transcriptCache.extract(data.transcript_path);
}
const result = processEvent(hook_type, data, tool);
if (!result) {
return res.status(400).json({
@@ -1482,6 +1490,12 @@ router.post("/event", (req, res) => {
// sessions every 15s to detect errors that hooks missed.
const WATCHDOG_INTERVAL_MS = 15_000;
const STALE_THRESHOLD_MS = 10_000; // only check sessions idle for >10s
// The watchdog's fallback for a session with no transcript_path column value.
const lastTranscriptEvent = db.prepare(
`SELECT data FROM events WHERE session_id = ?
AND event_type IN ('SessionStart','UserPromptSubmit','PreToolUse','Stop','Notification')
ORDER BY created_at DESC LIMIT 1`
);
// Idle-working timeout for the no-marker interrupt case (Esc pressed before the
// model emits anything → Claude Code writes no interrupt entry and fires no
@@ -1541,11 +1555,11 @@ function watchdogCheck() {
// lifecycle is owned by server/lib/codex-usage.js.
const staleSessions = db
.prepare(
`SELECT s.id, s.status, s.cwd,
(SELECT MAX(e.created_at) FROM events e WHERE e.session_id = s.id) as last_event,
(SELECT e.data FROM events e WHERE e.session_id = s.id
AND e.event_type IN ('SessionStart','UserPromptSubmit','PreToolUse','Stop','Notification')
ORDER BY e.created_at DESC LIMIT 1) as last_data
// transcript_path is the session's own column (every hook keeps it);
// reading it out of the newest event's `data` walked a multi-KB payload
// per session, every 15 s, in every dashboard on this database.
`SELECT s.id, s.status, s.cwd, s.transcript_path,
(SELECT MAX(e.created_at) FROM events e WHERE e.session_id = s.id) as last_event
FROM sessions s
WHERE s.status IN ('active', 'error') AND s.updated_at < ?
AND (s.source = 'local' OR s.source IS NULL) AND s.tool = 'claude'`
@@ -1553,11 +1567,13 @@ function watchdogCheck() {
.all(cutoff);
for (const sess of staleSessions) {
// Try to get transcript_path from event data first
let tPath = null;
if (sess.last_data) {
// The session's recorded transcript first, else the newest hook payload
// that named one (a row from before the column was filled).
let tPath = sess.transcript_path || null;
if (!tPath) {
const lastData = lastTranscriptEvent.get(sess.id)?.data;
try {
tPath = JSON.parse(sess.last_data).transcript_path;
tPath = lastData ? JSON.parse(lastData).transcript_path : null;
} catch {}
}
// Fall back: derive from Claude's standard path layout