chore: maintenance — dependency majors, rename step 4, member Claude config, OpenAPI coverage

Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
  SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
  rule for sendFile/download, alerts ack route without inline regex),
  better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
  schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
  DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
  TypeScript 7, i18next/react-i18next/codemirror minors.

Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
  the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.

Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
  and gates the router by allowlist; /profiles* and /plugins/update-all stay
  admin-only. Member writes go through write-as, CLI calls through sudo as the
  member (never -y), reads through openSafe; cc_config_changed reaches only
  its owner. Not verified live on an S3 install yet.

Other
- Chat: an HTML file is an artifact only when written whole and not a project
  page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
This commit is contained in:
2026-10-26 14:22:41 +07:00
parent cc2ece94c3
commit 8546748759
102 changed files with 4165 additions and 2004 deletions
+1 -1
View File
@@ -93,7 +93,7 @@ export const SagiAvatar = memo(function SagiAvatar({
moment = null,
}: SagiAvatarProps) {
const rootRef = useRef<SVGSVGElement | null>(null);
const rafRef = useRef<number>();
const rafRef = useRef<number | undefined>(undefined);
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
+3 -3
View File
@@ -197,11 +197,11 @@ export function useSagiBrain(): SagiBrain {
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
const reactionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
const bubbleTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const momentTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const lastBubbleAt = useRef(0);
const burst = useRef<Burst | null>(null);
const momentKey = useRef(0);
+1 -1
View File
@@ -279,7 +279,7 @@ function QuotaChips() {
const [open, setOpen] = useState(false);
// Leaving the chips closes the panel only after a beat, and entering the
// panel cancels that: the pointer crosses the gap to read it.
const closeTimer = useRef<number>();
const closeTimer = useRef<number | undefined>(undefined);
const show = () => {
window.clearTimeout(closeTimer.current);
setOpen(true);
@@ -13,7 +13,7 @@ import { AgentCard } from "../AgentCard";
import type { Agent } from "../../lib/types";
import { formatModelName, fmtCost } from "../../lib/format";
function renderCard(element: JSX.Element) {
function renderCard(element: React.JSX.Element) {
return render(<MemoryRouter>{element}</MemoryRouter>);
}
@@ -151,7 +151,7 @@ import { api } from "../../lib/api";
import { StatusBar } from "../StatusBar";
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
const at = (url: string, ui: JSX.Element) =>
const at = (url: string, ui: React.JSX.Element) =>
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
describe("StatusBar", () => {
@@ -382,7 +382,8 @@ describe("LaneChangesPanel", () => {
const box = within(folder.parentElement!).getByRole("checkbox", {
name: "Tick every file in server/lib",
});
expect(box).toBeChecked();
// The default ticks land in an effect after the list renders.
await waitFor(() => expect(box).toBeChecked());
await user.click(box);
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
await user.click(folder);
+1 -1
View File
@@ -765,7 +765,7 @@ export function Divider({
onRatio,
label,
}: {
gridRef: React.RefObject<HTMLDivElement>;
gridRef: React.RefObject<HTMLDivElement | null>;
dir: SplitDir;
at: number;
box: Rect;
+55 -14
View File
@@ -48,7 +48,9 @@
* most every 150 ms. Several browsers on one pane share ONE size: the server
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
* here takes the lead back (`resize` with `drive: true`).
* here takes the lead back (`resize` with `drive: true`). Cells are drawn by
* the WebGL addon (the DOM renderer after a lost context), and Alt+arrow keeps
* the word-move keys xterm 5 sent (`altArrowSeq`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
@@ -63,7 +65,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import type { Terminal } from "@xterm/xterm";
import type { CanvasAddon } from "@xterm/addon-canvas";
import type { WebglAddon } from "@xterm/addon-webgl";
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
import type { SearchAddon } from "@xterm/addon-search";
import { ArrowDown, ArrowUp, X } from "lucide-react";
@@ -123,10 +125,10 @@ const clipboardProvider: IClipboardProvider = {
* Workspace chunk. The result is cached, so every later pane (and a test that
* awaits `loadXterm()` first) sets up synchronously. */
const importXterm = async () => {
const [xterm, fit, canvas, clipboard, search, links] = await Promise.all([
const [xterm, fit, webgl, clipboard, search, links] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-canvas"),
import("@xterm/addon-webgl"),
import("@xterm/addon-clipboard"),
import("@xterm/addon-search"),
import("@xterm/addon-web-links"),
@@ -134,7 +136,7 @@ const importXterm = async () => {
return {
Terminal: xterm.Terminal,
FitAddon: fit.FitAddon,
CanvasAddon: canvas.CanvasAddon,
WebglAddon: webgl.WebglAddon,
SearchAddon: search.SearchAddon,
WebLinksAddon: links.WebLinksAddon,
// The addon's typings declare one argument, its code takes (base64, provider).
@@ -158,6 +160,25 @@ export function loadXterm(): Promise<Xterm> {
return xtermLoading;
}
const IS_MAC = /^Mac/.test(typeof navigator === "undefined" ? "" : navigator.platform);
/** What xterm 5 sent for a bare Alt+arrow (its Keyboard.ts "HACK"s, removed
* in xterm 6): Ctrl+arrow's sequence, except macOS's left/right, which are
* readline's backward-word / forward-word and whose up/down stay Alt's. */
export function altArrowSeq(key: string, mac = IS_MAC): string | null {
switch (key) {
case "ArrowLeft":
return mac ? "\x1bb" : "\x1b[1;5D";
case "ArrowRight":
return mac ? "\x1bf" : "\x1b[1;5C";
case "ArrowUp":
return mac ? null : "\x1b[1;5A";
case "ArrowDown":
return mac ? null : "\x1b[1;5B";
default:
return null;
}
}
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
const RETRY_MIN_MS = 1500;
const RETRY_MAX_MS = 30_000;
@@ -369,7 +390,7 @@ export const TerminalView = memo(function TerminalView({
const attach = ({
Terminal,
FitAddon,
CanvasAddon,
WebglAddon,
SearchAddon,
WebLinksAddon,
ClipboardWithProvider,
@@ -379,7 +400,7 @@ export const TerminalView = memo(function TerminalView({
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
// substitutes those specific glyphs from a different face with a
// mismatched advance width, corrupting the cell it's drawn into even
// under the canvas renderer (which only stops a glyph from shoving its
// under the WebGL renderer (which only stops a glyph from shoving its
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
// already bundled for the rest of the UI; main.tsx additionally imports
// its `vietnamese` subset so this covers the diacritics too. Settings →
@@ -425,14 +446,23 @@ export const TerminalView = memo(function TerminalView({
// face whose advance width differs, so glyphs shove their neighbours along
// and the row's tail spills onto the row below. The buffer is correct
// throughout (copying the line yields clean text); only the painting drifts,
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D
// context keeps the DOM renderer rather than losing the terminal.
let canvas: CanvasAddon | null = null;
// and no CSS can correct it. xterm 6 dropped its canvas renderer, so the
// cells are drawn by the WebGL one. Best-effort: a browser with no WebGL2
// keeps the DOM renderer rather than losing the terminal, and so does a
// pane whose context the browser takes back (GPU reset, or past the
// browser's cap of live contexts with many panes open) — disposing the
// addon hands the drawing back to the DOM renderer.
let gpu: WebglAddon | null = null;
try {
canvas = new CanvasAddon();
term.loadAddon(canvas);
const addon = new WebglAddon();
term.loadAddon(addon);
gpu = addon;
addon.onContextLoss(() => {
addon.dispose();
if (gpu === addon) gpu = null;
});
} catch {
canvas = null;
gpu = null;
}
fit.fit();
@@ -522,6 +552,17 @@ export const TerminalView = memo(function TerminalView({
term.input("\x1f", true);
return false;
}
// Alt+arrow moves by word, as it did before xterm 6 dropped its own
// mapping (xterm.js #5346): Ctrl+arrow's sequence, or readline's
// ESC b / ESC f for left/right on macOS — the same keys tmux,
// bash and claude's prompt have always been handed from this pane.
const altArrow =
ev.altKey && !ev.ctrlKey && !ev.metaKey && !ev.shiftKey ? altArrowSeq(ev.key) : null;
if (altArrow) {
ev.preventDefault();
term.input(altArrow, true);
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true;
@@ -734,7 +775,7 @@ export const TerminalView = memo(function TerminalView({
resizeDisposable.dispose();
dataDisposable.dispose();
ws?.close();
canvas?.dispose();
gpu?.dispose();
term.dispose();
termRef.current = null;
};
@@ -6,7 +6,9 @@
* included) as outgoing binary frames, and
* re-attaches after the socket drops (server restart) with backoff, but not
* after `exit` or a policy close,
* and recolours live on a dark-theme change without re-attaching.
* recolours live on a dark-theme change without re-attaching, draws through
* the WebGL addon (DOM renderer again on a lost context) and keeps xterm 5's
* Alt+arrow word moves.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
@@ -19,7 +21,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { TerminalView, loadXterm } from "../TerminalView";
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
@@ -109,8 +111,24 @@ vi.mock("../../../lib/terminalTheme", () => ({
vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
}));
vi.mock("@xterm/addon-canvas", () => ({
CanvasAddon: vi.fn().mockImplementation(() => ({ __canvas: true, dispose: vi.fn() })),
// The newest WebGL addon, with the context-loss listener the view gave it.
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
last: null,
};
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn().mockImplementation(() => {
let onLoss = () => {};
const addon = {
__webgl: true,
dispose: vi.fn(),
onContextLoss: (fn: () => void) => {
onLoss = fn;
return { dispose: vi.fn() };
},
};
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
return addon;
}),
}));
class MockWebSocket {
@@ -397,18 +415,52 @@ describe("TerminalView", () => {
expect(onExit).toHaveBeenCalledTimes(1);
});
it("renders through the canvas addon, not xterm's DOM renderer", () => {
it("renders through the WebGL addon, not xterm's DOM renderer", () => {
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
// fallback face with a different advance, so glyphs shove their neighbours
// and the row's tail spills onto the row below — buffer clean, painting
// wrong. The canvas renderer draws each cell at a fixed position instead.
// wrong. The WebGL renderer draws each cell at a fixed position instead
// (xterm 6 removed the canvas one).
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __canvas?: boolean }).__canvas)).toBe(
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
true
);
});
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const addon = gpu.last!;
expect(addon.dispose).not.toHaveBeenCalled();
addon.lose();
expect(addon.dispose).toHaveBeenCalledTimes(1);
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
});
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
expect(altArrowSeq("ArrowRight", false)).toBe("\x1b[1;5C");
expect(altArrowSeq("ArrowUp", false)).toBe("\x1b[1;5A");
expect(altArrowSeq("ArrowDown", false)).toBe("\x1b[1;5B");
expect(altArrowSeq("ArrowLeft", true)).toBe("\x1bb");
expect(altArrowSeq("ArrowRight", true)).toBe("\x1bf");
expect(altArrowSeq("ArrowUp", true)).toBeNull();
expect(altArrowSeq("a", false)).toBeNull();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
const ev = new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, cancelable: true });
expect(handler(ev)).toBe(false);
expect(ev.defaultPrevented).toBe(true);
expect(inputMock).toHaveBeenCalledWith(altArrowSeq("ArrowLeft"), true);
// Another modifier with it is xterm's own sequence.
expect(
handler(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, shiftKey: true }))
).toBe(true);
});
describe("size reporting (tmux must never be wider than the terminal)", () => {
// A tmux pane wider than xterm wraps each over-long row's tail onto the
// next row's left margin, and tmux never repaints a row it believes
+41
View File
@@ -0,0 +1,41 @@
/**
* @file useCcTarget — whose Claude Code config the Config Explorer manages for
* this login (GET /api/cc-config/target). Local mode and an admin: the host's,
* answered at once without a request (nothing changes for them). A non-admin
* in server mode asks the server: `own` when they have their own Linux account
* (S3) — Settings then shows "Your Claude Code config" — else `none` and the
* page stays hidden. The server's refusal (403 ENOACCOUNT/EADMIN) is the rule;
* this only hides what would be refused.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { api, type CcTarget } from "../lib/api";
import { usePermissions } from "../components/AuthGate";
const HOST: CcTarget = { kind: "host", readOnly: false, claudeHome: null };
/** `null` while a non-admin's answer is on its way. */
export function useCcTarget(): CcTarget | null {
const { isAdmin } = usePermissions();
const [target, setTarget] = useState<CcTarget | null>(isAdmin ? HOST : null);
useEffect(() => {
if (isAdmin) {
setTarget(HOST);
return;
}
let live = true;
api.ccConfig
.target()
.then((t) => {
if (live) setTarget(t);
})
.catch(() => {
if (live) setTarget({ kind: "none", readOnly: true, claudeHome: null });
});
return () => {
live = false;
};
}, [isAdmin]);
return target;
}
+2 -2
View File
@@ -27,9 +27,9 @@ const FOCUSABLE =
*/
export function useDialog(
open: boolean,
panelRef: RefObject<HTMLElement>,
panelRef: RefObject<HTMLElement | null>,
onClose?: () => void,
initialFocus?: RefObject<HTMLElement>
initialFocus?: RefObject<HTMLElement | null>
): void {
// Read through a ref so an inline `onClose` does not re-run the effect, which
// would steal focus back to the first control on every parent render.
+3 -3
View File
@@ -63,7 +63,7 @@ type MessageHandler = (msg: WSMessage) => void;
* auto-reconnects with capped exponential backoff on close - plus an
* immediate reconnect attempt on tab focus/network-online/visibility-change
* so the socket recovers quickly after a server restart or laptop sleep.
* Guards against React 18 StrictMode's mount→cleanup→remount cycle opening a
* Guards against React StrictMode's mount→cleanup→remount cycle opening a
* duplicate socket (see the inline comment in `connect`).
* @param onMessage Called with every message parsed from the socket; the
* latest reference is used even across reconnects (no stale closures).
@@ -73,7 +73,7 @@ export function useWebSocket(onMessage: MessageHandler) {
const wsRef = useRef<WebSocket | null>(null);
const handlersRef = useRef<MessageHandler>(onMessage);
const [connected, setConnected] = useState(false);
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const mountedRef = useRef(true);
const reconnectAttempts = useRef(0);
@@ -82,7 +82,7 @@ export function useWebSocket(onMessage: MessageHandler) {
const connect = useCallback(() => {
if (!mountedRef.current) return;
// Don't open a second socket if one is already alive or in flight.
// Without this, React 18 StrictMode (mount → cleanup → remount in dev)
// Without this, React StrictMode (mount → cleanup → remount in dev)
// and the close→reconnect race could leave two sockets connected at the
// same time. Both would receive every server broadcast, producing
// duplicate stream_event deltas (doubled text, duplicate assistant
+5
View File
@@ -1,6 +1,10 @@
{
"title": "Claude Config Explorer",
"subtitle": "Your Claude Code setup at a glance - plugins, skills, subagents, MCP servers, hooks, and settings.",
"own": {
"label": "Your Claude Code config — {{home}}",
"subtitle": "Only your own account's setup: every change here runs as your account and reaches only your own sessions."
},
"refresh": "Refresh",
"refreshing": "Refreshing…",
"lastUpdated": "Last updated {{time}}",
@@ -30,6 +34,7 @@
},
"overview": {
"rootsTitle": "Roots",
"claudeHome": "Your .claude",
"projectClaudeDir": "Project .claude",
"projectRoot": "Project root",
"claudeJson": "claude.json",
+4
View File
@@ -1033,6 +1033,10 @@
"title": "Claude Code",
"description": "Claude Code's own configuration, and what AgentHub reads from it."
},
"claudeCodeOwn": {
"title": "Your Claude Code config",
"description": "The skills, agents, plugins, MCP servers and settings your own sessions use."
},
"codex": {
"title": "Codex",
"description": "Codex's own configuration, and what AgentHub reads from it."
+5
View File
@@ -1,6 +1,10 @@
{
"title": "Trình khám phá cấu hình Claude",
"subtitle": "Toàn cảnh thiết lập Claude Code của bạn - plugin, skill, subagent, máy chủ MCP, hook và cài đặt.",
"own": {
"label": "Cấu hình Claude Code của bạn — {{home}}",
"subtitle": "Chỉ thiết lập của tài khoản bạn: mọi thay đổi ở đây chạy dưới tài khoản của bạn và chỉ áp dụng cho các phiên của chính bạn."
},
"refresh": "Làm mới",
"refreshing": "Đang làm mới…",
"lastUpdated": "Cập nhật lần cuối {{time}}",
@@ -30,6 +34,7 @@
},
"overview": {
"rootsTitle": "Thư mục gốc",
"claudeHome": ".claude của bạn",
"projectClaudeDir": "Dự án .claude",
"projectRoot": "Thư mục dự án",
"claudeJson": "claude.json",
+4
View File
@@ -1021,6 +1021,10 @@
"title": "Claude Code",
"description": "Cấu hình của Claude Code và những gì AgentHub đọc từ đó."
},
"claudeCodeOwn": {
"title": "Cấu hình Claude Code của bạn",
"description": "Skill, agent, plugin, MCP server và thiết lập mà các phiên của chính bạn dùng."
},
"codex": {
"title": "Codex",
"description": "Cấu hình của Codex và những gì AgentHub đọc từ đó."
+5
View File
@@ -717,6 +717,11 @@
.xterm-viewport::-webkit-scrollbar {
display: none;
}
/* xterm 6 draws its own scrollbar (VS Code's scrollable element) instead of
the viewport's native one: the same dead affordance, hidden the same way. */
.xterm .xterm-scrollable-element > .scrollbar {
display: none;
}
/* Settings → Appearance → Text size (lib/fontSize.ts) sets the ROOT font size.
Tailwind's own sizes are rem and follow it; these are the `text-[Npx]`
@@ -49,6 +49,16 @@ describe("artifactView", () => {
expect(isArtifact("server.js", true)).toBe(false);
});
it("never takes the project's own HTML pages for artifacts", () => {
expect(isArtifact("/tmp/out/report.html", true)).toBe(true);
expect(isArtifact("/tmp/out/report.html", false)).toBe(false);
expect(isArtifact("/repo/index.html", true)).toBe(false);
expect(isArtifact("/repo/client/INDEX.HTM", true)).toBe(false);
expect(isArtifact("/repo/public/about.html", true)).toBe(false);
expect(isArtifact("C:\\repo\\templates\\mail.html", true)).toBe(false);
expect(isArtifact("/repo/docs/chart.html", true)).toBe(true);
});
it("rebuilds a file's versions from Write, Edit and MultiEdit", () => {
const msgs = [
use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }),
@@ -31,9 +31,9 @@ describe("git targets", () => {
await api.lanes.gitAction(gitTarget(3, "s-1"), "stage", { paths: ["a"] });
await api.lanes.git(gitTarget(3, null));
const urls = spy.mock.calls.map((c) => String(c[0]));
expect(urls[0]).toMatch(/\/lanes\/3\/git\/changes\?range=work$/);
expect(urls[1]).toMatch(/\/lanes\/3\/git\/changes\?range=work&session=s-1$/);
expect(urls[2]).toMatch(/\/lanes\/3\/git\/stage\?session=s-1$/);
expect(urls[3]).toMatch(/\/lanes\/3\/git$/);
expect(urls[0]).toMatch(/\/workspaces\/3\/git\/changes\?range=work$/);
expect(urls[1]).toMatch(/\/workspaces\/3\/git\/changes\?range=work&session=s-1$/);
expect(urls[2]).toMatch(/\/workspaces\/3\/git\/stage\?session=s-1$/);
expect(urls[3]).toMatch(/\/workspaces\/3\/git$/);
});
});
@@ -25,7 +25,7 @@ function stubFetch(body: unknown) {
}
describe("api.lanes.git", () => {
it("requests /api/lanes/<id>/git and returns the parsed facts", async () => {
it("requests /api/workspaces/<id>/git and returns the parsed facts", async () => {
const facts = {
available: true,
branch: "feat/x",
@@ -41,7 +41,7 @@ describe("api.lanes.git", () => {
expect(spy).toHaveBeenCalledTimes(1);
const call = spy.mock.calls[0] as [string, RequestInit | undefined];
// endsWith, not toContain: `/gitt` contains `/git` and would slip through.
expect(String(call[0]).endsWith("/api/lanes/3/git")).toBe(true);
expect(String(call[0]).endsWith("/api/workspaces/3/git")).toBe(true);
expect(call[1]?.method ?? "GET").toBe("GET");
expect(result).toEqual(facts);
});
+56 -41
View File
@@ -915,7 +915,7 @@ function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
if (typeof target !== "number" && target.repo) params.set("repo", target.repo);
const qs = params.toString();
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
return `/workspaces/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
}
/** Where the File Explorer works: a workspace (as {@link GitTarget}) or a user's home. */
@@ -929,7 +929,7 @@ function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
const qs = new URLSearchParams(q).toString();
return `/homes/${encodeURIComponent(place.home)}/fs${sub}${qs ? `?${qs}` : ""}`;
}
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs");
return gitUrl(place, sub, q).replace(/^(\/workspaces\/\d+)\/git/, "$1/fs");
}
/**
@@ -2010,7 +2010,7 @@ export const api = {
purged_agents: number;
}>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }),
gc: (body: { dryRun?: boolean } = {}) =>
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/lanes/gc", {
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/workspaces/gc", {
method: "POST",
body: JSON.stringify(body),
}),
@@ -2297,6 +2297,13 @@ export const api = {
* CLAUDE.md/auto-memory, marketplaces, keybindings, and the statusline
* script - for the dashboard's "CC Config" explorer/editor pages. */
ccConfig: {
/**
* GET /api/cc-config/target - whose Claude Code config this login manages
* here: `own` (server mode, their own account's), `host` (an admin, local
* mode, or an analyst's read-only view), `none` (no account: the page is
* not theirs).
*/
target: () => request<CcTarget>("/cc-config/target"),
/**
* GET /api/cc-config/overview - counts of every artifact kind, for the
* explorer's landing page.
@@ -3790,7 +3797,7 @@ export const api = {
* GET /api/lanes — list all lanes with pipeline progress and liveness.
* @returns `{ lanes, counts }` — all {@link Lane} records and aggregate counts.
*/
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/lanes"),
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/workspaces"),
/**
* POST /api/lanes/ensure — ensure a lane exists for the given cwd.
* @param body The cwd and optional title. When `cwd` is omitted the server
@@ -3806,7 +3813,7 @@ export const api = {
/** Only a lane on THIS directory counts, not one on a parent of it. */
exact?: boolean;
}) =>
request<{ lane: Lane; created: boolean }>("/lanes/ensure", {
request<{ lane: Lane; created: boolean }>("/workspaces/ensure", {
method: "POST",
body: JSON.stringify(body),
}),
@@ -3829,7 +3836,7 @@ export const api = {
/** This clone's name for {@link lanes.cloneProgress}. */
progressToken?: string;
}) =>
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
request<{ lane: Lane; created: boolean; path: string }>("/workspaces/clone", {
method: "POST",
body: JSON.stringify(body),
}),
@@ -3839,7 +3846,7 @@ export const api = {
* carrying `progressToken` arrives, and a minute after it ends.
*/
cloneProgress: (token: string) =>
request<CloneProgressView>(`/lanes/clone/progress/${encodeURIComponent(token)}`),
request<CloneProgressView>(`/workspaces/clone/progress/${encodeURIComponent(token)}`),
/**
* GET /api/lanes/pipelines — every pipeline template the server can render
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
@@ -3847,7 +3854,7 @@ export const api = {
*/
pipelines: () =>
request<{ pipelines: { id: string; name: string; nodes: { id: string }[] }[] }>(
"/lanes/pipelines"
"/workspaces/pipelines"
),
/**
* GET /api/lanes/pipeline-stats — per-node pass counts aggregated over every
@@ -3857,7 +3864,7 @@ export const api = {
*/
pipelineStats: (laneId?: number | null) =>
request<PipelineStats>(
`/lanes/pipeline-stats${laneId ? `?lane=${encodeURIComponent(laneId)}` : ""}`
`/workspaces/pipeline-stats${laneId ? `?lane=${encodeURIComponent(laneId)}` : ""}`
),
/**
* GET /api/lanes/branches — a candidate source repo's local branches.
@@ -3869,7 +3876,7 @@ export const api = {
*/
branches: (repo: string) =>
request<{ branches: string[]; current: string | null; worktrees: LaneWorktree[] }>(
`/lanes/branches?repo=${encodeURIComponent(repo)}`
`/workspaces/branches?repo=${encodeURIComponent(repo)}`
),
/**
* POST /api/lanes/worktree — provision a dashboard-managed git worktree as
@@ -3887,7 +3894,7 @@ export const api = {
pipeline?: string;
tool?: LaneTool;
}) =>
request<{ lane: Lane }>("/lanes/worktree", {
request<{ lane: Lane }>("/workspaces/worktree", {
method: "POST",
body: JSON.stringify(body),
}),
@@ -3922,7 +3929,7 @@ export const api = {
file?: boolean;
mtime?: number | null;
}[];
}>(`/lanes/browse${qs ? `?${qs}` : ""}`);
}>(`/workspaces/browse${qs ? `?${qs}` : ""}`);
},
/**
* POST /api/lanes/browse/mkdir — the folder browser's New folder: one
@@ -3930,7 +3937,7 @@ export const api = {
* `EFOLDER` (outside the caller's own folder).
*/
mkdir: (parent: string, name: string) =>
request<{ path: string }>("/lanes/browse/mkdir", {
request<{ path: string }>("/workspaces/browse/mkdir", {
method: "POST",
body: JSON.stringify({ parent, name }),
}),
@@ -3944,7 +3951,7 @@ export const api = {
* @returns `{ path }`, or `{ cancelled: true }` when the user cancelled.
*/
pickPath: (kind: "dir" | "file", start?: string) =>
request<{ path?: string; cancelled?: boolean }>("/lanes/pick-path", {
request<{ path?: string; cancelled?: boolean }>("/workspaces/pick-path", {
method: "POST",
body: JSON.stringify({ kind, start }),
}),
@@ -3953,7 +3960,7 @@ export const api = {
* @param id The lane id.
* @returns `{ lane }` — the {@link Lane} record.
*/
get: (id: number) => request<{ lane: Lane }>(`/lanes/${id}`),
get: (id: number) => request<{ lane: Lane }>(`/workspaces/${id}`),
/**
* GET /api/lanes/:id/git — the lane's working-copy facts.
* @param id The lane id.
@@ -4087,7 +4094,7 @@ export const api = {
*/
removeSessionWorktree: (id: number, session: string) =>
request<{ removed: true; branch: string | null; branchKept: boolean }>(
`/lanes/${id}/session-worktree/remove`,
`/workspaces/${id}/session-worktree/remove`,
{ method: "POST", body: JSON.stringify({ session, confirm: true }) }
),
gitBranches: (id: GitTarget) =>
@@ -4181,7 +4188,7 @@ export const api = {
* @param id The lane id.
* @returns {@link LaneRuntime}
*/
runtime: (id: number) => request<LaneRuntime>(`/lanes/${id}/runtime`),
runtime: (id: number) => request<LaneRuntime>(`/workspaces/${id}/runtime`),
/**
* POST /api/lanes/:id/up — boot the lane's stack through its profile hooks.
* Returns 202: the boot runs in the background and streams progress as
@@ -4190,7 +4197,7 @@ export const api = {
* @param body `build: false` reuses an existing build.
*/
up: (id: number, body: { build?: boolean } = {}) =>
request<{ ok: true; laneId: number }>(`/lanes/${id}/up`, {
request<{ ok: true; laneId: number }>(`/workspaces/${id}/up`, {
method: "POST",
body: JSON.stringify(body),
}),
@@ -4201,7 +4208,7 @@ export const api = {
* @returns The pids stopped and the resulting runtime facts.
*/
down: (id: number) =>
request<{ ok: true; killed: number[]; runtime: LaneRuntime }>(`/lanes/${id}/down`, {
request<{ ok: true; killed: number[]; runtime: LaneRuntime }>(`/workspaces/${id}/down`, {
method: "POST",
body: JSON.stringify({}),
}),
@@ -4211,7 +4218,7 @@ export const api = {
* @returns `{ lane }` — the created {@link Lane}.
*/
create: (body: { title?: string; cwd: string; branch?: string; pipeline?: string }) =>
request<{ lane: Lane }>("/lanes", { method: "POST", body: JSON.stringify(body) }),
request<{ lane: Lane }>("/workspaces", { method: "POST", body: JSON.stringify(body) }),
/**
* PATCH /api/lanes/:id — partial update of a lane.
* @param id The lane id.
@@ -4220,17 +4227,17 @@ export const api = {
*/
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
* folders and context files, and whether its tool can take them. */
sources: (id: number) => request<LaneSourcesView>(`/lanes/${id}/sources`),
sources: (id: number) => request<LaneSourcesView>(`/workspaces/${id}/sources`),
/** GET /api/lanes/:id/repos — the git repos inside the workspace folder
* (a parent folder of several repos), each a Changes section read with
* `?repo=<path>`; `root` = the folder itself is in a repo. */
repos: (id: number, refresh = false) =>
request<{ root: boolean; items: { path: string; name: string }[]; truncated: boolean }>(
`/lanes/${id}/repos${refresh ? "?refresh=1" : ""}`
`/workspaces/${id}/repos${refresh ? "?refresh=1" : ""}`
),
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
setInstructions: (id: number, instructions: string | null) =>
request<LaneSourcesView>(`/lanes/${id}/instructions`, {
request<LaneSourcesView>(`/workspaces/${id}/instructions`, {
method: "PUT",
body: JSON.stringify({ instructions }),
}),
@@ -4248,19 +4255,19 @@ export const api = {
progressToken?: string;
}
) =>
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
request<LaneSourcesView & { path: string }>(`/workspaces/${id}/sources/clone`, {
method: "POST",
body: JSON.stringify(body),
}),
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
addFolder: (id: number, path: string) =>
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, {
request<LaneSourcesView>(`/workspaces/${id}/sources/folder`, {
method: "POST",
body: JSON.stringify({ path }),
}),
/** POST /api/lanes/:id/sources/file — upload one context file (≤ 10 MB). */
addFile: (id: number, file: File) =>
request<LaneSourcesView>(`/lanes/${id}/sources/file`, {
request<LaneSourcesView>(`/workspaces/${id}/sources/file`, {
method: "POST",
body: file,
headers: {
@@ -4271,11 +4278,11 @@ export const api = {
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
* uploaded file's AgentHub copy is deleted. */
removeSource: (id: number, sourceId: number) =>
request<LaneSourcesView>(`/lanes/${id}/sources/${sourceId}`, { method: "DELETE" }),
request<LaneSourcesView>(`/workspaces/${id}/sources/${sourceId}`, { method: "DELETE" }),
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/lanes/${id}/memory`),
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/workspaces/${id}/memory`),
update: (id: number, patch: Partial<Lane>) =>
request<{ lane: Lane }>(`/lanes/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
request<{ lane: Lane }>(`/workspaces/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
/**
* POST /api/lanes/:id/stage — advance the pipeline stage.
* @param id The lane id.
@@ -4286,14 +4293,14 @@ export const api = {
id: number,
body: { stage: string; status?: string; evidence?: string; note?: string; result?: string }
) =>
request<{ lane: Lane }>(`/lanes/${id}/stage`, { method: "POST", body: JSON.stringify(body) }),
request<{ lane: Lane }>(`/workspaces/${id}/stage`, { method: "POST", body: JSON.stringify(body) }),
/**
* GET /api/lanes/:id/preflight?action=reset|remove|purge — read the
* action-specific facts that a destructive request must echo as `expect`.
*/
preflight: (id: number, action: "reset" | "remove" | "purge") =>
request<import("./types").LanePreflight>(
`/lanes/${id}/preflight?${new URLSearchParams({ action }).toString()}`
`/workspaces/${id}/preflight?${new URLSearchParams({ action }).toString()}`
),
/**
* POST /api/lanes/:id/:action — trigger a lane action (start, stop, clear, remove, etc.).
@@ -4305,7 +4312,7 @@ export const api = {
action: (id: number, action: string, body: Record<string, unknown> = {}) =>
// `run_id` answers a `start`: the session it opened, which for a split
// pane's slot (`body.slot` 1–3) is not the lane's own `run_id`.
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/lanes/${id}/${action}`, {
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/workspaces/${id}/${action}`, {
method: "POST",
body: JSON.stringify(body),
}),
@@ -4316,7 +4323,7 @@ export const api = {
*/
features: {
list: (id: number) =>
request<{ features: import("./types").LaneFeature[] }>(`/lanes/${id}/features`),
request<{ features: import("./types").LaneFeature[] }>(`/workspaces/${id}/features`),
/**
* GET /api/lanes/:id/features/:slug — fetch an archived feature snapshot.
* @param id The lane id.
@@ -4325,7 +4332,7 @@ export const api = {
*/
show: (id: number, slug: string) =>
request<{ feature: import("./types").LaneFeature }>(
`/lanes/${id}/features/${encodeURIComponent(slug)}`
`/workspaces/${id}/features/${encodeURIComponent(slug)}`
),
},
/**
@@ -4335,7 +4342,7 @@ export const api = {
*/
proof: {
list: (id: number) =>
request<{ features: import("./types").ProofFeature[] }>(`/lanes/${id}/proof`),
request<{ features: import("./types").ProofFeature[] }>(`/workspaces/${id}/proof`),
/**
* URL for a proof image, ready to use in an <img src>.
* @param id The lane id.
@@ -4345,7 +4352,7 @@ export const api = {
* @returns A URL string pointing to the image.
*/
imageUrl: (id: number, slug: string, group: string, file: string): string =>
`/api/lanes/${id}/proof/${encodeURIComponent(slug)}/${encodeURIComponent(group)}/${encodeURIComponent(file)}`,
`/api/workspaces/${id}/proof/${encodeURIComponent(slug)}/${encodeURIComponent(group)}/${encodeURIComponent(file)}`,
/**
* DELETE /api/lanes/:id/proof/:slug — delete proof entries for a feature.
* @param id The lane id.
@@ -4354,19 +4361,19 @@ export const api = {
* @returns `{ deleted }` — count of deleted images.
*/
delete: (id: number, slug: string, body: { group?: string; images?: string[] }) =>
request<{ deleted: number }>(`/lanes/${id}/proof/${encodeURIComponent(slug)}`, {
request<{ deleted: number }>(`/workspaces/${id}/proof/${encodeURIComponent(slug)}`, {
method: "DELETE",
body: JSON.stringify(body),
}),
},
mcpSync: (id: number) =>
request<{ servers: string[]; profilesSeeded: string[] }>(`/lanes/${id}/mcp/sync`, {
request<{ servers: string[]; profilesSeeded: string[] }>(`/workspaces/${id}/mcp/sync`, {
method: "POST",
body: "{}",
}),
profileInit: (id: number, force = false) =>
request<{ scaffolded: boolean; written?: string[]; todos?: string[]; reason?: string }>(
`/lanes/${id}/profile/init`,
`/workspaces/${id}/profile/init`,
{ method: "POST", body: JSON.stringify({ force }) }
),
},
@@ -4627,6 +4634,13 @@ export interface CcProfileUsage {
* user + project scope in one response. */
export type CcScope = "user" | "project" | "all";
/** GET /api/cc-config/target — whose Claude Code config the page manages. */
export interface CcTarget {
kind: "own" | "host" | "none";
readOnly: boolean;
claudeHome: string | null;
}
/** The per-tier model names an AI Proxy profile carries. Each maps to one env
* var Claude Code reads; a proxy that names its models once fills them all
* from the profile's `model`. */
@@ -5207,8 +5221,9 @@ export interface CcOverview {
/** Key filesystem locations the explorer reads from. */
roots: {
claudeHome: string;
projectClaudeDir: string;
projectRoot: string;
/** Null for a member's own config with no workspace named: no project layer. */
projectClaudeDir: string | null;
projectRoot: string | null;
/** Path to the top-level `.claude.json` (marketplaces/global settings). */
claudeJson: string;
};
+31 -4
View File
@@ -43,13 +43,40 @@ export const RENDERED: ReadonlySet<ArtifactKind> = new Set([
"image",
]);
/** An edit worth a card in the conversation: a page, a document, a diagram, a
* table — and a React component the model wrote whole (a `.tsx` it merely
* edited is project code, not an artifact). */
/** Folders an app keeps its own pages in: an HTML file under one of them is
* the project's code, never a page the model made to show. */
const PROJECT_DIRS = new Set([
"src",
"public",
"templates",
"views",
"pages",
"app",
"static",
"dist",
"build",
"components",
"layouts",
]);
/** An HTML file the project serves itself: an `index.html`, or one under a
* folder in {@link PROJECT_DIRS}. */
function isProjectPage(path: string): boolean {
const parts = path.split(/[\\/]/);
const name = (parts.pop() || "").toLowerCase();
if (name === "index.html" || name === "index.htm") return true;
return parts.some((d) => PROJECT_DIRS.has(d.toLowerCase()));
}
/** An edit worth a card in the conversation: a document, a diagram, a table —
* and a page or React component the model wrote whole (a `.tsx` or `.html` it
* merely edited, and an HTML page of the project itself, are project code,
* not an artifact). */
export function isArtifact(path: string, created: boolean): boolean {
const k = artifactKind(path);
if (k === "react") return created;
return k === "html" || k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
if (k === "html") return created && !isProjectPage(path);
return k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
}
/** A code fence language for the Code tab. */
+1 -1
View File
@@ -7,7 +7,7 @@
*
* The order lives in the browser rather than the lanes table on purpose: it is
* a per-viewer preference, and nothing
* server-side (the CLI's `agenthub lanes`, the pipeline-stats picker) has an
* server-side (the CLI's `agenthub workspaces`, the pipeline-stats picker) has an
* opinion about it. Ceiling: the order does not follow the user to another
* browser or to the CLI — move it to a `lanes.sort_order` column if it ever
* must.
+1 -1
View File
@@ -2633,7 +2633,7 @@ export interface LaneNode {
icon: string;
gate: boolean;
/** The one command that drives this node — a slash command
* (`/superpowers:writing-plans`) or a shell one (`agenthub lanes hook ci-gate`),
* (`/superpowers:writing-plans`) or a shell one (`agenthub workspace hook ci-gate`),
* from the pipeline template. Empty string for a node with no single driver
* (`done`, or a gate another agent declares). Display text only; the
* dashboard never runs it. Optional in the type only because the archived
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @file main.tsx
* @description Vite client entry point. Bootstraps React 18, loads global styles,
* @description Vite client entry point. Bootstraps React 19, loads global styles,
* registers the service worker, prefetches the Workspace chunk on a `/workspace`
* deep link, and mounts {@link App} into `#root` once the active locale is loaded.
*
+80 -26
View File
@@ -16,6 +16,11 @@
* files, and the `MdItemList` artifacts) also opens a
* right-click menu that repeats those buttons — same handlers, same
* confirm path — plus Copy name/path.
* Server mode, a non-admin with their own Linux account (S3): the page is
* THEIR config (`useCcTarget`, labelled as such, no project layer unless a
* workspace is named), and the host-wide controls — Switch profile, Update
* all, AgentHub's own Claude settings — are an admin's and hidden (the server
* refuses them anyway).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -126,6 +131,8 @@ import { ScopeBadge } from "../components/config/ScopeBadge";
import { ConfigTabBar } from "../components/config/ConfigTabBar";
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
import { ClaudeAgentHubSettings } from "../components/settings/ClaudeAgentHubSettings";
import { usePermissions } from "../components/AuthGate";
import { useCcTarget } from "../hooks/useCcTarget";
import {
Empty,
SkeletonRows,
@@ -516,8 +523,14 @@ export function CcConfig() {
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
const projectRoot = data.overview?.roots.projectRoot ?? null;
// Project files under the plugin cache are wiped by the next plugin update.
const projectDisabled = isPluginCachePath(projectRoot);
// Server mode: whose config this is. The host-wide controls are an admin's.
const { isAdmin } = usePermissions();
const ccTarget = useCcTarget();
const ownHome = ccTarget?.kind === "own" ? ccTarget.claudeHome : null;
// Project files under the plugin cache are wiped by the next plugin update;
// a member's own config has no project layer at all.
const projectDisabled =
isPluginCachePath(projectRoot) || (!!data.overview && projectRoot === null);
useEffect(() => {
if (projectDisabled && scope === "project") setScope("all");
}, [projectDisabled, scope]);
@@ -681,6 +694,8 @@ export function CcConfig() {
}}
onOpenBackups={() => setBackupsOpen(true)}
wsConnected={wsConnected}
showProfiles={isAdmin}
ownHome={ownHome}
/>
<Tabs current={tab} onSelect={(k) => void setTab(k)} counts={data.overview?.counts} />
@@ -777,7 +792,7 @@ export function CcConfig() {
{/* AgentHub's own Claude Code settings follow the overview, outside the
list's search box and scope. */}
{tab === "overview" && (
{tab === "overview" && isAdmin && (
<ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} />
)}
@@ -820,10 +835,22 @@ interface HeaderProps {
onRefresh: () => void;
onOpenBackups: () => void;
wsConnected: boolean;
/** The host's login profiles link: an admin's (and local mode's). */
showProfiles?: boolean;
/** A member's own Claude home (server mode, S3): the page says whose it is. */
ownHome?: string | null;
}
/** The page's toolbar. No title of its own: the Settings hub already draws one. */
function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }: HeaderProps) {
function Header({
loading,
lastUpdated,
onRefresh,
onOpenBackups,
wsConnected,
showProfiles = true,
ownHome = null,
}: HeaderProps) {
const { t } = useTranslation("ccConfig");
const settingsHref = useSettingsHref();
const { t: tCommon } = useTranslation("common");
@@ -848,18 +875,30 @@ function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }:
{tCommon("offline")}
</span>
)}
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
{ownHome ? (
<div className="min-w-0">
<p className="text-xs font-medium text-fg-primary" data-testid="cc-own-config">
{t("own.label", { home: ownHome })}
</p>
<p className="text-xs text-fg-muted max-w-2xl">{t("own.subtitle")}</p>
</div>
) : (
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
)}
</div>
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
<div className="flex items-center gap-2 justify-end flex-wrap">
{/* Every AI tool's login profiles live in one place: Settings → AI tools. */}
<Link
to={settingsHref("agents#tool-profiles")}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>
<UserCog className="w-3.5 h-3.5" />
{t("profiles.openButton")}
</Link>
{/* Every AI tool's login profiles live in one place: Settings → AI tools.
The host's logins: an admin's. */}
{showProfiles && (
<Link
to={settingsHref("agents#tool-profiles")}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>
<UserCog className="w-3.5 h-3.5" />
{t("profiles.openButton")}
</Link>
)}
<button
type="button"
onClick={onOpenBackups}
@@ -1290,18 +1329,31 @@ function OverviewPanel({ overview }: { overview: CcOverview | null }) {
{t("overview.rootsTitle")}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
<RootRow
icon={FolderTree}
tone="emerald"
label={t("overview.projectClaudeDir")}
value={roots.projectClaudeDir}
/>
<RootRow
icon={FolderTree}
tone="violet"
label={t("overview.projectRoot")}
value={roots.projectRoot}
/>
{/* A member's own config has no project layer: their Claude home instead. */}
{roots.projectRoot === null && (
<RootRow
icon={FolderTree}
tone="emerald"
label={t("overview.claudeHome")}
value={roots.claudeHome}
/>
)}
{roots.projectClaudeDir !== null && (
<RootRow
icon={FolderTree}
tone="emerald"
label={t("overview.projectClaudeDir")}
value={roots.projectClaudeDir}
/>
)}
{roots.projectRoot !== null && (
<RootRow
icon={FolderTree}
tone="violet"
label={t("overview.projectRoot")}
value={roots.projectRoot}
/>
)}
<RootRow
icon={FileText}
tone="amber"
@@ -1718,6 +1770,7 @@ function PluginsPanel({
onChanged: () => void;
}) {
const { t } = useTranslation("ccConfig");
const { isAdmin } = usePermissions();
const { busy, run } = useConfigAction(onToast, onChanged);
const [input, setInput] = useState("");
const [available, setAvailable] = useState<CcAvailablePlugin[] | null>(null);
@@ -1785,7 +1838,8 @@ function PluginsPanel({
commands={[{ cmd: t("explain.plugins.installCmd"), note: "" }]}
/>
<UpdateAllCard onToast={onToast} onChanged={onChanged} />
{/* Update all runs on the host's plugins (AgentHub's own included): an admin's. */}
{isAdmin && <UpdateAllCard onToast={onToast} onChanged={onChanged} />}
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5">
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
+13 -2
View File
@@ -27,6 +27,9 @@
* /workspace, `lib/settingsLink.ts`): the same pages, every link kept in the
* tab (replaced, as a tab click), no Back link, and Ctrl/Cmd+F left alone
* while the tab is hidden behind a session.
* Server mode hides every tool page from a non-admin, except Claude Code for a
* non-admin with their own Linux account (`useCcTarget`): "Your Claude Code
* config", which they manage as themselves.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
@@ -75,6 +78,7 @@ import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { ChangesPanelSettings } from "../components/settings/ChangesPanelSettings";
import { useAiTools } from "../hooks/useAiTools";
import { useCcTarget } from "../hooks/useCcTarget";
import {
rankSettings,
rankSettingsEntries,
@@ -252,6 +256,10 @@ export function SettingsHub({ section }: { section?: string } = {}) {
const authState = useAuth().state;
const signedIn = authState.mode === "server" && !!authState.user;
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
// Server mode: a non-admin with their own Linux account (S3) manages their
// OWN Claude Code config here; the host's stays an admin's.
const ccTarget = useCcTarget();
const ownClaude = !isAdmin && ccTarget?.kind === "own";
const s = (key: string) => ({
title: t(`settingsNav.section.${key}.title`),
description: t(`settingsNav.section.${key}.description`),
@@ -315,7 +323,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
group: "agents" as const,
tool: "claude",
Icon: toolNavIcon("claude", "Claude Code"),
...s("claudeCode"),
...s(ownClaude ? "claudeCodeOwn" : "claudeCode"),
render: () => <CcConfig />,
},
]
@@ -439,7 +447,10 @@ export function SettingsHub({ section }: { section?: string } = {}) {
// server answers 403 EADMIN), so its pages — every tool's too — are hidden.
const sections = isAdmin
? allSections
: allSections.filter((x) => !x.tool && !ADMIN_SECTIONS.has(x.id));
: allSections.filter(
(x) =>
(!x.tool && !ADMIN_SECTIONS.has(x.id)) || (ownClaude && x.id === "agents/claude-code")
);
const navSections = sections.filter((x) => !x.tool);
const toolTabs = sections.filter((x) => x.tool);
function toolHref(id: string) {
+2 -2
View File
@@ -1251,7 +1251,7 @@ export function Workspace() {
}
};
// Mirrors `agenthub lanes pipeline <template> <id>`: same PATCH, same
// Mirrors `agenthub workspace pipeline <template> <id>`: same PATCH, same
// stage-mismatch warning when the current declared stage matches no node
// in the newly chosen template.
const handlePipelineChange = async (id: number, pipeline: string) => {
@@ -1420,7 +1420,7 @@ export function Workspace() {
<div data-testid="proof-gallery" className="mt-2">
{proofFeature.ticket_report && (
<a
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
href={`/api/workspaces/${currentLane.id}/proof/${proofFeature.ticket_report}`}
target="_blank"
rel="noreferrer"
className="text-xs text-fg-muted"
+10 -5
View File
@@ -238,12 +238,17 @@ describe("Artifacts", () => {
within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Share" })
).toBeInTheDocument();
await user.keyboard("{Escape}");
// One merged list by default; Hub is a filter over it.
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" }));
// One merged list by default; Hub is a filter over it. The Suggested
// count's own `unowned` list runs whenever settings land, so it is left
// out: only the order of the LIST loads is under test.
const listScopes = () =>
vi
.mocked(api.artifacts.list)
.mock.calls.map(([opts]) => opts?.scope)
.filter((s) => s !== "unowned");
expect(listScopes().pop()).toBe("all");
await user.click(screen.getByRole("button", { name: "Hub" }));
await waitFor(() =>
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "hub" }))
);
await waitFor(() => expect(listScopes().pop()).toBe("hub"));
});
it("splits the Hub and My library into yours and others'", async () => {
@@ -0,0 +1,133 @@
/**
* @file Covers the Claude Code config page as a MEMBER's own config (server
* mode, a non-admin with their own Linux account): it says whose config it is,
* and the host-wide controls — Switch profile, Update all, AgentHub's own
* Claude settings — are not shown (the server refuses them anyway). An admin
* still sees all three.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
const perms = vi.hoisted(() => ({ isAdmin: false }));
vi.mock("../../components/AuthGate", async (importOriginal) => {
const m = await importOriginal<typeof import("../../components/AuthGate")>();
return {
...m,
usePermissions: () => ({ ...m.usePermissions(), local: false, isAdmin: perms.isAdmin }),
};
});
vi.mock("../../components/settings/ClaudeAgentHubSettings", () => ({
ClaudeAgentHubSettings: () => <div data-testid="agenthub-claude-settings" />,
}));
const HOME = "/srv/users/mia/.claude";
const ccConfigMock = vi.hoisted(() => {
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
return {
target: r({ kind: "own", readOnly: false, claudeHome: "/srv/users/mia/.claude" }),
overview: r({
roots: {
claudeHome: "/srv/users/mia/.claude",
projectClaudeDir: null,
projectRoot: null,
claudeJson: "/srv/users/mia/.claude.json",
},
counts: {
skills: { user: 0, project: 0 },
agents: { user: 0, project: 0 },
commands: { user: 0, project: 0 },
outputStyles: { user: 0, project: 0 },
mcpServers: { user: 0, project: 0 },
hooks: {},
plugins: 0,
marketplaces: 0,
keybindings: 0,
settingsFiles: 0,
memory: 0,
},
}),
skills: r({ items: [] }),
agents: r({ items: [] }),
commands: r({ items: [] }),
outputStyles: r({ items: [] }),
memory: r({ items: [] }),
plugins: r({ manifestPath: null, manifestExists: false, plugins: [] }),
marketplaces: r({ items: [] }),
mcp: r({ user: [], projectScoped: [] }),
hooks: r({ items: [] }),
settings: r({ items: [] }),
keybindings: r({ groups: [] }),
statusline: r({}),
hookScripts: r({ items: [] }),
backups: r({ items: [] }),
pluginsAvailable: r({ installed: [], available: [] }),
updateAllStatus: r({ batch: null }),
};
});
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
};
});
vi.mock("../../lib/eventBus", () => ({
eventBus: {
subscribe: () => () => {},
publish: () => {},
onConnection: () => () => {},
connected: true,
setConnected: () => {},
},
}));
import { CcConfig } from "../CcConfig";
async function settle() {
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
await new Promise((res) => setTimeout(res, 0));
});
}
async function open() {
render(
<MemoryRouter>
<CcConfig />
</MemoryRouter>
);
await settle();
}
beforeEach(() => {
vi.clearAllMocks();
perms.isAdmin = false;
});
describe("CcConfig — a member's own config", () => {
it("says whose config it is and hides the host-wide controls", async () => {
await open();
expect(screen.getByTestId("cc-own-config")).toHaveTextContent(HOME);
expect(screen.queryByRole("link", { name: /switch profile/i })).not.toBeInTheDocument();
expect(screen.queryByTestId("agenthub-claude-settings")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("tab", { name: /plugins/i }));
await settle();
expect(screen.queryByRole("button", { name: /^update all$/i })).not.toBeInTheDocument();
expect(ccConfigMock.updateAllStatus).not.toHaveBeenCalled();
});
it("keeps all three for an admin", async () => {
perms.isAdmin = true;
await open();
expect(screen.queryByTestId("cc-own-config")).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: /switch profile/i })).toBeInTheDocument();
expect(screen.getByTestId("agenthub-claude-settings")).toBeInTheDocument();
expect(ccConfigMock.target).not.toHaveBeenCalled();
});
});
@@ -21,10 +21,15 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { AiTool, AiToolsView } from "../../lib/api";
// Local mode unless a test says otherwise; still an admin either way.
const perms = vi.hoisted(() => ({ local: true }));
const perms = vi.hoisted(() => ({ local: true, isAdmin: true }));
// What GET /api/cc-config/target answers a non-admin.
const ccTarget = vi.hoisted(() => ({ kind: "none" as "own" | "host" | "none" }));
vi.mock("../../components/AuthGate", async (importOriginal) => {
const m = await importOriginal<typeof import("../../components/AuthGate")>();
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) };
return {
...m,
usePermissions: () => ({ ...m.usePermissions(), local: perms.local, isAdmin: perms.isAdmin }),
};
});
vi.mock("../../components/settings/GitIdentitySettings", () => ({
GitIdentitySettings: () => <div data-testid="git-identity" />,
@@ -88,7 +93,14 @@ vi.mock("../../lib/api", async (importOriginal) => {
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
// AI tools → Profiles.
toolProfiles: { list: vi.fn(async () => ({ tools: [] })) },
ccConfig: { profiles: vi.fn(async () => ({ items: [], active: null })) },
ccConfig: {
profiles: vi.fn(async () => ({ items: [], active: null })),
target: vi.fn(async () => ({
kind: ccTarget.kind,
readOnly: ccTarget.kind !== "own",
claudeHome: ccTarget.kind === "own" ? "/srv/users/mia/.claude" : null,
})),
},
},
};
});
@@ -124,6 +136,8 @@ const nav = () => screen.getByRole("navigation", { name: "Settings sections" });
beforeEach(() => {
perms.local = true;
perms.isAdmin = true;
ccTarget.kind = "none";
__resetAiTools();
view = {
default: "claude",
@@ -202,6 +216,29 @@ describe("SettingsHub", () => {
expect(screen.getByTestId("ai-tools")).toBeInTheDocument();
});
it("gives a non-admin with their own account their own Claude Code page, and no other tool's", async () => {
perms.local = false;
perms.isAdmin = false;
ccTarget.kind = "own";
renderAt("/settings/agents/claude-code");
expect(await screen.findByTestId("cc-config")).toBeInTheDocument();
const tabs = within(screen.getByRole("tablist", { name: "AI tool settings" }))
.getAllByRole("tab")
.map((a) => a.textContent);
expect(tabs).toEqual(["All tools", "Your Claude Code config"]);
});
it("keeps the Claude Code page from a non-admin with no account of their own", async () => {
perms.local = false;
perms.isAdmin = false;
ccTarget.kind = "none";
renderAt("/settings/agents/claude-code");
expect(
await screen.findByRole("heading", { level: 1, name: "Appearance" })
).toBeInTheDocument();
expect(screen.queryByTestId("cc-config")).not.toBeInTheDocument();
});
it("navigates from the mobile select", async () => {
renderAt("/settings/data");
await screen.findByTestId("settings-data");
File diff suppressed because it is too large Load Diff