feat: workspace aliases, tab groups of split panes, native path picker (v2.1.0)
- /api/workspaces and `agenthub workspace(s)` / `--workspace` as aliases of /api/lanes and `agenthub lanes` / `--lane`; old names print one stderr note. REPL `help lanes|workspace|workspaces` resolves through the aliases. - A workspace holds up to ten sessions (slots 0-9); every console tab is a group of 1-4 split panes (PaneGroups, agenthub.workspace.paneLayout); parseGroups keeps the stored active tab when an earlier one is dropped. - Browse button beside path fields: POST /api/lanes/pick-path opens the OS picker on the server's desktop — loopback only and no forwarding header (EREMOTE), a display failure is ENODIALOG not a Cancel, the dialog is killed when the request closes, a folder field falls back to the in-app browser on any failure. - Changes rail counts chip, image viewer (/git/raw), status bar session counts and per-day cost (`date=` on /api/pricing/cost). - /agenthub-open and /agenthub-doctor run from the INSTALLED build (supersededBy), doctor offers fixes behind two confirmations; plugin-open treats a locked bootstrap as non-fatal. - New-terminal shell waits for the first attach (issue #55). - repo-onboarding / debug-live-issue skills removed. - OpenAPI: pick-path, lane/date on /pricing/cost, tool on /run/history, /api/workspaces mount.
This commit is contained in:
@@ -2,25 +2,28 @@
|
||||
* @file StatusBar — the one-line bar along the bottom of the app shell, on
|
||||
* every page: the live-connection state (click: the connection details modal,
|
||||
* which the sidebar owns and which also carries the update check), the version
|
||||
* — 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 — and, on the
|
||||
* — with a green dot while a newer release is out —, how many SESSIONS are
|
||||
* running and how many need the user (`GET /api/sessions?status=active` and
|
||||
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
|
||||
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — 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
|
||||
* whenever the URL names another workspace or session. Each figure carries its
|
||||
* token total, and hovering it lists tokens and cost per model. 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 { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import type { CostResult } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName } from "../lib/format";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
||||
|
||||
const COST_POLL_MS = 30_000;
|
||||
@@ -30,6 +33,37 @@ function localDate(d = new Date()): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
const usd = (n: number) => `$${n.toFixed(2)}`;
|
||||
|
||||
/** Tokens and cost per model (the breakdown's speed/geo/tier buckets folded),
|
||||
* costliest first: the hover text of a cost figure. */
|
||||
function perModel(r: CostResult): { tokens: number; lines: string } {
|
||||
const byModel = new Map<string, { tokens: number; cost: number }>();
|
||||
for (const b of r.breakdown) {
|
||||
const m = byModel.get(b.model) ?? { tokens: 0, cost: 0 };
|
||||
m.tokens += b.input_tokens + b.output_tokens + b.cache_read_tokens + b.cache_write_tokens;
|
||||
m.cost += b.cost;
|
||||
byModel.set(b.model, m);
|
||||
}
|
||||
const rows = [...byModel].sort((a, b) => b[1].cost - a[1].cost);
|
||||
return {
|
||||
tokens: rows.reduce((n, [, m]) => n + m.tokens, 0),
|
||||
lines: rows
|
||||
.map(([model, m]) => `${formatModelName(model)}: ${fmt(m.tokens)} · ${fmtCostFull(m.cost)}`)
|
||||
.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function CostFigure({ testId, label, r }: { testId: string; label: string; r: CostResult }) {
|
||||
const { t } = useTranslation("nav");
|
||||
const { tokens, lines } = perModel(r);
|
||||
return (
|
||||
<span data-testid={testId} tabIndex={0} title={lines || undefined} className="tabular-nums">
|
||||
{label} {t("statusBar.tokens", { tokens: fmt(tokens) })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBar({
|
||||
wsConnected,
|
||||
left,
|
||||
@@ -44,12 +78,12 @@ export function StatusBar({
|
||||
updateAvailable?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("nav");
|
||||
const { lanes } = useLanes();
|
||||
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 [active, setActive] = useState(0);
|
||||
const [needYou, setNeedYou] = useState(0);
|
||||
const running = Math.max(active - needYou, 0);
|
||||
const [today, setToday] = useState<CostResult | null>(null);
|
||||
const [laneCost, setLaneCost] = useState<CostResult | null>(null);
|
||||
const [sessionCost, setSessionCost] = useState<CostResult | null>(null);
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
const location = useLocation();
|
||||
const shown = selectedFrom(location.pathname, location.search);
|
||||
@@ -63,6 +97,22 @@ export function StatusBar({
|
||||
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
|
||||
}, []);
|
||||
|
||||
const loadCounts = useCallback(() => {
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 1 })
|
||||
.then((r) => setActive(r.total))
|
||||
.catch(() => undefined);
|
||||
api.sessions
|
||||
.list({ awaiting: true, limit: 1 })
|
||||
.then((r) => setNeedYou(r.total))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
useEffect(loadCounts, [loadCounts]);
|
||||
useLiveRefresh(loadCounts, {
|
||||
intervalMs: 15_000,
|
||||
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
// Another workspace or session: never show the previous one's figure.
|
||||
@@ -71,19 +121,19 @@ export function StatusBar({
|
||||
const tick = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
api.pricing
|
||||
.totalCost()
|
||||
.then((r) => live && setToday(r.daily_costs.find((d) => d.date === localDate())?.cost ?? 0))
|
||||
.totalCost({ date: localDate() })
|
||||
.then((r) => live && setToday(r))
|
||||
.catch(() => undefined);
|
||||
if (lane !== null) {
|
||||
api.pricing
|
||||
.totalCost({ lane })
|
||||
.then((r) => live && setLaneCost(r.total_cost))
|
||||
.then((r) => live && setLaneCost(r))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if (sessionId) {
|
||||
api.pricing
|
||||
.sessionCost(sessionId)
|
||||
.then((r) => live && setSessionCost(r.total_cost))
|
||||
.then((r) => live && setSessionCost(r))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
@@ -96,7 +146,6 @@ export function StatusBar({
|
||||
document.removeEventListener("visibilitychange", tick);
|
||||
};
|
||||
}, [lane, sessionId]);
|
||||
const usd = (n: number) => `$${n.toFixed(2)}`;
|
||||
|
||||
return (
|
||||
<footer
|
||||
@@ -144,19 +193,25 @@ export function StatusBar({
|
||||
</span>
|
||||
</span>
|
||||
{today !== null && (
|
||||
<span data-testid="status-bar-cost" className="tabular-nums">
|
||||
{t("statusBar.today", { cost: usd(today) })}
|
||||
</span>
|
||||
<CostFigure
|
||||
testId="status-bar-cost"
|
||||
label={t("statusBar.today", { cost: usd(today.total_cost) })}
|
||||
r={today}
|
||||
/>
|
||||
)}
|
||||
{lane !== null && laneCost !== null && (
|
||||
<span data-testid="status-bar-workspace-cost" className="tabular-nums">
|
||||
{t("statusBar.workspace", { cost: usd(laneCost) })}
|
||||
</span>
|
||||
<CostFigure
|
||||
testId="status-bar-workspace-cost"
|
||||
label={t("statusBar.workspace", { cost: usd(laneCost.total_cost) })}
|
||||
r={laneCost}
|
||||
/>
|
||||
)}
|
||||
{sessionId && sessionCost !== null && (
|
||||
<span data-testid="status-bar-session-cost" className="tabular-nums">
|
||||
{t("statusBar.session", { cost: usd(sessionCost) })}
|
||||
</span>
|
||||
<CostFigure
|
||||
testId="status-bar-session-cost"
|
||||
label={t("statusBar.session", { cost: usd(sessionCost.total_cost) })}
|
||||
r={sessionCost}
|
||||
/>
|
||||
)}
|
||||
</footer>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user