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:
+9
-3
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`} />
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user