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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -765,7 +765,7 @@ export function Divider({
|
||||
onRatio,
|
||||
label,
|
||||
}: {
|
||||
gridRef: React.RefObject<HTMLDivElement>;
|
||||
gridRef: React.RefObject<HTMLDivElement | null>;
|
||||
dir: SplitDir;
|
||||
at: number;
|
||||
box: Rect;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ừ đó."
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user