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
+54 -6
View File
@@ -5,17 +5,23 @@
* — with a green dot while a newer release is out —, how many workspaces are running and how
* many need the user (from the shared lane list, no request of its own), and
* today's spend — the `daily_costs` bucket for the local date from
* `GET /api/pricing/cost`, the Dashboard's own cost call, re-read every 30 s
* while the tab is visible. No provider quota: reading Claude's quota rotates
* `GET /api/pricing/cost`, the Dashboard's own cost call — and, on the
* Workspace page, the open workspace's total (`?lane=` on the same call: the
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
* the URL's `session`, or the focused pane's session from the page's run poll,
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
* whenever the URL names another workspace or session. No provider quota: reading Claude's quota rotates
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { Wifi, WifiOff } from "lucide-react";
import { api } from "../lib/api";
import { api, type RunHandle } from "../lib/api";
import { useLanes } from "../hooks/useLanes";
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
const COST_POLL_MS = 30_000;
@@ -42,23 +48,55 @@ export function StatusBar({
const running = lanes.filter((l) => l.status === "running").length;
const needYou = lanes.filter((l) => l.needs_action).length;
const [today, setToday] = useState<number | null>(null);
const [laneCost, setLaneCost] = useState<number | null>(null);
const [sessionCost, setSessionCost] = useState<number | null>(null);
const [runs, setRuns] = useState<RunHandle[]>([]);
const location = useLocation();
const shown = selectedFrom(location.pathname, location.search);
const lane = shown.lane;
const sessionId =
shown.session ?? (shown.run && runs.find((r) => r.id === shown.run)?.sessionId) ?? null;
useEffect(() => {
const take = (e: Event) => setRuns((e as CustomEvent<RunHandle[]>).detail);
window.addEventListener(LIVE_RUNS_EVENT, take);
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
}, []);
useEffect(() => {
let live = true;
// Another workspace or session: never show the previous one's figure.
setLaneCost(null);
setSessionCost(null);
const tick = () => {
if (document.visibilityState !== "visible") return;
api.pricing
.totalCost()
.then((r) => setToday(r.daily_costs.find((d) => d.date === localDate())?.cost ?? 0))
.then((r) => live && setToday(r.daily_costs.find((d) => d.date === localDate())?.cost ?? 0))
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
.then((r) => live && setLaneCost(r.total_cost))
.catch(() => undefined);
}
if (sessionId) {
api.pricing
.sessionCost(sessionId)
.then((r) => live && setSessionCost(r.total_cost))
.catch(() => undefined);
}
};
tick();
const id = setInterval(tick, COST_POLL_MS);
document.addEventListener("visibilitychange", tick);
return () => {
live = false;
clearInterval(id);
document.removeEventListener("visibilitychange", tick);
};
}, []);
}, [lane, sessionId]);
const usd = (n: number) => `$${n.toFixed(2)}`;
return (
<footer
@@ -94,7 +132,17 @@ export function StatusBar({
</span>
{today !== null && (
<span data-testid="status-bar-cost" className="tabular-nums">
{t("statusBar.today", { cost: `$${today.toFixed(2)}` })}
{t("statusBar.today", { cost: usd(today) })}
</span>
)}
{lane !== null && laneCost !== null && (
<span data-testid="status-bar-workspace-cost" className="tabular-nums">
{t("statusBar.workspace", { cost: usd(laneCost) })}
</span>
)}
{sessionId && sessionCost !== null && (
<span data-testid="status-bar-session-cost" className="tabular-nums">
{t("statusBar.session", { cost: usd(sessionCost) })}
</span>
)}
</footer>