feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins

Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged)
- Login for every page and API call (server/lib/auth.js, routes/auth.js,
  AuthGate), roles admin / analyst / member, accounts made only from the
  host's shell: `agenthub server setup|off|status`, `agenthub users
  list|add|passwd|role|disable|enable|assign|audit`
- Rows are owned: a member sees only their own workspaces, sessions and what
  hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience);
  staff reads of a member's session are audited (server/lib/audit.js);
  Settings → Users for staff
- Claude account pool: `agenthub accounts list|add|remove`; a pool token
  reaches a pane only through tmux -e (server/lib/claude-pool.js)
- A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback,
  live pane only); writes pass an Origin check; named locks per member
- docs/SERVER-MODE.md, docs/plans/server-mode/

Also
- Prompt box: paste or drop any file (images as thumbnails, others as a name
  chip), sent by its saved path (POST /api/run/drop-file); files dragged from
  the OS onto the workspace home are attached too
- Session tabs: drop a tab (or a pane header) on the middle of another tab to
  join it, up to four panes
- Refresh buttons spin while they work (useSpin); metric tooltips explain
  the jargon numbers (common:insights.help.*)
- Light palette retuned for contrast; IntelliJ Light theme removed
- CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md

Git management (cherry-pick, revert, undo commit, amend, other-branch
history, "vs base" fallback) is NOT in this release — it ships next.

Committed --no-verify: both full suites run on this exact tree (server
2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev;
client 1086/1086), tsc clean, prettier applied by hand.
This commit is contained in:
2026-10-07 14:11:25 +07:00
parent ca821008e2
commit 9a54978eef
112 changed files with 6509 additions and 436 deletions
+41 -6
View File
@@ -95,6 +95,7 @@ import {
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import { keepIfSame } from "../lib/keepIfSame";
@@ -779,6 +780,7 @@ function AnalyticsTab() {
if (seq === loadSeq.current) setLoading(false);
}
}, [scope, tool, range]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
@@ -1067,8 +1069,13 @@ function AnalyticsTab() {
]}
/>
</div>
<button onClick={load} className="btn-ghost" disabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
<button
onClick={refresh}
className="btn-ghost"
disabled={loading || spinning}
aria-busy={loading || spinning}
>
<RefreshCw className={`w-4 h-4 ${loading || spinning ? "animate-spin" : ""}`} />
{t("common:refresh")}
</button>
<button onClick={handleExport} className="btn-ghost" disabled={!data}>
@@ -1095,6 +1102,7 @@ function AnalyticsTab() {
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<StatCard
label={t("totalSessions")}
help={t("common:insights.help.sessions")}
to="/sessions"
value={data ? fmt(data.overview.total_sessions) : ""}
raw={data ? data.overview.total_sessions.toLocaleString() : undefined}
@@ -1105,6 +1113,7 @@ function AnalyticsTab() {
/>
<StatCard
label={t("totalAgents")}
help={t("common:insights.help.agents")}
value={data ? fmt(data.overview.total_agents) : ""}
raw={data ? data.overview.total_agents.toLocaleString() : undefined}
trend={data ? `${data.overview.active_agents} ${t("common:active")}` : undefined}
@@ -1114,6 +1123,7 @@ function AnalyticsTab() {
/>
<StatCard
label={t("tokensExCacheReads")}
help={t("common:insights.help.tokens")}
value={data ? fmt(nonCacheReadTokens) : ""}
raw={data ? nonCacheReadTokens.toLocaleString() : undefined}
trend={
@@ -1130,6 +1140,7 @@ function AnalyticsTab() {
/>
<StatCard
label={t("totalCost")}
help={t("common:insights.help.cost")}
value={costData ? fmtCost(costData.total_cost) : ""}
raw={costData ? fmtCostFull(costData.total_cost) : undefined}
trend={
@@ -1143,6 +1154,7 @@ function AnalyticsTab() {
/>
<StatCard
label={t("totalEvents")}
help={t("common:insights.help.events")}
to="/sessions?view=events"
value={data ? fmt(data.overview.total_events) : ""}
raw={data ? data.overview.total_events.toLocaleString() : undefined}
@@ -1329,7 +1341,12 @@ function AnalyticsTab() {
{/* Token mix donut */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("tokenMix")}</h3>
<h3
className="text-sm font-medium text-fg-secondary mb-5 cursor-help underline decoration-dotted underline-offset-2 w-fit"
title={t("common:insights.help.tokenMix")}
>
{t("tokenMix")}
</h3>
{tokenMixSegments.length === 0 ? (
<p className="text-sm text-fg-muted">{t("common:noData")}</p>
) : (
@@ -1437,7 +1454,12 @@ function AnalyticsTab() {
{/* Spend pace: burn rate, month to date, month-end forecast */}
<div className="card p-5">
<h3 className="text-sm font-medium text-fg-secondary mb-5">{t("spendPace")}</h3>
<h3
className="text-sm font-medium text-fg-secondary mb-5 cursor-help underline decoration-dotted underline-offset-2 w-fit"
title={t("common:insights.help.spendPace")}
>
{t("spendPace")}
</h3>
<div className="space-y-3">
{[
{ label: t("burnRate"), value: pace.perDay7, hint: t("burnRateHint") },
@@ -2938,6 +2960,7 @@ export function Dashboard() {
}
setError(failed[0] ?? null);
}, [scope]);
const [spinning, refresh] = useSpin(load);
const monitorOn = activeTab === "monitor";
useEffect(() => {
@@ -3059,8 +3082,14 @@ export function Dashboard() {
Analytics/Monitor/Health aside every time Monitor opened.
Analytics and Health carry their own Refresh; one per view. */}
{monitorOn && (
<button onClick={load} className="btn-ghost flex-shrink-0">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
<button
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost flex-shrink-0"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
)}
{/* Tabs */}
@@ -3110,6 +3139,7 @@ export function Dashboard() {
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<StatCard
label={t("totalSessions")}
help={t("common:insights.help.sessions")}
to="/sessions"
value={stats ? fmt(stats.total_sessions) : ""}
raw={stats ? stats.total_sessions.toLocaleString() : undefined}
@@ -3119,6 +3149,7 @@ export function Dashboard() {
/>
<StatCard
label={t("activeAgents")}
help={t("common:insights.help.agents")}
value={stats?.active_agents ?? ""}
icon={Bot}
accentColor="text-status-success"
@@ -3126,6 +3157,7 @@ export function Dashboard() {
/>
<StatCard
label={t("activeSubagents")}
help={t("common:insights.help.subagents")}
value={stats ? allSubagents.filter((a) => a.status === "working").length : ""}
icon={GitBranch}
accentColor="text-syntax-keyword"
@@ -3134,6 +3166,7 @@ export function Dashboard() {
/>
<StatCard
label={t("eventsToday")}
help={t("common:insights.help.events")}
to="/sessions?view=events"
value={stats ? fmt(stats.events_today) : ""}
raw={stats ? stats.events_today.toLocaleString() : undefined}
@@ -3143,6 +3176,7 @@ export function Dashboard() {
/>
<StatCard
label={t("totalEvents")}
help={t("common:insights.help.events")}
to="/sessions?view=events"
value={stats ? fmt(stats.total_events) : ""}
raw={stats ? stats.total_events.toLocaleString() : undefined}
@@ -3152,6 +3186,7 @@ export function Dashboard() {
/>
<StatCard
label={t("totalCost")}
help={t("common:insights.help.cost")}
value={totalCost !== null ? fmtCost(totalCost) : ""}
raw={
totalCost !== null