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