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
+9 -3
View File
@@ -54,11 +54,11 @@ The client is a single-page application (SPA) built with modern web technologies
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
Other rules that fall out of this:
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it three steps down, `#924224` (it was `#a34a28`, which missed 4.5:1 as text on its own selected chip).
- **The light Default is IntelliJ Light's palette** (`#eceef2` → `#ffffff` neutrals, blue accent, IntelliJ's editor syntax colours); the separate IntelliJ Light theme was folded into it.
- Light `--accent` is **not** the dark one: dark's `#0090ff` is 3.3:1 on white. Light mode uses IntelliJ's `#285ac0`, dark enough to clear 4.5:1 as text and under white.
- **White text sits on `accent-solid`, never on `accent`.** `--accent` is a TEXT colour; in the dark themes it is the light step (Default `#70b8ff`, Orca Dark `#6aa0f8`) that reads on a near-black card and is far too light to carry white. Every theme also defines `--accent-solid`/`--accent-solid-hover` (Tailwind `bg-accent-solid`) for the fill behind white text — `.btn-primary`, a Switch's on state, a Checkbox tick — darkened until white clears 4.5:1 (Default `#0076d1`). In the light themes the text accent is already dark enough, so `--accent-solid` is simply `var(--accent)`.
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
@@ -793,6 +793,12 @@ in the file is shown as text, never run (no `rehype-raw`), links open in a new
tab, and a leading YAML frontmatter block is shown as a code block. Styles are
the `.md-preview` rules in `index.css`, built from theme tokens.
#### Metric tooltips (`common:insights.help.*`)
The dotted-underline tooltips on jargon metrics (`StatCard`'s and
`SessionOverview`'s `help` prop) are worded in `common:insights.help.*`, `en` +
`vi`.
---
## Utilities
@@ -92,6 +92,7 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextM
import { fmtCost, timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import { useAiTools } from "../hooks/useAiTools";
import { useSpin } from "../hooks/useSpin";
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
const PAGE_SIZE = 25;
@@ -342,6 +343,7 @@ export function AlertsNotifications() {
setLoadingAlerts(false);
}
}, [unackedOnly, t]);
const [spinning, refreshAlerts] = useSpin(loadAlerts);
const loadMore = useCallback(async () => {
try {
@@ -1017,11 +1019,13 @@ export function AlertsNotifications() {
label={t("feed.unackedOnly")}
/>
<button
onClick={() => loadAlerts()}
onClick={refreshAlerts}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs"
title={t("refresh")}
>
<RefreshCw className="w-3.5 h-3.5" />
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />
</button>
{unacked > 0 && (
<button
+163
View File
@@ -0,0 +1,163 @@
/**
* @file AuthGate.tsx
* @description Server mode's login gate around the whole app (docs/SERVER-MODE.md).
* Asks `GET /api/auth/me` (again every 2 s while it gets no answer): in local
* mode — the default — it renders the app and nothing else changes, at once
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
* someone logs in, and again whenever a request answers 401 `EAUTH`
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
* previous user's data. `useAuth()` gives the sidebar the user and `logout`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
createContext,
useContext,
useEffect,
useState,
type FormEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
type AuthValue = { state: AuthState; logout: () => Promise<void> };
const AuthContext = createContext<AuthValue>({ state: { mode: "local" }, logout: async () => {} });
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
export const useAuth = () => useContext(AuthContext);
/** The mode this browser last saw, so a local dashboard paints at once. */
const MODE_KEY = "agenthub.authMode";
const RETRY_MS = 2000;
function cachedState(): AuthState | null {
try {
return localStorage.getItem(MODE_KEY) === "local" ? { mode: "local" } : null;
} catch {
return null;
}
}
export function AuthGate({ children }: { children: ReactNode }) {
// null = still asking the server. A browser that last saw local mode starts
// there and is corrected by the answer (or by a 401) if that changed.
const [state, setState] = useState<AuthState | null>(cachedState);
useEffect(() => {
let timer: number | undefined;
let alive = true;
const ask = () => {
api.auth
.me()
.then((next) => {
if (!alive) return;
try {
localStorage.setItem(MODE_KEY, next.mode);
} catch {
/* storage blocked */
}
setState(next);
})
.catch((err: { status?: number; code?: string }) => {
if (!alive) return;
// An answer that is not this build's: an older server still running
// under a freshly built UI (404, or its index.html → EBADRESPONSE), or
// DASHBOARD_TOKEN's own 401 — neither is server mode, so render the
// app as before and let it show its own state.
if (
err?.status === 404 ||
err?.code === "EBADRESPONSE" ||
(err?.status === 401 && err?.code !== "EAUTH")
) {
setState({ mode: "local" });
return;
}
// No answer at all (a restart, a proxy's 502): ask again rather than
// guess — "local" would hide a server-mode dashboard's login page.
timer = window.setTimeout(ask, RETRY_MS);
});
};
ask();
// Any request answering 401 EAUTH means server mode with no valid login.
const onRequired = () => setState({ mode: "server", user: null });
window.addEventListener(AUTH_REQUIRED_EVENT, onRequired);
return () => {
alive = false;
window.clearTimeout(timer);
window.removeEventListener(AUTH_REQUIRED_EVENT, onRequired);
};
}, []);
if (!state) return null;
if (state.mode === "server" && !state.user) {
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
}
const logout = async () => {
await api.auth.logout().catch(() => {});
setState({ mode: "server", user: null });
};
return <AuthContext.Provider value={{ state, logout }}>{children}</AuthContext.Provider>;
}
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
const { t } = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
onLoggedIn((await api.auth.login(username, password)).user);
} catch (err) {
const code = (err as { code?: string }).code;
setError(code === "ETOOMANY" ? t("auth.tooMany") : t("auth.failed"));
setPassword("");
} finally {
setBusy(false);
}
};
return (
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
<form onSubmit={submit} className="card w-full max-w-sm space-y-4 p-6">
<h1 className="text-lg font-semibold text-fg-primary">{t("auth.title")}</h1>
<label className="block space-y-1 text-sm text-fg-secondary">
<span>{t("auth.username")}</span>
<input
className="input w-full"
autoComplete="username"
autoFocus
required
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</label>
<label className="block space-y-1 text-sm text-fg-secondary">
<span>{t("auth.password")}</span>
<input
className="input w-full"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
{error && (
<p role="alert" className="text-sm text-status-danger">
{error}
</p>
)}
<button type="submit" className="btn-primary w-full" disabled={busy}>
{busy ? t("auth.submitting") : t("auth.submit")}
</button>
</form>
</main>
);
}
+19 -3
View File
@@ -88,12 +88,15 @@ function StatTile({
label,
value,
hint,
help,
icon,
tone = "default",
}: {
label: string;
value: React.ReactNode;
hint?: string;
/** Plain-language explanation, shown as the label's tooltip. */
help?: string;
icon: React.ReactNode;
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
}) {
@@ -120,9 +123,14 @@ function StatTile({
return (
<div className={`rounded-lg border px-3 py-2.5 ${palette}`}>
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted">
<div
className={`flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted${help ? " cursor-help" : ""}`}
title={help}
>
<span className={iconTone}>{icon}</span>
{label}
<span className={help ? "underline decoration-dotted underline-offset-2" : undefined}>
{label}
</span>
</div>
<div className="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight">
{value}
@@ -310,6 +318,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
<StatTile
label={t("overview.events")}
help={t("common:insights.help.events")}
value={stats.total_events.toLocaleString()}
hint={
eventRate !== null && eventRate > 0
@@ -322,6 +331,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
/>
<StatTile
label={t("overview.toolCalls")}
help={t("common:insights.help.toolCalls")}
value={toolCallCount.toLocaleString()}
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
icon={<Wrench className="w-3 h-3" />}
@@ -329,6 +339,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
/>
<StatTile
label={t("overview.subagents")}
help={t("common:insights.help.subagents")}
value={stats.agents.subagent.toLocaleString()}
hint={
stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined
@@ -338,12 +349,14 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
/>
<StatTile
label={t("overview.compactions")}
help={t("common:insights.help.compactions")}
value={stats.agents.compaction.toLocaleString()}
icon={<Layers className="w-3 h-3" />}
tone="blue"
/>
<StatTile
label={t("overview.errors")}
help={t("common:insights.help.errors")}
value={stats.error_count.toLocaleString()}
icon={<AlertTriangle className="w-3 h-3" />}
tone={stats.error_count > 0 ? "rose" : "default"}
@@ -478,7 +491,10 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
{totalTokens > 0 && (
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
<div className="flex items-center justify-between mb-2.5">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<h3
className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5 cursor-help"
title={t("common:insights.help.tokenFlow")}
>
<Coins className="w-3.5 h-3.5 text-status-warning" />
{t("overview.tokenFlow")}
</h3>
+19
View File
@@ -69,9 +69,11 @@ import {
ChevronUp,
ChevronDown,
FolderTree,
LogOut,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
import { useAuth } from "./AuthGate";
import { eventBus } from "../lib/eventBus";
import { WorkspaceTree } from "./WorkspaceTree";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
@@ -188,6 +190,7 @@ export function Sidebar({
resizing = false,
}: SidebarProps) {
const { t } = useTranslation();
const auth = useAuth();
const asideRef = useRef<HTMLElement>(null);
const drawerOpen = Boolean(drawer?.open);
useDialog(drawerOpen, asideRef, drawer?.onClose);
@@ -531,6 +534,22 @@ export function Sidebar({
<Settings className="w-4 h-4 flex-shrink-0" />
{!collapsed && <span>{t("nav:settings")}</span>}
</NavLink>
{auth.state.mode === "server" && auth.state.user && (
<button
type="button"
onClick={() => void auth.logout()}
title={`${t("common:auth.signedInAs", {
name: auth.state.user.username,
role: auth.state.user.role,
})} — ${t("common:auth.logout")}`}
aria-label={t("common:auth.logout")}
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
}`}
>
<LogOut className="h-4 w-4" />
</button>
)}
<button
type="button"
onClick={onToggle}
+7 -1
View File
@@ -77,6 +77,8 @@ interface StatCardProps {
loading?: boolean;
/** Drill-in: the whole card links here (e.g. the list the number counts). */
to?: string;
/** Plain-language explanation of the metric, shown as the label's tooltip. */
help?: string;
}
/**
@@ -92,11 +94,15 @@ export function StatCard({
raw,
loading = false,
to,
help,
}: StatCardProps) {
const body = (
<>
<div className="flex items-center justify-between gap-3 mb-3">
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
<span
className={`text-xs font-medium text-fg-muted uppercase tracking-wider truncate${help ? " cursor-help underline decoration-dotted underline-offset-2" : ""}`}
title={help}
>
{label}
</span>
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
+12 -11
View File
@@ -30,6 +30,7 @@ import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
const COST_POLL_MS = 30_000;
@@ -265,15 +266,15 @@ function QuotaChips() {
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
};
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
const [refreshing, setRefreshing] = useState(false);
const load = useCallback((force = false) => {
if (force) setRefreshing(true);
api.pricing
.quota(force)
.then((r) => setTools(r.tools))
.catch(() => undefined)
.finally(() => force && setRefreshing(false));
}, []);
const load = useCallback(
(force = false) =>
api.pricing
.quota(force)
.then((r) => setTools(r.tools))
.catch(() => undefined),
[]
);
const [refreshing, refresh] = useSpin(() => load(true));
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
if (!tools.length) return null;
return (
@@ -282,7 +283,7 @@ function QuotaChips() {
type="button"
data-testid="status-bar-quota"
data-tip-off
onClick={() => load(true)}
onClick={refresh}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
@@ -314,7 +315,7 @@ function QuotaChips() {
tools={tools}
onEnter={show}
onLeave={hide}
onRefresh={() => load(true)}
onRefresh={refresh}
refreshing={refreshing}
/>
)}
@@ -0,0 +1,100 @@
/**
* @file AuthGate.test.tsx
* @description The login gate: local mode renders the app untouched, server
* mode shows the login page until a login succeeds, a wrong password says so,
* and a later 401 `EAUTH` brings the login page back.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "../../i18n";
const me = vi.fn();
const login = vi.fn();
vi.mock("../../lib/api", () => ({
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
api: { auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() } },
}));
import { AuthGate } from "../AuthGate";
const user = { id: 1, username: "mia", role: "member" };
beforeEach(() => {
me.mockReset();
login.mockReset();
localStorage.clear();
});
describe("AuthGate", () => {
it("renders the app in local mode", async () => {
me.mockResolvedValue({ mode: "local" });
render(<AuthGate>app</AuthGate>);
expect(await screen.findByText("app")).toBeInTheDocument();
});
it("paints at once in a browser that last saw local mode", () => {
localStorage.setItem("agenthub.authMode", "local");
me.mockReturnValue(new Promise(() => {}));
render(<AuthGate>app</AuthGate>);
expect(screen.getByText("app")).toBeInTheDocument();
});
it("asks again instead of guessing when the server does not answer", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
try {
me.mockRejectedValueOnce(new Error("502"));
me.mockResolvedValue({ mode: "server", user: null });
render(<AuthGate>app</AuthGate>);
expect(screen.queryByText("app")).toBeNull();
await act(async () => {
await vi.advanceTimersByTimeAsync(2100);
});
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
expect(screen.queryByText("app")).toBeNull();
} finally {
vi.useRealTimers();
}
});
it("renders the app when an older server or a dashboard token answers instead", async () => {
for (const err of [
{ status: 404 },
{ status: 200, code: "EBADRESPONSE" },
{ status: 401, code: "EUNAUTHORIZED" },
]) {
me.mockReset();
me.mockRejectedValue(Object.assign(new Error("x"), err));
const { unmount } = render(<AuthGate>app</AuthGate>);
expect(await screen.findByText("app")).toBeInTheDocument();
unmount();
}
});
it("asks for a login in server mode and opens the app after one", async () => {
me.mockResolvedValue({ mode: "server", user: null });
login.mockRejectedValueOnce(Object.assign(new Error("x"), { code: "EBADLOGIN" }));
login.mockResolvedValueOnce({ user });
render(<AuthGate>app</AuthGate>);
const name = await screen.findByLabelText(/username/i);
const pass = screen.getByLabelText(/password/i);
await userEvent.type(name, "mia");
await userEvent.type(pass, "nope");
await userEvent.click(screen.getByRole("button"));
expect(await screen.findByRole("alert")).toBeInTheDocument();
expect(screen.queryByText("app")).toBeNull();
await userEvent.type(pass, "member-pass-1");
await userEvent.click(screen.getByRole("button"));
expect(await screen.findByText("app")).toBeInTheDocument();
expect(login).toHaveBeenLastCalledWith("mia", "member-pass-1");
act(() => {
window.dispatchEvent(new Event("agenthub:auth-required"));
});
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
});
});
@@ -244,7 +244,11 @@ describe("StatusBar", () => {
await user.click(refresh);
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
await act(async () => answer(same));
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent("Refresh");
// The spin outlasts an instant answer (a throttled refresh is served from
// cache), so the label returns after the minimum spin.
await waitFor(() =>
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent(/^Refresh$/)
);
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
await user.unhover(panel);
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
@@ -59,6 +59,7 @@ import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { MessageList } from "./MessageList";
import { useSpin } from "../../hooks/useSpin";
import { useTranslation } from "react-i18next";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
@@ -106,7 +107,6 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
const genRef = useRef(0);
// Refresh-button spinner state - separate from initial `loading` so the
// existing skeleton doesn't blink during a manual refresh.
const [refreshing, setRefreshing] = useState(false);
// Load available transcript list (also rescanned on a short interval so
// newly-spawned subagents appear in the dropdown without a page reload).
@@ -305,14 +305,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
// Manual refresh - surfaces a control in the toolbar so users can force
// a sync without reloading the page.
const refresh = useCallback(async () => {
setRefreshing(true);
try {
await fetchNewMessages();
} finally {
setRefreshing(false);
}
}, [fetchNewMessages]);
const [refreshing, refresh] = useSpin(fetchNewMessages);
// Scroll-up to load history
const loadHistory = useCallback(async () => {
@@ -81,6 +81,7 @@ import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
import { useArmed } from "../../hooks/useArmed";
import { useSpin } from "../../hooks/useSpin";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
import {
LaneDiffView,
@@ -1421,8 +1422,9 @@ export function LaneChangesPanel({
setTreeChildren({});
setExpandedDirs(new Set());
setReread((n) => n + 1);
void load();
return load();
};
const [rereading, runReread] = useSpin(rereadAll);
const toggleCollapsed = (path: string) =>
setCollapsed((prev) => {
@@ -1609,12 +1611,12 @@ export function LaneChangesPanel({
<button
type="button"
onClick={rereadAll}
onClick={runReread}
aria-label={t("changes.refresh")}
title={t("changes.refresh")}
className="order-last ml-auto shrink-0 text-fg-muted hover:text-fg-primary"
>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
<RefreshCw className={`h-3.5 w-3.5 ${loading || rereading ? "animate-spin" : ""}`} />
</button>
</div>
@@ -108,7 +108,7 @@ export function SessionReplay({
? { runId: holder.id, laneId: lane.id, held: true }
: await startInFreeTabDetailed(lane.id, { resumeSessionId: sessionId, prompt: text });
setPrompt("");
images.forEach((i) => URL.revokeObjectURL(i.url));
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]);
onResumed(r.runId, r.laneId);
// Held already: the prompt reached nothing yet — paste it there.
+29 -6
View File
@@ -202,6 +202,12 @@ function HomeBody({
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
const [title, setTitle] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const [ctxDrag, setCtxDrag] = useState(false);
const addFiles = async (picked: File[]) => {
for (const f of picked) {
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
}
};
const memoryPanel = useRef<HTMLDivElement>(null);
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
const recentCtx = useContextMenu<Session>();
@@ -328,7 +334,7 @@ function HomeBody({
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
});
setPrompt("");
images.forEach((i) => URL.revokeObjectURL(i.url));
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
setImages([]);
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
} catch (err) {
@@ -839,7 +845,26 @@ function HomeBody({
</p>
</section>
<section className={card}>
<section
data-testid="workspace-context-files"
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
// Files dragged in from the OS are added like the + button's pick.
onDragOver={(e) => {
if (!view || !e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setCtxDrag(true);
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
}}
onDrop={(e) => {
setCtxDrag(false);
if (!view || e.dataTransfer.files.length === 0) return;
e.preventDefault();
void addFiles([...e.dataTransfer.files]);
}}
>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
<button
@@ -858,12 +883,10 @@ function HomeBody({
type="file"
multiple
hidden
onChange={async (e) => {
onChange={(e) => {
const picked = [...(e.target.files ?? [])];
e.target.value = "";
for (const f of picked) {
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
}
void addFiles(picked);
}}
/>
</div>
@@ -12,7 +12,7 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { Lane, Session } from "../../../lib/types";
@@ -454,6 +454,13 @@ describe("WorkspaceHome", () => {
const file = new File(["# notes"], "notes.md", { type: "text/markdown" });
await user.upload(screen.getByTestId("workspace-file-input"), file);
expect(api.lanes.addFile).toHaveBeenCalledWith(1, file);
const a = new File(["a"], "a.csv", { type: "text/csv" });
const b = new File(["b"], "b.log", { type: "" });
fireEvent.drop(screen.getByTestId("workspace-context-files"), {
dataTransfer: { types: ["Files"], files: [a, b] },
});
await waitFor(() => expect(api.lanes.addFile).toHaveBeenCalledWith(1, b));
expect(api.lanes.addFile).toHaveBeenCalledWith(1, a);
await user.click(screen.getByRole("button", { name: "Remove spec.pdf" }));
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
});
+65 -10
View File
@@ -22,7 +22,10 @@
* a `+` item) brings it into the tab on screen, split to the right, and a
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any
* tab drags along the bar to reorder it, and a pane header dragged onto the
* bar takes that pane out into a tab of its own there. A right-click on a tab
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
* of another tab, a tab (or a pane header) joins that tab instead
* (`joinGroup`: side by side, then under the right pane, then under the left
* one, up to four panes). A right-click on a tab
* opens the same actions as a menu (`ContextMenu`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -51,7 +54,15 @@ import { AiToolIcon } from "../AiToolIcon";
import { freeSlot, startInFreeTab } from "../lanes/startSession";
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
import { insertionIndex } from "../../lib/laneOrderStorage";
import { type PaneGroups, detachSlot, groupOf, leaves, reorderGroups } from "../../lib/paneLayout";
import {
type PaneGroups,
MAX_PANES,
detachSlot,
groupOf,
joinGroup,
leaves,
reorderGroups,
} from "../../lib/paneLayout";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
type AiToolsState = ReturnType<typeof useAiTools>;
@@ -201,6 +212,10 @@ export function ConsoleTabs({
// point among the drawn tabs, from the pointer against their middles.
const listRef = useRef<HTMLDivElement>(null);
const [dropAt, setDropAt] = useState<number | null>(null);
// The drawn tab a drop would join (the pointer on its middle half), and the
// slots a drag from this bar carries — unknown for a pane header (one slot).
const [joinAt, setJoinAt] = useState<number | null>(null);
const dragged = useRef<number[]>([]);
const isBarDrag = (e: DragEvent) => {
const types = e.dataTransfer?.types;
return !!types && (types.includes(PANE_DRAG_TYPE) || types.includes(TAB_DRAG_TYPE));
@@ -215,31 +230,70 @@ export function ConsoleTabs({
x
);
};
const joinIndexAt = (x: number) => {
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
const i = els.findIndex((el) => {
const b = el.getBoundingClientRect();
return x >= b.left + b.width / 4 && x <= b.right - b.width / 4;
});
const tab = tabs[i];
if (!tab || dragged.current.some((s) => tab.slots.includes(s))) return null;
return tab.slots.length + Math.max(dragged.current.length, 1) <= MAX_PANES ? i : null;
};
const endDrag = () => {
dragged.current = [];
setDropAt(null);
setJoinAt(null);
};
const dragHandlers = {
onDragOver: (e: DragEvent) => {
if (!isBarDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const at = dropIndexAt(e.clientX);
const join = joinIndexAt(e.clientX);
const at = join === null ? dropIndexAt(e.clientX) : null;
setJoinAt((d) => (d === join ? d : join));
setDropAt((d) => (d === at ? d : at));
},
onDragLeave: (e: DragEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
},
onDragEnd: () => setDropAt(null),
onDrop: (e: DragEvent) => {
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setDropAt(null);
setJoinAt(null);
},
onDragEnd: endDrag,
onDrop: (e: DragEvent) => {
const join = joinIndexAt(e.clientX);
endDrag();
if (!isBarDrag(e)) return;
e.preventDefault();
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
const target = join === null ? undefined : tabs[join];
// A pane header dropped on its OWN tab is a detach, not a join: a pane
// drag carries no slots during dragover, so joinIndexAt cannot skip it.
const ownTab =
tab === "" && raw !== "" && target && groupOf(groups, Number(raw)) === target.index;
if (target && !ownTab) {
onGroupsChange((g) =>
joinGroup(
g,
tab !== ""
? leaves(g.groups[Number(tab)]?.root ?? null)
: raw !== ""
? [Number(raw)]
: [],
target.index
)
);
return;
}
// The drawn position, as an index into the groups.
const drawn = dropIndexAt(e.clientX);
const at = tabs[drawn]?.index ?? groups.groups.length;
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
if (tab !== "") {
onGroupsChange((g) => reorderGroups(g, Number(tab), at));
return;
}
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
if (raw === "") return;
const slot = Number(raw);
onGroupsChange((g) => {
@@ -426,6 +480,7 @@ export function ConsoleTabs({
data-slots={tab.key}
draggable
onDragStart={(e) => {
dragged.current = tab.slots;
if (single) startPaneDrag(e, single.slot ?? 0);
else {
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
@@ -439,7 +494,7 @@ export function ConsoleTabs({
active
? "bg-surface-0 text-fg-primary"
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
}`}
} ${joinAt === i ? "ring-2 ring-inset ring-accent/60 bg-accent/15" : ""}`}
>
{(dropAt === i || (dropAt === tabs.length && i === tabs.length - 1)) && (
<span
+38 -26
View File
@@ -6,8 +6,8 @@
* box (`WorkspaceHome.tsx`):
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
* newlines, and a pasted or dropped image attached by its saved path
* (`POST /api/run/drop-image`);
* newlines, and pasted or dropped files (images as thumbnails, any other
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`);
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
* or a fixed one (an AI Proxy's models), plus Custom…;
@@ -25,6 +25,7 @@ import {
Check,
ChevronDown,
ChevronRight,
FileText,
FolderOpen,
GitBranch,
Home,
@@ -153,8 +154,8 @@ interface PromptEditorProps {
slash: boolean;
/** Where `@` looks for files and project commands/skills are read from. */
fileCwd: string;
/** Images pasted or dropped so far, shown as thumbnails; the caller sends
* them with the text through `promptWithImages`. */
/** Files pasted or dropped so far (images shown as thumbnails); the caller
* sends them with the text through `promptWithImages`. */
images: PromptImage[];
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
/** Borderless, for a composer box that draws its own frame. */
@@ -164,11 +165,12 @@ interface PromptEditorProps {
onBusyChange?: (busy: boolean) => void;
}
/** An attached image: its path on the server's machine and a local preview. */
export type PromptImage = { path: string; url: string; name: string };
/** An attached file: its path on the server's machine, and a local preview
* `url` when it is an image. */
export type PromptImage = { path: string; url?: string; name: string };
/** The prompt as sent: the text, then each image's saved path — which is how
* the CLI is handed an image. */
/** The prompt as sent: the text, then each file's saved path — which is how
* the CLI is handed an image or a file. */
export function promptWithImages(text: string, images: PromptImage[]): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
}
@@ -309,9 +311,9 @@ export function PromptEditor({
});
};
// A pasted or dropped image is saved on the server's machine (the same
// route the terminal's drop uses) and shown as a thumbnail; its path is
// added to the prompt only when it is sent (promptWithImages).
// A pasted or dropped file is saved on the server's machine (the same
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
// its path is added to the prompt only when it is sent (promptWithImages).
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
const [viewing, setViewing] = useState<PromptImage | null>(null);
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
@@ -320,15 +322,15 @@ export function PromptEditor({
busyRef.current = onBusyChange;
useEffect(() => busyRef.current?.(uploading), [uploading]);
const attachImages = (list: FileList | null | undefined) => {
const picked = Array.from(list ?? []).filter((f) => f.type.startsWith("image/"));
const picked = Array.from(list ?? []);
if (picked.length === 0) return false;
setUpload("uploading");
Promise.all(picked.map((f) => api.run.dropImage(f)))
Promise.all(picked.map((f) => api.run.dropFile(f)))
.then((saved) => {
setUpload(null);
const added = picked.map((f, i) => ({
path: saved[i]!.path,
url: URL.createObjectURL(f),
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
name: f.name,
}));
onImagesChange((prev) => [...prev, ...added]);
@@ -337,7 +339,7 @@ export function PromptEditor({
return true;
};
const removeImage = (img: PromptImage) => {
URL.revokeObjectURL(img.url);
if (img.url) URL.revokeObjectURL(img.url);
onImagesChange((prev) => prev.filter((x) => x !== img));
};
useEffect(() => {
@@ -394,17 +396,27 @@ export function PromptEditor({
{images.length > 0 && (
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
{images.map((img) => (
<div key={img.url} className="group relative">
<button
type="button"
aria-label={img.name}
title={img.name}
onClick={() => setViewing(img)}
onContextMenu={(e) => setImageMenu({ ...menuPoint(e), img })}
className="block h-14 w-14 overflow-hidden rounded-lg border border-border bg-surface-3 hover:border-accent/50"
>
<img src={img.url} alt="" className="h-full w-full object-cover" />
</button>
<div key={img.path} className="group relative">
{img.url ? (
<button
type="button"
aria-label={img.name}
title={img.name}
onClick={() => setViewing(img)}
onContextMenu={(e) => setImageMenu({ ...menuPoint(e), img })}
className="block h-14 w-14 overflow-hidden rounded-lg border border-border bg-surface-3 hover:border-accent/50"
>
<img src={img.url} alt="" className="h-full w-full object-cover" />
</button>
) : (
<span
title={img.path}
className="flex h-14 max-w-48 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
>
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
<span className="truncate">{img.name}</span>
</span>
)}
<button
type="button"
aria-label={t("drop.remove")}
+10 -11
View File
@@ -14,9 +14,10 @@
* (`loadXterm`), not bundled with the Workspace chunk.
* Always painted dark, in the palette of the user's chosen DARK theme
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
* and its PATH pasted into the pane — what a local terminal does on a drop,
* and how `claude` attaches an image. The pane also registers with
* Files (images or any other) dropped on it are saved on the server
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop, and how `claude` attaches an image or reads
* a file. The pane also registers with
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
* pasted into whichever of the workspace's panes was focused last.
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
@@ -46,7 +47,7 @@ import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { notePaneFocus, registerPane } from "../../lib/panePaste";
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
type DropState = "hint" | "uploading" | "failed";
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
@@ -183,13 +184,13 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
e.preventDefault();
dragDepth.current = 0;
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
if (images.length === 0) return flash("imagesOnly");
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
setDrop("uploading");
try {
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
const saved = await Promise.all(files.map((f) => api.run.dropFile(f)));
// paste(), not a raw send: it honours bracketed-paste mode, so claude
// sees one pasted path rather than typed keystrokes.
// sees pasted paths rather than typed keystrokes.
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
termRef.current?.focus();
setDrop(null);
@@ -650,9 +651,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
<div
data-testid="terminal-drop"
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
drop === "failed" || drop === "imagesOnly"
? "border-red-400 text-red-300"
: "border-sky-400 text-sky-200"
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
}`}
>
{t(`drop.${drop}`)}
@@ -44,7 +44,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
}),
},
run: { files: r({ items: [] }), dropImage: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
run: { files: r({ items: [] }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
ccConfig: {
commands: r({
items: [
@@ -195,7 +195,7 @@ describe("PromptEditor — newlines and images", () => {
const img = new File(["x"], "shot.png", { type: "image/png" });
fireEvent.paste(box(), { clipboardData: { files: [img] } });
const thumb = await screen.findByRole("button", { name: "shot.png" });
expect(api.run.dropImage).toHaveBeenCalledWith(img);
expect(api.run.dropFile).toHaveBeenCalledWith(img);
// The path is not typed into the prompt; it goes with it on send.
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
@@ -204,16 +204,43 @@ describe("PromptEditor — newlines and images", () => {
fireEvent.keyDown(document, { key: "Escape" });
expect(screen.queryByRole("dialog")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
fireEvent.click(screen.getByRole("button", { name: "Remove attachment" }));
expect(screen.queryByRole("button", { name: "shot.png" })).toBeNull();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:shot");
expect(screen.getByTestId("sent").textContent).toBe("see");
});
it("attaches a dropped non-image file as a name chip sent by its path", async () => {
vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" });
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value="read"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash
fileCwd="/Users/tester"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("read", images)}</output>
</>
);
}
render(<Harness />);
const pdf = new File(["%PDF"], "spec.pdf", { type: "application/pdf" });
fireEvent.drop(box(), { dataTransfer: { files: [pdf] } });
expect(await screen.findByText("spec.pdf")).toBeTruthy();
expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf");
});
it("leaves a text paste to the textarea", () => {
renderEditor("");
fireEvent.paste(box(), { clipboardData: { files: [] } });
expect(api.run.dropImage).not.toHaveBeenCalled();
expect(api.run.dropFile).not.toHaveBeenCalled();
});
});
@@ -21,9 +21,9 @@ const wheelHandlerMock = vi.fn();
const keyHandlerMock = vi.fn();
const focusMock = vi.fn();
const pasteMock = vi.fn();
const dropImageMock = vi.fn();
const dropFileMock = vi.fn();
vi.mock("../../../lib/api", () => ({
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
api: { run: { dropFile: (f: File) => dropFileMock(f) } },
}));
const loadAddonMock = vi.fn();
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
@@ -421,29 +421,36 @@ describe("TerminalView", () => {
});
it("pastes the server-side path of a dropped image into the pane", async () => {
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-shot.png" });
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
const target = screen.getByTestId("terminal-view");
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop files or images to attach");
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
expect(dropImageMock).toHaveBeenCalledWith(img);
await waitFor(() =>
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-shot.png ")
);
expect(dropFileMock).toHaveBeenCalledWith(img);
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
});
it("uploads nothing for a dropped non-image and says why", async () => {
dropImageMock.mockReset();
it("pastes every path of several dropped files, images or not", async () => {
dropFileMock
.mockReset()
.mockImplementation(async (f: File) => ({ path: `/tmp/agenthub-drops-1/x-${f.name}` }));
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
const img = new File(["x"], "b.png", { type: "image/png" });
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { types: ["Files"], files: [txt] },
dataTransfer: { types: ["Files"], files: [txt, img] },
});
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
expect(dropImageMock).not.toHaveBeenCalled();
expect(pasteMock).not.toHaveBeenCalled();
await waitFor(() =>
expect(pasteMock).toHaveBeenCalledWith(
"/tmp/agenthub-drops-1/x-a.txt /tmp/agenthub-drops-1/x-b.png "
)
);
});
});
@@ -23,7 +23,6 @@ const THEME_LABELS: Record<string, string> = {
darcula: "Darcula",
"apple-dark": "Apple Dark",
"apple-light": "Apple Light",
"intellij-light": "IntelliJ Light",
canvas: "Canvas",
"orca-dark": "Orca Dark",
"orca-light": "Orca Light",
@@ -0,0 +1,108 @@
/**
* @file UsersSettings.tsx
* @description Settings → Users, server mode only and only for an admin or
* analyst (docs/SERVER-MODE.md): every account with its role, state,
* workspaces, sessions and the tokens/$ it used in the last 7 and 30 days
* (`GET /api/users`), and the audit log of staff reads of members' sessions
* (`GET /api/users/audit`). Read-only — accounts are managed with
* `agenthub users …` on the host.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type ServerUserUsage, type AuditEntry } from "../../lib/api";
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
import { SettingsSection } from "./SettingsRow";
export function UsersSettings() {
const { t } = useTranslation("settings");
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch((e: Error) => setError(e.message));
api.users
.audit()
.then((r) => setAudit(r.entries))
.catch(() => setAudit([]));
}, []);
return (
<>
<SettingsSection title={t("users.title")} description={t("users.description")}>
{error && (
<p role="alert" className="py-2 text-xs text-status-danger">
{error}
</p>
)}
{users && (
<div className="overflow-x-auto">
<table className="w-full text-sm" data-testid="users-table">
<thead className="text-left text-[11px] uppercase text-fg-muted">
<tr>
<th className="py-2 pr-3 font-medium">{t("users.name")}</th>
<th className="py-2 pr-3 font-medium">{t("users.role")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("users.workspaces")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("users.sessions")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("users.last7d")}</th>
<th className="py-2 text-right font-medium">{t("users.last30d")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{users.map((u) => (
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
<td className="py-2 pr-3">
{u.username}
{u.disabled && (
<span className="ml-2 text-[11px]">({t("users.disabled")})</span>
)}
</td>
<td className="py-2 pr-3">{u.role}</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
<td
className="py-2 pr-3 text-right tabular-nums"
title={t("users.tokens", { n: fmt(u.last7d.tokens) })}
>
{fmtCost(u.last7d.cost)}
</td>
<td
className="py-2 text-right tabular-nums"
title={t("users.tokens", { n: fmt(u.last30d.tokens) })}
>
{fmtCost(u.last30d.cost)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="pt-2 text-[11px] text-fg-muted">{t("users.manageHint")}</p>
</SettingsSection>
<SettingsSection title={t("users.auditTitle")} description={t("users.auditDescription")}>
{audit?.length === 0 && (
<p className="py-2 text-sm text-fg-muted">{t("users.auditEmpty")}</p>
)}
{audit && audit.length > 0 && (
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
{audit.map((a) => (
<li key={a.id} className="flex gap-3 py-1.5">
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
<span className="w-40 flex-shrink-0 text-fg-secondary">
{a.reader} → {a.owner}
</span>
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
</li>
))}
</ul>
)}
</SettingsSection>
</>
);
}
@@ -50,7 +50,6 @@ describe("AppearanceSettings", () => {
"Apple Dark",
"Orca Dark",
"Apple Light",
"IntelliJ Light",
"Canvas",
"Orca Light",
]) {
@@ -0,0 +1,57 @@
/**
* @file useSpin.test.ts
* @description Tests the Refresh-button hook: the reload runs with no
* arguments, a click while it is in flight is dropped, and the spin lasts at
* least the floor even when the reload answers at once — also under StrictMode's
* mount → unmount → mount, which once left it spinning for good.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { StrictMode } from "react";
import { act, renderHook } from "@testing-library/react";
import { useSpin } from "../useSpin";
afterEach(() => vi.useRealTimers());
describe("useSpin", () => {
it("drops a click while the reload is in flight, and spins for the floor after it answers", async () => {
vi.useFakeTimers();
let answer!: () => void;
const load = vi.fn(() => new Promise<void>((r) => (answer = r)));
const { result } = renderHook(() => useSpin(load, 600));
act(() => {
void result.current[1]();
});
expect(result.current[0]).toBe(true);
expect(load).toHaveBeenCalledWith();
act(() => {
void result.current[1]();
});
expect(load).toHaveBeenCalledTimes(1);
// Answered at once: still spinning until the floor, but a new click reloads.
await act(async () => answer());
expect(result.current[0]).toBe(true);
act(() => {
void result.current[1]();
});
expect(load).toHaveBeenCalledTimes(2);
await act(async () => {
answer();
await vi.advanceTimersByTimeAsync(600);
});
expect(result.current[0]).toBe(false);
});
it("stops spinning under StrictMode's double mount", async () => {
vi.useFakeTimers();
const { result } = renderHook(() => useSpin(() => undefined, 600), { wrapper: StrictMode });
await act(async () => {
void result.current[1]();
await vi.advanceTimersByTimeAsync(600);
});
expect(result.current[0]).toBe(false);
});
});
+53
View File
@@ -0,0 +1,53 @@
/**
* @file useSpin.ts
* @description A Refresh button's busy state. `run` calls the reload with no
* arguments (an `onClick={load}` handed the click event to it) and keeps
* `spinning` true for at least `minMs`, so a reload answered from a cache or
* a throttle in a few milliseconds still shows the user that it ran.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
/**
* @returns `[spinning, run]`. A click while the reload is still in flight is
* dropped; once it has answered, a click reloads again even if the icon is
* still finishing its minimum spin.
*/
export function useSpin(fn: () => unknown, minMs = 600): [boolean, () => Promise<void>] {
const [spinning, setSpinning] = useState(false);
const inFlight = useRef(false);
const lastRun = useRef(0);
const fnRef = useRef(fn);
fnRef.current = fn;
const alive = useRef(true);
// Set on mount too: StrictMode mounts, unmounts and mounts again, and a
// cleanup-only effect left `alive` false — the spin never stopped and the
// button stayed busy for good.
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
const run = useCallback(async () => {
if (inFlight.current) return;
inFlight.current = true;
const id = ++lastRun.current;
const started = Date.now();
setSpinning(true);
try {
await fnRef.current();
} finally {
inFlight.current = false;
const rest = minMs - (Date.now() - started);
if (rest > 0) await new Promise((r) => setTimeout(r, rest));
// Only the newest run stops the spin: an older one's floor ending must
// not cut short a reload clicked after it.
if (alive.current && id === lastRun.current) setSpinning(false);
}
}, [minMs]);
return [spinning, run];
}
+1 -1
View File
@@ -19,7 +19,7 @@ export type ThemeMode = Theme | "system";
/** Named themes per mode, in picker order. `default` is the stock palette. */
export const THEMES = {
dark: ["default", "darcula", "apple-dark", "orca-dark"],
light: ["default", "apple-light", "intellij-light", "canvas", "orca-light"],
light: ["default", "apple-light", "canvas", "orca-light"],
} as const satisfies Record<Theme, readonly string[]>;
const MODE_KEY = "theme";
+27
View File
@@ -202,5 +202,32 @@
"message": "Your edits have not been saved and will be lost.",
"discard": "Discard",
"keep": "Keep editing"
},
"auth": {
"title": "Sign in to AgentHub",
"username": "Username",
"password": "Password",
"submit": "Sign in",
"submitting": "Signing in…",
"failed": "Wrong username or password",
"tooMany": "Too many failed attempts — try again in a few minutes",
"logout": "Sign out",
"signedInAs": "Signed in as {{name}} ({{role}})"
},
"insights": {
"help": {
"sessions": "A session is one conversation with an AI agent, from start to finish.",
"agents": "Agents are the AI workers: the main one in each session plus any helpers it started.",
"tokens": "Tokens are the pieces of text the AI reads and writes. Roughly 1 token ≈ 4 characters. More tokens = more cost.",
"cost": "Estimated money spent, calculated from token counts and each model's price.",
"events": "Every step the AI takes (running a tool, finishing a reply…) is recorded as one event.",
"tokenMix": "Input = what you and the files sent the AI. Output = what the AI wrote. Cache read = text reused from memory (about 10× cheaper). Cache write = text saved to memory for reuse.",
"spendPace": "How fast money is being spent, and where this month will end up if that speed continues.",
"toolCalls": "How many actions the AI took: reading a file, editing, running a command…",
"subagents": "Helper agents the main AI started to work on part of the task in parallel.",
"compactions": "When a conversation gets too long, it is summarized to make room. Early details can be lost.",
"errors": "Steps that failed. A few are normal; many may mean the AI got stuck.",
"tokenFlow": "Input = sent to the AI, Output = written by the AI, Cache = reused from memory (cheaper)."
}
}
}
+4 -5
View File
@@ -142,11 +142,10 @@
"output": "Output"
},
"drop": {
"hint": "Drop image to attach",
"uploading": "Uploading image…",
"failed": "Could not attach the image",
"imagesOnly": "Only images can be dropped here",
"remove": "Remove image",
"hint": "Drop files or images to attach",
"uploading": "Uploading…",
"failed": "Could not attach the file",
"remove": "Remove attachment",
"view": "View image"
},
"terminal": {
+20
View File
@@ -823,6 +823,10 @@
"reports": {
"title": "Reports",
"description": "Analytics summaries written on a schedule, as HTML or JSON."
},
"users": {
"title": "Users",
"description": "Accounts, their usage and the audit log."
}
},
"backPrevious": "Back",
@@ -942,5 +946,21 @@
"importBody": "The file's preferences replace this browser's, then the page reloads.",
"imported": "Imported {{count}} preference(s). Reloading…",
"badFile": "That file is not an AgentHub preferences file."
},
"users": {
"title": "Users",
"description": "Every account on this server, with its workspaces, sessions and recent usage.",
"name": "User",
"role": "Role",
"workspaces": "Workspaces",
"sessions": "Sessions",
"last7d": "7 days",
"last30d": "30 days",
"disabled": "disabled",
"tokens": "{{n}} tokens",
"manageHint": "Add, disable or change a user on the host: agenthub users …",
"auditTitle": "Audit log",
"auditDescription": "Each time an admin or analyst opened a session a member owns.",
"auditEmpty": "No reads recorded yet."
}
}
+27
View File
@@ -202,5 +202,32 @@
"message": "Các chỉnh sửa của bạn chưa được lưu và sẽ bị mất.",
"discard": "Bỏ thay đổi",
"keep": "Tiếp tục sửa"
},
"auth": {
"title": "Đăng nhập AgentHub",
"username": "Tên đăng nhập",
"password": "Mật khẩu",
"submit": "Đăng nhập",
"submitting": "Đang đăng nhập…",
"failed": "Sai tên đăng nhập hoặc mật khẩu",
"tooMany": "Sai quá nhiều lần — hãy thử lại sau vài phút",
"logout": "Đăng xuất",
"signedInAs": "Đang đăng nhập: {{name}} ({{role}})"
},
"insights": {
"help": {
"sessions": "Một phiên là một cuộc trò chuyện với agent AI, từ lúc bắt đầu đến khi kết thúc.",
"agents": "Agent là các 'nhân công' AI: agent chính trong mỗi phiên cộng với các agent phụ nó tạo ra.",
"tokens": "Token là các mẩu chữ AI đọc và viết, khoảng 1 token ≈ 4 ký tự. Càng nhiều token càng tốn tiền.",
"cost": "Số tiền ước tính, tính từ số token và giá của từng mô hình.",
"events": "Mỗi bước AI làm (chạy công cụ, trả lời xong…) được ghi lại thành một sự kiện.",
"tokenMix": "Input = nội dung bạn và các file gửi cho AI. Output = nội dung AI viết ra. Cache read = nội dung dùng lại từ bộ nhớ đệm (rẻ hơn khoảng 10 lần). Cache write = nội dung lưu vào bộ nhớ đệm để dùng lại.",
"spendPace": "Tốc độ chi tiền, và cuối tháng sẽ tốn bao nhiêu nếu giữ tốc độ này.",
"toolCalls": "Số thao tác AI đã làm: đọc file, sửa file, chạy lệnh…",
"subagents": "Các agent phụ mà AI chính tạo ra để làm song song một phần công việc.",
"compactions": "Khi cuộc trò chuyện quá dài, nó được tóm tắt lại để có chỗ trống. Chi tiết ở đoạn đầu có thể bị mất.",
"errors": "Các bước bị lỗi. Vài lỗi là bình thường; nhiều lỗi có thể nghĩa là AI bị kẹt.",
"tokenFlow": "Input = gửi cho AI, Output = AI viết ra, Cache = dùng lại từ bộ nhớ đệm (rẻ hơn)."
}
}
}
+4 -5
View File
@@ -141,11 +141,10 @@
"output": "Output"
},
"drop": {
"hint": "Thả ảnh để đính kèm",
"uploading": "Đang tải ảnh lên…",
"failed": "Không đính kèm được ảnh",
"imagesOnly": "Chỉ thả được ảnh vào đây",
"remove": "Bỏ ảnh",
"hint": "Thả file hoặc ảnh để đính kèm",
"uploading": "Đang tải lên…",
"failed": "Không đính kèm được file",
"remove": "Bỏ đính kèm",
"view": "Xem ảnh"
},
"terminal": {
+20
View File
@@ -823,6 +823,10 @@
"reports": {
"title": "Báo cáo",
"description": "Bản tóm tắt phân tích ghi ra theo lịch, dạng HTML hoặc JSON."
},
"users": {
"title": "Người dùng",
"description": "Tài khoản, mức dùng và nhật ký truy cập."
}
},
"backPrevious": "Quay lại",
@@ -942,5 +946,21 @@
"importBody": "Tùy chọn trong tệp sẽ thay thế tùy chọn của trình duyệt này, sau đó trang tải lại.",
"imported": "Đã nhập {{count}} tùy chọn. Đang tải lại…",
"badFile": "Tệp này không phải tệp tùy chọn của AgentHub."
},
"users": {
"title": "Người dùng",
"description": "Mọi tài khoản trên server này, cùng workspace, session và mức dùng gần đây.",
"name": "Người dùng",
"role": "Vai trò",
"workspaces": "Workspace",
"sessions": "Session",
"last7d": "7 ngày",
"last30d": "30 ngày",
"disabled": "đã khoá",
"tokens": "{{n}} token",
"manageHint": "Thêm, khoá hoặc sửa người dùng trên máy chủ: agenthub users …",
"auditTitle": "Nhật ký truy cập",
"auditDescription": "Mỗi lần admin hoặc analyst mở một session của member.",
"auditEmpty": "Chưa có lượt đọc nào."
}
}
+38 -95
View File
@@ -17,8 +17,7 @@
* 11-12 text (11 secondary, 12 primary)
* The DARK theme follows it literally: `slate` supplies surface/border/fg and
* `blue` supplies accent. The LIGHT theme keeps the structure (same roles, same
* rising-lightness ramp) but swaps the hue family for Claude's own warm ivory
* and terracotta, because a cool near-white page reads as glare. `green`/
* rising-lightness ramp) with IntelliJ Light's neutrals and blue accent. `green`/
* `red`/`amber` step 11 supplies the status tokens — the same step used for
* fg-secondary, since 11 is Radix's guaranteed-readable-text step. Values are
* the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to
@@ -29,37 +28,19 @@
/* Light theme — the CSS default, so it applies with no class on <html>.
`[data-mode="light"]` lets a light theme preview re-scope inside a dark
page (the Settings appearance cards).
Radix `slate` light, RE-ORDERED so lightness rises with the index exactly
as it does in dark: the page is the DARKEST step and a card is brighter
than the page it sits on. The previous light block used the scale in its
native direction, which inverted the whole language — a near-white page
(glare) under grey cards that read as recessed, and every `hover:bg-*`
darkening instead of lifting.
The HUE is warm — Claude's own ivory/terracotta family, not a cool slate.
A cool near-white ramp kept reading as glare no matter how far the page
step was pulled down, because the brightest thing on screen was also the
bluest. Cream lowers the perceived glare at the same measured lightness
and gives every neutral a warm cast to sit against. The whole ramp sits
one notch below Claude's own ivory (#faf9f5 card) and no step reaches pure
white: at full brightness a large light panel still tires the eye. */
--surface-0: 232 229 218; /* #e8e5da — page background */
--surface-1: 236 233 223; /* #ece9df — sub-headers inside a card */
--surface-2: 240 237 228; /* #f0ede4 — inputs, chips, toolbars */
--surface-3: 245 243 236; /* #f5f3ec — `.card`, Claude's ivory a step down */
--surface-4: 249 247 241; /* #f9f7f1 — hover / active lift */
--surface-5: 252 251 247; /* #fcfbf7 — topmost, rare */
--border: 207 201 184; /* #cfc9b8 */
--border-light: 222 217 204; /* #ded9cc */
/* Claude's terracotta (#cc785c), taken down two steps: the brand value is
3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and
fails as a link or a button label. This is 5.3:1 as text on a card and
5.9:1 white-on-accent. Dark mode keeps its blue — inverting a warm accent
onto a near-black page is a different problem with a different answer.
One more step down (#a34a28 → #924224) so `text-accent` also clears 4.5:1
on its own selected chip (`bg-accent/15–20`), not just on the card. */
--accent: 146 66 36; /* #924224 */
--accent-hover: 122 55 30; /* #7a371e */
IntelliJ Light's cool neutrals, ordered so lightness rises with the index
exactly as it does in dark: the page is the DARKEST step and a card is
brighter than the page it sits on, so every `hover:bg-*` lifts. */
--surface-0: 236 238 242; /* #eceef2 — page background */
--surface-1: 241 242 245; /* #f1f2f5 — sub-headers inside a card */
--surface-2: 247 248 250; /* #f7f8fa — inputs, chips, toolbars */
--surface-3: 253 253 254; /* #fdfdfe — `.card` */
--surface-4: 255 255 255; /* #ffffff — hover / active lift */
--surface-5: 255 255 255; /* #ffffff — topmost, rare */
--border: 211 213 219; /* #d3d5db */
--border-light: 228 230 235; /* #e4e6eb */
--accent: 40 90 192; /* #285ac0 */
--accent-hover: 33 74 158; /* #214a9e */
/* The FILL behind white text (`.btn-primary`, a Switch's on-track, a ticked
Checkbox, `bg-accent-solid`). In light themes the text accent is already
dark enough to carry white, so it simply IS the accent — resolved per
@@ -68,43 +49,37 @@
white text cannot sit on (3.3:1). */
--accent-solid: var(--accent);
--accent-solid-hover: var(--accent-hover);
--fg-primary: 31 30 27; /* #1f1e1b — 15.0:1 on a card */
--fg-secondary: 96 91 83; /* #605b53 — 6.1:1 */
/* Measured, not eyeballed: this carries real content (timestamps, counts),
so it is held above 4.5:1 — 5.4:1 on a card, 4.7:1 on the page — and still
sits a clear step below fg-secondary. */
--fg-muted: 104 99 89; /* #686359 */
/* Warm and soft, to match the surfaces: a neutral-grey shadow goes muddy
over cream. The SHADOW carries "raised" here, not the quiet border. */
--shadow-card: 0 1px 2px rgb(60 50 30 / 0.05), 0 2px 6px rgb(60 50 30 / 0.07);
/* Status: darkened two steps from Radix 11. One step cleared 4.5:1 against
the card; these also clear it against the status's OWN densest chip (a
20% tint of itself — 4.5:1), which is where the label actually sits. */
--status-success: 22 104 66; /* #166842 */
--status-danger: 170 36 41; /* #aa2429 */
--status-warning: 133 79 0; /* #854f00 */
--fg-primary: 30 31 34; /* #1e1f22 */
--fg-secondary: 73 75 87; /* #494b57 */
/* Carries real content (timestamps, counts), so it is held above 4.5:1 on a
card and still sits a clear step below fg-secondary. */
--fg-muted: 103 106 120; /* #676a78 */
/* The SHADOW carries "raised" here, not the quiet border. */
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
/* Status labels clear 4.5:1 on the card AND on their own 20% chip. */
--status-success: 23 106 45; /* #176a2d */
--status-danger: 173 28 38; /* #ad1c26 */
--status-warning: 124 82 0; /* #7c5200 */
/* What every status/accent FILL is pre-mixed against (see `tint()` in
tailwind.config.js) — the card surface, because that is where chips and
alert strips overwhelmingly sit. */
/* Re-declared by every scope that sets a theme (this block, `.dark`), so it
resolves against THAT scope's surface-3; a new scope must re-declare it too. */
--tint-base: var(--surface-3);
/* Syntax colours for `lib/highlight.ts`. Same hues as the dark set, taken
four steps down the Tailwind ramp (300 → 700): a pastel keyword is legible
on a near-black code block and invisible on a light one. */
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
--syn-string: 4 120 87; /* emerald-700 #047857 */
--syn-number: 194 65 12; /* orange-700 #c2410c */
--syn-keyword: 109 40 217; /* violet-700 #6d28d9 */
--syn-builtin: 3 105 161; /* sky-700 #0369a1 */
--syn-function: 180 83 9; /* amber-700 #b45309 */
--syn-operator: 190 24 93; /* pink-700 #be185d */
--syn-punctuation: 107 114 128; /* gray-500 #6b7280 */
--syn-property: 14 116 144; /* cyan-700 #0e7490 */
--syn-tag: 190 18 60; /* rose-700 #be123c */
/* Syntax colours for `lib/highlight.ts` — IntelliJ Light's editor scheme. */
--syn-comment: 112 112 112; /* #707070 */
--syn-string: 6 125 23; /* #067d17 */
--syn-number: 23 80 235; /* #1750eb */
--syn-keyword: 0 51 179; /* #0033b3 */
--syn-builtin: 0 0 128; /* #000080 */
--syn-function: 0 98 122; /* #00627a */
--syn-operator: 75 75 75; /* #4b4b4b */
--syn-punctuation: 75 75 75; /* #4b4b4b */
--syn-property: 135 16 148; /* #871094 */
--syn-tag: 0 51 179; /* #0033b3 */
--syn-plain: var(--fg-primary);
--syn-add: 4 120 87; /* emerald-700 */
--syn-del: 185 28 28; /* red-700 #b91c1c */
--syn-add: 6 125 23; /* #067d17 */
--syn-del: 192 31 42; /* #c01f2a */
/* A diff line edited in place (not wholly added or removed). */
--diff-mod: 29 78 216; /* blue-700 #1d4ed8 */
/* A file on the Changes panel's never-stage list (orange, apart from amber M). */
@@ -309,38 +284,6 @@
--syn-del: 197 0 15; /* #c5000f */
}
[data-mode="light"][data-theme="intellij-light"] {
--surface-0: 236 238 242; /* #eceef2 */
--surface-1: 241 242 245; /* #f1f2f5 */
--surface-2: 247 248 250; /* #f7f8fa */
--surface-3: 253 253 254; /* #fdfdfe */
--surface-4: 255 255 255; /* #ffffff */
--surface-5: 255 255 255; /* #ffffff */
--border: 211 213 219; /* #d3d5db */
--border-light: 228 230 235; /* #e4e6eb */
--accent: 40 90 192; /* #285ac0 */
--accent-hover: 33 74 158; /* #214a9e */
--fg-primary: 30 31 34; /* #1e1f22 */
--fg-secondary: 73 75 87; /* #494b57 */
--fg-muted: 103 106 120; /* #676a78 */
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
--status-success: 23 106 45; /* #176a2d */
--status-danger: 173 28 38; /* #ad1c26 */
--status-warning: 124 82 0; /* #7c5200 */
--syn-comment: 112 112 112; /* #707070 */
--syn-string: 6 125 23; /* #067d17 */
--syn-number: 23 80 235; /* #1750eb */
--syn-keyword: 0 51 179; /* #0033b3 */
--syn-builtin: 0 0 128; /* #000080 */
--syn-function: 0 98 122; /* #00627a */
--syn-operator: 75 75 75; /* #4b4b4b */
--syn-punctuation: 75 75 75; /* #4b4b4b */
--syn-property: 135 16 148; /* #871094 */
--syn-tag: 0 51 179; /* #0033b3 */
--syn-add: 6 125 23; /* #067d17 */
--syn-del: 192 31 42; /* #c01f2a */
}
[data-mode="light"][data-theme="canvas"] {
--surface-0: 227 220 205; /* #e3dccd */
--surface-1: 232 226 212; /* #e8e2d4 */
+84 -9
View File
@@ -5,7 +5,8 @@
* the pane that takes its place, swapping/moving panes, resizing a split,
* pruning panes whose run is gone, the rectangles the grid draws, the TABS
* that group up to four panes each (a session in exactly one), and the
* per-workspace storage that must survive bad or stale data.
* dropping one tab onto another (joined in a fixed order: right, under the
* right pane, under the left pane), and the per-workspace storage that must survive bad or stale data.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
@@ -17,6 +18,7 @@ import {
MAX_PANES,
closeInLayout,
detachSlot,
joinGroup,
layoutRects,
leaves,
loadGroups,
@@ -203,7 +205,10 @@ describe("pane groups (tabs of up to four panes)", () => {
});
it("activates the tab already holding a session, focusing it", () => {
const g: PaneGroups = { groups: [single(0), { root: split("row", pane(1), pane(2)), focus: 1 }], active: 0 };
const g: PaneGroups = {
groups: [single(0), { root: split("row", pane(1), pane(2)), focus: 1 }],
active: 0,
};
const out = openInGroups(g, 2, false);
expect(out.active).toBe(1);
expect(out.groups[1]!.focus).toBe(2);
@@ -245,12 +250,18 @@ describe("pane groups (tabs of up to four panes)", () => {
it("an ended session leaves its tab; an emptied active tab hands over to a neighbour", () => {
const g: PaneGroups = { groups: [single(0), single(1), single(2)], active: 2 };
expect(removeSlot(g, 2)).toEqual({ groups: [single(0), single(1)], active: 1 });
expect(removeSlot({ ...g, active: 0 }, 0)).toEqual({ groups: [single(1), single(2)], active: 0 });
expect(removeSlot({ ...g, active: 0 }, 0)).toEqual({
groups: [single(1), single(2)],
active: 0,
});
expect(removeSlot(g, 7)).toBe(g);
});
it("prunes dead panes and gives every live session without a tab one", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)], active: 1 };
const g: PaneGroups = {
groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)],
active: 1,
};
const out = pruneGroups(g, new Set([0, 4]), new Set());
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0], [4]]);
expect(out.active).toBe(0);
@@ -258,7 +269,10 @@ describe("pane groups (tabs of up to four panes)", () => {
});
it("detaches a pane into a tab of its own at a given place, and reorders tabs", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(1)), focus: 0 }, single(2)], active: 1 };
const g: PaneGroups = {
groups: [{ root: split("row", pane(0), pane(1)), focus: 0 }, single(2)],
active: 1,
};
const out = detachSlot(g, 1, 0);
expect(out.groups.map((x) => leaves(x.root))).toEqual([[1], [0], [2]]);
expect(out.active).toBe(0);
@@ -272,19 +286,36 @@ describe("parseLayout / storage", () => {
beforeEach(() => window.localStorage.clear());
it("stores tabs per workspace and reads a pre-tab single layout as one tab", () => {
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(9)), focus: 9 }, { root: pane(4), focus: 4 }], active: 1 };
const g: PaneGroups = {
groups: [
{ root: split("row", pane(0), pane(9)), focus: 9 },
{ root: pane(4), focus: 4 },
],
active: 1,
};
saveGroups(1, g);
expect(loadGroups(1)).toEqual(g);
window.localStorage.setItem(
"agenthub.workspace.paneLayout",
JSON.stringify({ 2: { root: split("row", pane(0), pane(1)), focus: 1 } })
);
expect(loadGroups(2)).toEqual({ groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }], active: 0 });
expect(loadGroups(2)).toEqual({
groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }],
active: 0,
});
});
it("drops a slot past nine and a session stored in two tabs", () => {
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }], active: 0 })).toEqual(EMPTY_GROUPS);
const out = parseGroups({ groups: [{ root: pane(1), focus: 1 }, { root: pane(1), focus: 1 }], active: 1 });
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }], active: 0 })).toEqual(
EMPTY_GROUPS
);
const out = parseGroups({
groups: [
{ root: pane(1), focus: 1 },
{ root: pane(1), focus: 1 },
],
active: 1,
});
expect(out).toEqual({ groups: [{ root: pane(1), focus: 1 }], active: 0 });
});
@@ -334,3 +365,47 @@ describe("parseLayout / storage", () => {
expect(loadGroups(1)).toEqual(EMPTY_GROUPS);
});
});
describe("joinGroup", () => {
const solo = (s: number): PaneLayout => ({ root: pane(s), focus: s });
const tabs = (...slots: number[]): PaneGroups => ({ groups: slots.map(solo), active: 0 });
it("joins tabs in order: side by side, under the right pane, under the left one", () => {
let g = tabs(0, 1, 2, 3);
g = joinGroup(g, [1], 0);
expect(g.groups[0]?.root).toEqual(split("row", pane(0), pane(1)));
g = joinGroup(g, [2], 0);
expect(g.groups[0]?.root).toEqual(split("row", pane(0), split("col", pane(1), pane(2))));
g = joinGroup(g, [3], 0);
expect(g.groups[0]?.root).toEqual(
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)))
);
expect(g).toEqual({ groups: [g.groups[0]], active: 0 });
expect(g.groups[0]?.focus).toBe(3);
});
it("activates the target tab and takes a pane out of a combo", () => {
const combo: PaneLayout = { root: split("row", pane(1), pane(2)), focus: 1 };
const g = joinGroup({ groups: [solo(0), combo], active: 0 }, [2], 1);
expect(g.groups.map((l) => leaves(l.root))).toEqual([[0], [1, 2]]);
const h = joinGroup({ groups: [solo(0), combo], active: 1 }, [2], 0);
expect(h).toEqual({
groups: [
{ root: split("row", pane(0), pane(2)), focus: 2 },
{ root: pane(1), focus: 1 },
],
active: 0,
});
});
it("joins a whole combo, and refuses a fifth pane", () => {
const combo: PaneLayout = { root: split("row", pane(1), pane(2)), focus: 1 };
const g = joinGroup({ groups: [solo(0), combo], active: 1 }, [1, 2], 0);
expect(g.groups).toHaveLength(1);
expect(leaves(g.groups[0]?.root ?? null)).toEqual([0, 1, 2]);
const full = joinGroup(g, [], 0);
expect(full).toBe(g);
const four = joinGroup({ ...g, groups: [...g.groups, solo(3), solo(4)] }, [3], 0);
expect(joinGroup(four, [4], 0)).toBe(four);
});
});
+59
View File
@@ -529,6 +529,32 @@ function ccQuery(scope?: CcScope, cwd?: string): string {
* that needs to branch on which error this was should check `.code`, never
* guess at substrings of the human-readable `.message`.
*/
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
/** One account in Settings → Users (`GET /api/users`). */
export type ServerUserUsage = AuthUser & {
disabled: boolean;
workspaces: number;
sessions: number;
last7d: { cost: number; tokens: number };
last30d: { cost: number; tokens: number };
};
/** A staff read of a member's session (`GET /api/users/audit`). */
export type AuditEntry = {
id: number;
at: string;
method: string;
path: string;
session_id: string | null;
reader: string | null;
owner: string | null;
};
/** Who is using the dashboard: local mode has no login at all. */
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "member" };
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
/** GETs in flight, by path: concurrent callers of the same read (several
* components mounting, a WS event fanning out to many listeners) share one
* fetch and one parsed result. Deleted as soon as it settles, so this is
@@ -564,6 +590,11 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
// isn't JSON (or json() throws), fall back to an empty object so the `?.`
// chain below cleanly degrades to the generic `HTTP <status>` message.
const body = await res.json().catch(() => ({}));
// Server mode: the login ended (expired, logged out elsewhere, disabled) —
// AuthGate listens and shows the login page again.
if (res.status === 401 && body?.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
code: body?.error?.code,
status: res.status,
@@ -625,6 +656,26 @@ export function gitTarget(laneId: number, session?: string | null): GitTarget {
}
export const api = {
// ───────────────────────── Server mode login (routes/auth.js) ─────────────────────────
auth: {
/** GET /api/auth/me — `{mode:"local"}`, or server mode with the user (null = logged out). */
me: () => request<AuthState>("/auth/me"),
/** POST /api/auth/login — sets the httpOnly login cookie. */
login: (username: string, password: string) =>
request<{ user: AuthUser }>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
/** POST /api/auth/logout — ends this login. */
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () => request<{ users: ServerUserUsage[] }>("/users"),
/** GET /api/users/audit — staff reads of members' sessions, newest first. */
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
},
// ───────────────────────── Updates / self-update API ─────────────────────────
/** Self-update status: whether this install is a git clone and, if so,
* whether the tracked upstream/origin remote is ahead. Backs the "update
@@ -2318,6 +2369,14 @@ export const api = {
body: file,
headers: { "Content-Type": file.type },
}),
/** POST /api/run/drop-file - the same for any file, keeping its (sanitized)
* name so the agent sees what it is. */
dropFile: (file: File) =>
request<{ path: string }>(`/run/drop-file?name=${encodeURIComponent(file.name)}`, {
method: "POST",
body: file,
headers: { "Content-Type": "application/octet-stream" },
}),
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
start: (args: RunStartArgs) =>
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
+29
View File
@@ -327,6 +327,35 @@ export function detachSlot(g: PaneGroups, slot: number, at: number): PaneGroups
return compact(groups, at);
}
/** Where a pane joining a tab lands: right of a lone pane, then under the
* right pane, then under the left one (2×2 at four). */
function joinLayout(layout: PaneLayout, slot: number): PaneLayout {
const shown = leaves(layout.root);
if (!layout.root) return solo(slot);
const [target, edge]: [number, Edge] =
shown.length === 1
? [shown[0] as number, "right"]
: [(shown.length === 2 ? shown[1] : shown[0]) as number, "bottom"];
return { root: splitLeaf(layout.root, target, slot, edge), focus: slot };
}
/** Moves `slots` (a dropped tab, or one pane of a combo) out of their tabs
* into the tab at `to`, which becomes active. Same object back when nothing
* would move or the tab would pass `MAX_PANES`. */
export function joinGroup(g: PaneGroups, slots: number[], to: number): PaneGroups {
const dst = g.groups[to];
if (!dst) return g;
const moving = slots.filter((s) => {
const from = groupOf(g, s);
return from >= 0 && from !== to;
});
if (!moving.length || leaves(dst.root).length + moving.length > MAX_PANES) return g;
const groups = g.groups.map((l, i) =>
i === to ? moving.reduce(joinLayout, l) : moving.reduce(closeInLayout, l)
);
return compact(groups, to);
}
/** Moves the tab at `from` before the one at `at`; the active tab stays active. */
export function reorderGroups(g: PaneGroups, from: number, at: number): PaneGroups {
if (from < 0 || from === at || from + 1 === at) return g;
+6 -2
View File
@@ -63,11 +63,13 @@ import "@fontsource/jetbrains-mono/latin-500.css";
import "@fontsource/jetbrains-mono/vietnamese-400.css";
import "@fontsource/jetbrains-mono/vietnamese-500.css";
import App from "./App";
import { AuthGate } from "./components/AuthGate";
import { i18nReady } from "./i18n";
import "./index.css";
// Deep link into the Workspace: start its (lazy) chunk now, in parallel with the
// locale fetch below, instead of after the first render asks for it.
if (location.pathname.startsWith("/workspace")) void import("./pages/Workspace");
import "./index.css";
if ("serviceWorker" in navigator) {
// Detect whether the page is already controlled by an SW *before* we
@@ -103,7 +105,9 @@ void i18nReady
.then(() =>
createRoot(root).render(
<StrictMode>
<App />
<AuthGate>
<App />
</AuthGate>
</StrictMode>
)
);
+6 -2
View File
@@ -77,6 +77,7 @@ import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { downloadText, exportName, toCsv } from "../lib/exportData";
import { toast } from "../lib/toast";
import { useDataScope } from "../lib/dataScope";
@@ -234,6 +235,7 @@ export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {})
useEffect(() => {
load();
}, [load]);
const [spinning, refresh] = useSpin(() => load());
// Agent labels for agents on screen not asked about yet — keyed by agent, so
// a subagent spawned later in a live session gets its label too.
@@ -401,12 +403,14 @@ export function ActivityFeed({ embedded = false }: { embedded?: boolean } = {})
<Download className="w-4 h-4" /> {t("exportJson")}
</button>
<button
onClick={() => load()}
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
aria-label={t("common:refresh")}
title={t("common:refresh")}
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
</div>
</div>
+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
+6 -2
View File
@@ -84,6 +84,7 @@ import {
import { api, type RunHandle } from "../lib/api";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
import { useSpin } from "../hooks/useSpin";
import { workspaceHref } from "../components/SessionRowMenu";
import { downloadText, exportName, toCsv } from "../lib/exportData";
import { toast } from "../lib/toast";
@@ -275,6 +276,7 @@ export function SessionDetail() {
setLoading(false);
}
}, [id, t]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
@@ -675,12 +677,14 @@ export function SessionDetail() {
<SessionResumeButton session={session} />
<SnapshotButton sessionId={session.id} />
<button
onClick={load}
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
aria-label={t("common:refresh")}
title={t("common:refresh")}
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
</button>
</div>
+5 -2
View File
@@ -71,6 +71,7 @@ import { api } from "../lib/api";
import type { RemoteSource, RunHandle } from "../lib/api";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
import { useSpin } from "../hooks/useSpin";
import { useAiTools } from "../hooks/useAiTools";
import { useLanes } from "../hooks/useLanes";
import { toast } from "../lib/toast";
@@ -238,6 +239,7 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
// `scope` is a dep so a data-scope change re-fetches; the api layer injects
// the matching `sources` param.
}, [query, page, scope, t]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
@@ -390,8 +392,9 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
>
<Download className="w-4 h-4" /> {t("exportJson")}
</button>
<button onClick={load} className="btn-ghost">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
<button onClick={refresh} disabled={spinning} aria-busy={spinning} className="btn-ghost">
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
</div>
+10 -2
View File
@@ -120,6 +120,7 @@ import type { WSMessage } from "../lib/types";
import { confirmDialog } from "../lib/confirm";
import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog";
import { useSpin } from "../hooks/useSpin";
import { exportPrefs, importPrefs } from "../lib/prefsBackup";
/** The fewest milliseconds between two activity-driven `/settings/info` reads:
@@ -249,6 +250,7 @@ export function Settings({
setLoading(false);
}
}, [t, needsInfo, section]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
@@ -814,8 +816,14 @@ export function Settings({
<FileDown className="w-3.5 h-3.5" />
{t("exportData")}
</a>
<button onClick={load} className="btn-ghost">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
<button
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
</div>
+30 -1
View File
@@ -41,6 +41,7 @@ import {
Palette,
Router,
Search,
UsersRound,
} from "lucide-react";
import { Settings } from "./Settings";
import { CcConfig } from "./CcConfig";
@@ -64,6 +65,8 @@ import {
import { Skeleton } from "../components/Skeleton";
import { AiToolIcon } from "../components/AiToolIcon";
import { toast } from "../lib/toast";
import { useAuth } from "../components/AuthGate";
import { UsersSettings } from "../components/settings/UsersSettings";
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
type Group = (typeof GROUPS)[number];
@@ -124,6 +127,15 @@ const MOVED: Record<string, string> = {
"ai-router": "ai-proxy",
};
/** Sections whose API a server-mode member may not reach (STAFF_ONLY_PREFIXES in server/lib/auth.js). */
const STAFF_SECTIONS = new Set([
"data",
"reports",
"agents/claude-code",
"agents/codex",
"git-credentials",
]);
/** An unknown section: say so, then show Appearance. */
function UnknownSection({ name }: { name: string }) {
const { t } = useTranslation("settings");
@@ -205,12 +217,15 @@ export function SettingsHub() {
};
}, [location.key, location.hash]);
const auth = useAuth();
const isMember = auth.state.mode === "server" && auth.state.user?.role === "member";
const isStaff = auth.state.mode === "server" && !isMember && Boolean(auth.state.user);
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
const s = (key: string) => ({
title: t(`settingsNav.section.${key}.title`),
description: t(`settingsNav.section.${key}.description`),
});
const sections: Section[] = [
const allSections: Section[] = [
{
id: "data",
group: "general",
@@ -246,6 +261,17 @@ export function SettingsHub() {
...s("reports"),
render: () => <ReportSettings />,
},
...(isStaff
? [
{
id: "users",
group: "general" as const,
Icon: UsersRound,
...s("users"),
render: () => <UsersSettings />,
},
]
: []),
{
id: "notifications",
group: "general",
@@ -342,6 +368,9 @@ export function SettingsHub() {
render: () => <WorktreeVisibilitySettings />,
},
];
// Server mode: a member cannot open the host's own configuration or the
// all-users views (the server answers 403 EADMIN), so those pages are hidden.
const sections = isMember ? allSections.filter((x) => !STAFF_SECTIONS.has(x.id)) : allSections;
const entries: SettingsEntry[] = sections.flatMap((sec) => [
{ section: sec.id, title: sec.title, description: sec.description, sectionTitle: true },
+10 -5
View File
@@ -78,6 +78,7 @@ import { api } from "../lib/api";
import type { DateRange } from "../lib/dateRange";
import { eventBus } from "../lib/eventBus";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import type { WorkflowData, WSMessage } from "../lib/types";
import { WorkflowStats } from "../components/workflows/WorkflowStats";
@@ -150,10 +151,7 @@ export function Workflows({
msg.type === "workflow_upserted",
});
const handleRefresh = () => {
setLoading(true);
fetchData();
};
const [refreshing, handleRefresh] = useSpin(fetchData);
const handleExport = () => {
if (!data) return;
@@ -174,6 +172,7 @@ export function Workflows({
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
refreshing={refreshing}
onExport={handleExport}
lastUpdated={null}
/>
@@ -197,6 +196,7 @@ export function Workflows({
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
refreshing={refreshing}
onExport={handleExport}
lastUpdated={null}
/>
@@ -221,6 +221,7 @@ export function Workflows({
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onRefresh={handleRefresh}
refreshing={refreshing}
onExport={handleExport}
lastUpdated={lastUpdated}
/>
@@ -499,6 +500,7 @@ function PageHeader({
statusFilter,
onStatusFilterChange,
onRefresh,
refreshing = false,
onExport,
lastUpdated,
}: {
@@ -507,6 +509,7 @@ function PageHeader({
statusFilter: StatusFilter;
onStatusFilterChange: (f: StatusFilter) => void;
onRefresh: () => void;
refreshing?: boolean;
onExport: () => void;
lastUpdated: Date | null;
}) {
@@ -566,10 +569,12 @@ function PageHeader({
{/* Actions */}
<button
onClick={onRefresh}
disabled={refreshing}
aria-busy={refreshing}
className="p-2 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-3 transition-colors"
title={t("refreshData")}
>
<RefreshCw className="w-4 h-4" />
<RefreshCw className={`w-4 h-4 ${refreshing ? "animate-spin" : ""}`} />
</button>
<button
onClick={onExport}
@@ -157,6 +157,7 @@ exports[`screen snapshots > Activity feed 1`] = `
JSON
</button>
<button
aria-busy="false"
aria-label="Refresh"
class="btn-ghost"
title="Refresh"
@@ -3220,6 +3221,7 @@ exports[`screen snapshots > Dashboard 1`] = `
</div>
</div>
<button
aria-busy="false"
class="btn-ghost"
>
<svg
@@ -3292,7 +3294,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="A session is one conversation with an AI agent, from start to finish."
>
Total Sessions
</span>
@@ -3339,7 +3342,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="Agents are the AI workers: the main one in each session plus any helpers it started."
>
Total Agents
</span>
@@ -3405,7 +3409,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="Tokens are the pieces of text the AI reads and writes. Roughly 1 token ≈ 4 characters. More tokens = more cost."
>
Tokens (excl. cache reads)
</span>
@@ -3487,7 +3492,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="Estimated money spent, calculated from token counts and each model's price."
>
Total Cost
</span>
@@ -3541,7 +3547,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="flex items-center justify-between gap-3 mb-3"
>
<span
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate"
class="text-xs font-medium text-fg-muted uppercase tracking-wider truncate cursor-help underline decoration-dotted underline-offset-2"
title="Every step the AI takes (running a tool, finishing a reply…) is recorded as one event."
>
Total Events
</span>
@@ -5386,7 +5393,8 @@ exports[`screen snapshots > Dashboard 1`] = `
class="card p-5"
>
<h3
class="text-sm font-medium text-fg-secondary mb-5"
class="text-sm font-medium text-fg-secondary mb-5 cursor-help underline decoration-dotted underline-offset-2 w-fit"
title="How fast money is being spent, and where this month will end up if that speed continues."
>
Spend pace
</h3>
@@ -6018,6 +6026,7 @@ exports[`screen snapshots > Session detail 1`] = `
</button>
</div>
<button
aria-busy="false"
aria-label="Refresh"
class="btn-ghost"
title="Refresh"
@@ -6210,7 +6219,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-surface-3 bg-surface-2 text-fg-secondary"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="Every step the AI takes (running a tool, finishing a reply…) is recorded as one event."
>
<span
class="text-fg-muted"
@@ -6232,7 +6242,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Events
<span
class="underline decoration-dotted underline-offset-2"
>
Events
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6244,7 +6258,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-syntax-keyword/20 bg-syntax-keyword/5 text-syntax-keyword"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="How many actions the AI took: reading a file, editing, running a command…"
>
<span
class="text-syntax-keyword"
@@ -6266,7 +6281,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Tool calls
<span
class="underline decoration-dotted underline-offset-2"
>
Tool calls
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6278,7 +6297,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-syntax-property/20 bg-syntax-property/5 text-syntax-property"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="Helper agents the main AI started to work on part of the task in parallel."
>
<span
class="text-syntax-property"
@@ -6316,7 +6336,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Subagents
<span
class="underline decoration-dotted underline-offset-2"
>
Subagents
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6328,7 +6352,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-syntax-builtin/20 bg-syntax-builtin/5 text-syntax-builtin"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="When a conversation gets too long, it is summarized to make room. Early details can be lost."
>
<span
class="text-syntax-builtin"
@@ -6356,7 +6381,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Compactions
<span
class="underline decoration-dotted underline-offset-2"
>
Compactions
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6368,7 +6397,8 @@ exports[`screen snapshots > Session detail 1`] = `
class="rounded-lg border px-3 py-2.5 border-surface-3 bg-surface-2 text-fg-secondary"
>
<div
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted"
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted cursor-help"
title="Steps that failed. A few are normal; many may mean the AI got stuck."
>
<span
class="text-fg-muted"
@@ -6396,7 +6426,11 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Errors
<span
class="underline decoration-dotted underline-offset-2"
>
Errors
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6435,7 +6469,9 @@ exports[`screen snapshots > Session detail 1`] = `
/>
</svg>
</span>
Duration
<span>
Duration
</span>
</div>
<div
class="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight"
@@ -6830,6 +6866,7 @@ exports[`screen snapshots > Sessions 1`] = `
JSON
</button>
<button
aria-busy="false"
class="btn-ghost"
>
<svg
@@ -7521,6 +7558,7 @@ exports[`screen snapshots > Settings — data 1`] = `
Export Data
</a>
<button
aria-busy="false"
class="btn-ghost"
>
<svg
@@ -9857,6 +9895,7 @@ exports[`screen snapshots > Workflows 1`] = `
</button>
</div>
<button
aria-busy="false"
class="p-2 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-3 transition-colors"
title="Refresh data"
>