Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e60ebe468b | |||
| 26977780cc | |||
| e06472ecd9 | |||
| ab27629139 | |||
| 3b1fdfac51 | |||
| 087c1ddb5d | |||
| 890cd08f57 | |||
| aabc95768c | |||
| b34b201bd1 | |||
| c356cf96a1 |
@@ -1,6 +1,6 @@
|
||||
# Claude Code Working Guide
|
||||
|
||||
_Last synced: v2.8.73 (2026-11-04) — Claude quota rows carry `accountUuid`; the Artifacts folder bar is restyled._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
_Last synced: v2.8.77 (2026-11-06) — pool accounts are rows of Settings → Profiles; Changes panel on the workspace home._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||
|
||||
## Project mission
|
||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||
@@ -61,7 +61,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
|
||||
- **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin; a batch upload of many small files is ONE `extract` of a tar the server writes itself — folders and regular files only, names it checked, `tar -k`; a member's single file goes to a hidden temp name and is `write-as move`d onto its name only once every byte arrived, never written in place) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `<data dir>/trash/<user id>/`), restorable 7 days. `write-as` is root-installed: a change to it needs `agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`) — except its TRASH, whose restore/purge an admin may run (`TRASH_W` in `server/routes/homes.js`, the owner's trash via `trashKey`, audited with the item id); keep that exception to the trash routes only. A home's Trash is its USER's one recycle bin (`listUserTrash`: home-account store + host store `<data dir>/trash/<user id>/`, items tagged `store`). **An upload survives a dashboard restart because its bytes are staged first** (`server/lib/upload-staging.js`): parts go to `<data dir>/upload-staging/<user>/<id>/` on the HOST, keyed by the caller's user and a browser-made id (never a path), and `?staged=<id>` on `upload`/`upload-batch` writes them through the SAME code and guards — a commit's answer is stored and replayed, never written twice. Do not add a staged write that skips `upload`/`uploadBatch`, nor stage inside a member's home. Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`.
|
||||
- **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo.
|
||||
- **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions.
|
||||
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill — in the Hub, or in the skill's source folder, which the server takes when the Hub is listed and an installer's differing copy is then offered **Update** — writes a NEW snapshot and drops the old one — never a write into the one being read; the newer of the two changes wins; **Share again** (`POST /api/artifacts/:id/reshare`) takes the owner's folder on the server — else a user-scope plugin's same-named skill — on a click, and an upload named like the uploader's own skill, or carrying `id`, replaces that row's snapshot instead of adding a twin) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus those shared with everyone (`shared`, the Hub) or with them by name (`artifact_shares`, **Shared with you** — the name list only its owner or an admin sees); only the owner or an admin changes one, and Share again never widens a by-name share to everyone. Artifact folders are the viewer's own (`agenthub.artifacts.folders` preference), never an artifact column. A `page` keeps a COPY anyone it reaches can view without the publishing claude.ai account — its HTML file (and the files the tool named) snapshotted at publish, or the `artifacts-export/<artifact id>.*` Claude saves for a Doc / Slides / Design (`scripts/artifact-export-hint.js`, the one hook that answers Claude: synchronous, matched to those tools only, silent outside AgentHub panes) attached at Stop; it is viewed in `HtmlFrame`'s sandbox or drawn by pdf.js, never fetched from claude.ai — do not add a route that proxies or logs into claude.ai for it. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
|
||||
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill — in the Hub, or in the skill's source folder, which the server takes when the Hub is listed and an installer's differing copy is then offered **Update** — writes a NEW snapshot and drops the old one — never a write into the one being read; the newer of the two changes wins; **Share again** (`POST /api/artifacts/:id/reshare`) takes the owner's folder on the server — else a user-scope plugin's same-named skill — on a click, and an upload named like the uploader's own skill, or carrying `id`, replaces that row's snapshot instead of adding a twin) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus those shared with everyone (`shared`, the Hub) or with them by name (`artifact_shares`, **Shared with you** — the name list only its owner or an admin sees); only the owner or an admin changes one, and Share again never widens a by-name share to everyone. Artifact folders are the viewer's own (`agenthub.artifacts.folders` preference), never an artifact column. A `page` keeps a COPY anyone it reaches can view without the publishing claude.ai account — its HTML file (and the files the tool named) snapshotted at publish from the workspace folder or the publishing session's own Claude Code scratchpad (`<tmp>/claude-<uid>/<project>/<session id>/scratchpad`, its folders real dirs of the workspace's account, each file opened `O_NOFOLLOW` and owned by it — never any other path), or the `artifacts-export/<artifact id>.*` Claude saves for a Doc / Slides / Design (`scripts/artifact-export-hint.js`, the one hook that answers Claude: synchronous, matched to those tools only, silent outside AgentHub panes) attached at Stop; it is viewed in `HtmlFrame`'s sandbox or drawn by pdf.js, never fetched from claude.ai — do not add a route that proxies or logs into claude.ai for it. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
|
||||
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written at every spawn as the ONLY file `CLAUDE.md` of `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir, via `write-as`), which goes in as one more `--add-dir` with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane env — never `--append-system-prompt-file`: a custom system prompt is a launch flag Claude cannot carry over a restart, so it refused `/tui` in every pane with instructions (side effect: a CLAUDE.md in an extra folder is now read too); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
|
||||
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. Billing has one more owner: a session NO workspace owns belongs to the user of the Linux account it ran as (`sessions.account` — the hook's peer, else its folder; `server/lib/session-account.js`, the host's account → the admin marked `users.host_account`, else the only admin), so a script's `claude -p` counts in Today, the Users page and spend quotas — a workspace's ownership always wins over it.
|
||||
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file WorkspaceContext — a workspace's context: Instructions, Memory
|
||||
* (read-only), Context files and Folders (`GET/PUT/POST/DELETE
|
||||
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
|
||||
* workspace home's right column and, `compact`, folded at the bottom of the
|
||||
* workspace home's right column (sticky: it follows the home's scroll) and,
|
||||
* `compact`, folded at the bottom of the
|
||||
* session view's Changes panel. Folder suggestions are the repo's untracked
|
||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the
|
||||
* same form clones related repos beside the workspace (`CloneSourcesForm`).
|
||||
@@ -157,7 +158,13 @@ function ContextBody({
|
||||
<aside
|
||||
data-testid="workspace-context"
|
||||
aria-label={t("home.contextPanel")}
|
||||
className={compact ? "" : "card w-80 shrink-0 self-start p-0"}
|
||||
// On the home it follows the scroll (sticky in the home's scroller),
|
||||
// scrolling itself — without a bar — when taller than the window.
|
||||
className={
|
||||
compact
|
||||
? ""
|
||||
: "card no-scrollbar sticky top-2 max-h-[calc(100vh-7rem)] w-80 shrink-0 self-start overflow-y-auto p-0"
|
||||
}
|
||||
>
|
||||
{view && !view.supported && (
|
||||
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
||||
|
||||
@@ -60,6 +60,10 @@ export const THEME_LABELS: Record<string, string> = {
|
||||
canvas: "Canvas",
|
||||
"orca-dark": "Orca Dark",
|
||||
"orca-light": "Orca Light",
|
||||
"sunset-cloud": "Sunset Cloud",
|
||||
terminal: "Terminal",
|
||||
"night-sea": "Night Sea",
|
||||
sakura: "Sakura",
|
||||
};
|
||||
|
||||
const MODES: { mode: ThemeMode; Icon: typeof Moon }[] = [
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* @file PoolAccountSettings — Settings → AI tools → Pool account: in server
|
||||
* mode, every user picks which account of the shared account pool their own
|
||||
* new sessions run on, per tool, among the accounts their pool group may use
|
||||
* (`GET/PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
|
||||
* "Automatic" leaves it to the balancer; an admin's pin locks the choice. A
|
||||
* Claude login goes into the user's home at once, so running panes follow it;
|
||||
* Codex and Cursor take it at the next session. Renders nothing in local mode
|
||||
* or when the pool holds no account the user may use.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Lock } from "lucide-react";
|
||||
import { api, type MyPoolTool, type PoolTool } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsRow, SettingsSection } from "./SettingsRow";
|
||||
|
||||
const LABEL: Record<PoolTool, string> = { claude: "Claude Code", codex: "Codex", cursor: "Cursor" };
|
||||
|
||||
export function PoolAccountSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { local } = usePermissions();
|
||||
const [tools, setTools] = useState<MyPoolTool[] | null>(null);
|
||||
const [busy, setBusy] = useState<PoolTool | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (local) return;
|
||||
api.me
|
||||
.poolAccounts()
|
||||
.then((r) => setTools(r.tools))
|
||||
.catch(() => setTools(null));
|
||||
}, [local]);
|
||||
|
||||
if (local || !tools?.length) return null;
|
||||
|
||||
const choose = async (tool: PoolTool, account: string | null) => {
|
||||
setBusy(tool);
|
||||
try {
|
||||
const r = await api.me.choosePoolAccount(tool, account);
|
||||
setTools((list) =>
|
||||
(list ?? []).map((x) => (x.tool === tool ? { ...x, choice: r.choice } : x))
|
||||
);
|
||||
toast.success(
|
||||
!r.choice
|
||||
? t("poolAccount.automaticSaved")
|
||||
: r.switched === "switched" || r.switched === "kept"
|
||||
? t("poolAccount.switchedNow", { account: r.choice })
|
||||
: t("poolAccount.nextSession", { account: r.choice })
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
id="pool-account"
|
||||
title={t("poolAccount.title")}
|
||||
description={t("poolAccount.description")}
|
||||
>
|
||||
{tools.map((x) => (
|
||||
<SettingsRow
|
||||
key={x.tool}
|
||||
id={`pool-account-${x.tool}`}
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<AiToolIcon tool={{ id: x.tool, label: LABEL[x.tool] }} className="h-4 w-4" />
|
||||
{LABEL[x.tool]}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
x.pin ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t("poolAccount.pinned", { account: x.pin })}
|
||||
</span>
|
||||
) : (
|
||||
t("poolAccount.count", { count: x.accounts.length })
|
||||
)
|
||||
}
|
||||
disabled={!!x.pin}
|
||||
control={
|
||||
<select
|
||||
aria-label={t("poolAccount.select", { tool: LABEL[x.tool] })}
|
||||
value={x.pin ?? x.choice ?? ""}
|
||||
disabled={!!x.pin || busy === x.tool}
|
||||
onChange={(e) => void choose(x.tool, e.target.value || null)}
|
||||
className="select !py-1 !text-xs max-w-[16rem]"
|
||||
>
|
||||
{!x.pin && <option value="">{t("poolAccount.automatic")}</option>}
|
||||
{x.accounts.map((a) => (
|
||||
<option key={a.name} value={a.name} disabled={a.expired && a.name !== x.choice}>
|
||||
{[
|
||||
a.name,
|
||||
a.email && a.email !== a.name ? `(${a.email})` : null,
|
||||
a.expired
|
||||
? `— ${t("poolAccount.expired")}`
|
||||
: a.full
|
||||
? `— ${t("poolAccount.full")}`
|
||||
: a.quotaPercent != null
|
||||
? `— ${Math.round(a.quotaPercent)}%`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @file PoolProfiles — the shared account pool's accounts as rows of Settings →
|
||||
* AI tools → Profiles (server mode). Each account this user's pool group may
|
||||
* use is one row beside the saved profiles, plus an "Automatic" row; "Switch
|
||||
* to" picks which account the user's OWN new sessions run on
|
||||
* (`PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
|
||||
* Automatic leaves it to the balancer; an admin's pin locks every row. A
|
||||
* Claude login goes into the user's home at once, so running panes follow it;
|
||||
* Codex and Cursor take it at the next session.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Lock } from "lucide-react";
|
||||
import { api, type MyPoolTool } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const rowCls = "rounded-lg border border-border bg-surface-2 px-3 py-2.5";
|
||||
const btnCls =
|
||||
"h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50";
|
||||
|
||||
/** Lower-cased emails of a tool's pool accounts — a saved profile with one is the same account. */
|
||||
export function poolEmails(entry: MyPoolTool | null | undefined): Set<string> {
|
||||
return new Set(
|
||||
(entry?.accounts ?? []).map((a) => a.email?.toLowerCase()).filter((e): e is string => !!e)
|
||||
);
|
||||
}
|
||||
|
||||
export function PoolProfiles({
|
||||
entry,
|
||||
onChange,
|
||||
}: {
|
||||
entry: MyPoolTool;
|
||||
onChange: (next: MyPoolTool) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { t: tc } = useTranslation("ccConfig");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const locked = !!entry.pin;
|
||||
/** The account the user's new sessions run on now; null = Automatic. */
|
||||
const current = entry.pin ?? entry.choice;
|
||||
|
||||
const choose = async (account: string | null) => {
|
||||
setBusy(account ?? "");
|
||||
try {
|
||||
const r = await api.me.choosePoolAccount(entry.tool, account);
|
||||
onChange({ ...entry, choice: r.choice });
|
||||
toast.success(
|
||||
!r.choice
|
||||
? t("poolAccount.automaticSaved")
|
||||
: r.switched === "switched" || r.switched === "kept"
|
||||
? t("poolAccount.switchedNow", { account: r.choice })
|
||||
: t("poolAccount.nextSession", { account: r.choice })
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const inUse = (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
|
||||
{t("poolAccount.inUse")}
|
||||
</span>
|
||||
);
|
||||
const poolBadge = (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full border border-border bg-surface-3 text-fg-muted">
|
||||
{t("poolAccount.badge")}
|
||||
</span>
|
||||
);
|
||||
const switchBtn = (account: string | null, disabled = false) => (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null || disabled}
|
||||
onClick={() => void choose(account)}
|
||||
className={btnCls}
|
||||
>
|
||||
{busy === (account ?? "") ? tc("profiles.switching") : tc("profiles.switchButton")}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid={`pool-profiles-${entry.tool}`}>
|
||||
{locked && (
|
||||
<p className="text-[11px] text-fg-muted inline-flex items-center gap-1.5">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t("poolAccount.pinned", { account: entry.pin })}
|
||||
</p>
|
||||
)}
|
||||
{!locked && (
|
||||
<div className={rowCls} data-testid={`pool-row-${entry.tool}-automatic`}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium text-fg-primary">
|
||||
{t("poolAccount.automatic")}
|
||||
</span>
|
||||
{poolBadge}
|
||||
{current === null && inUse}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{t("poolAccount.automaticHint")}
|
||||
</span>
|
||||
{current !== null && switchBtn(null)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{entry.accounts.map((a) => {
|
||||
const status = a.expired
|
||||
? t("poolAccount.expired")
|
||||
: a.full
|
||||
? t("poolAccount.full")
|
||||
: a.quotaPercent != null
|
||||
? t("poolAccount.quota", { pct: Math.round(a.quotaPercent) })
|
||||
: null;
|
||||
return (
|
||||
<div key={a.name} className={rowCls} data-testid={`pool-row-${entry.tool}-${a.name}`}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium text-fg-primary">{a.name}</span>
|
||||
{poolBadge}
|
||||
{current === a.name && inUse}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{a.email && a.email !== a.name ? a.email : ""}
|
||||
</span>
|
||||
{!locked && current !== a.name && switchBtn(a.name, a.expired)}
|
||||
</div>
|
||||
{status && (
|
||||
<div
|
||||
className={`text-[10px] mt-0.5 ${
|
||||
a.expired || a.full ? "text-status-danger" : "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,14 +4,20 @@
|
||||
* (`/api/cc-config/profiles`, with its quota check and token-owner mismatch
|
||||
* warning — this panel used to be a modal on the Claude Code page); Codex and
|
||||
* Oh My Pi are `/api/tool-profiles`; a tool that cannot be switched
|
||||
* (Antigravity: an OS-keyring login) says why. In server mode the panel links
|
||||
* each Claude/Codex profile to the account pool in Administration → Users,
|
||||
* where an admin moves it in (`?poolAdd=<tool>&poolProfile=<name>` opens the
|
||||
* pool's Add dialog on it), and shows how many accounts the pool holds.
|
||||
* (Antigravity: an OS-keyring login) says why. In server mode the shared
|
||||
* account pool's accounts this user may use (`GET /api/me/pool-accounts`) are
|
||||
* rows of the same list (PoolProfiles: "Switch to" picks the account the
|
||||
* user's own new sessions run on, plus an Automatic row) — there is no
|
||||
* separate Pool account section any more. A saved profile holding the same
|
||||
* account (email) as a pool row is folded into that row. Staff also see the
|
||||
* host's saved logins and how many accounts the whole pool holds; an admin can
|
||||
* move a profile into the pool in Administration → Users
|
||||
* (`?poolAdd=<tool>&poolProfile=<name>` opens the pool's Add dialog on it). A
|
||||
* developer sees only the pool rows of the tools they may use.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
@@ -33,6 +39,7 @@ import {
|
||||
type CcProfile,
|
||||
type CcProfileUsage,
|
||||
type CcUsageWindow,
|
||||
type MyPoolTool,
|
||||
type ToolProfile,
|
||||
type ToolProfileIdentity,
|
||||
type ToolProfilesTool,
|
||||
@@ -53,11 +60,14 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../Context
|
||||
import { SkeletonRows } from "../config/configPrimitives";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
import { PoolProfiles, poolEmails } from "./PoolProfiles";
|
||||
|
||||
/** The tools the panel covers, in tab order, with the command that signs one in. */
|
||||
const TOOLS: { id: string; label: string; login: string }[] = [
|
||||
/** The tools the panel covers, in tab order, with the command that signs one in.
|
||||
* `poolOnly`: no saved logins of its own — a tab only while the pool has one. */
|
||||
const TOOLS: { id: string; label: string; login: string; poolOnly?: boolean }[] = [
|
||||
{ id: "claude", label: "Claude Code", login: "claude auth login" },
|
||||
{ id: "codex", label: "Codex", login: "codex login" },
|
||||
{ id: "cursor", label: "Cursor", login: "", poolOnly: true },
|
||||
{ id: "omp", label: "Oh My Pi", login: "omp (then /login)" },
|
||||
{ id: "antigravity", label: "Antigravity", login: "agy" },
|
||||
];
|
||||
@@ -72,15 +82,21 @@ export function ToolProfilesSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const aiTools = useAiTools();
|
||||
const { local, seesAll } = usePermissions();
|
||||
const [tab, setTab] = useState<string>("claude");
|
||||
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
|
||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||
/** Server mode: the pool accounts THIS user may use, per tool. */
|
||||
const [mine, setMine] = useState<MyPoolTool[] | null>(null);
|
||||
const mineOf = (tool: string) => mine?.find((x) => x.tool === tool) ?? null;
|
||||
const shown = TOOLS.filter((x) => {
|
||||
if (mineOf(x.id)) return true;
|
||||
// The host's own logins are a staff-only read (a developer would get 403s).
|
||||
if (!seesAll || x.poolOnly) return false;
|
||||
const tool = aiTools.byId(x.id);
|
||||
if (x.id === "claude") return tool?.enabled !== false;
|
||||
return !!tool && tool.installed && tool.enabled !== false;
|
||||
});
|
||||
const [tab, setTab] = useState<string>("claude");
|
||||
const current = shown.find((x) => x.id === tab) ?? shown[0];
|
||||
const [others, setOthers] = useState<ToolProfilesTool[] | null>(null);
|
||||
const [pool, setPool] = useState<AccountPool | null>(null);
|
||||
|
||||
const loadOthers = useCallback(
|
||||
() =>
|
||||
@@ -91,9 +107,9 @@ export function ToolProfilesSettings() {
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadOthers();
|
||||
}, [loadOthers]);
|
||||
// The pool is a server-mode, staff-only read.
|
||||
if (seesAll) void loadOthers();
|
||||
}, [loadOthers, seesAll]);
|
||||
// The whole pool is a server-mode, staff-only read.
|
||||
useEffect(() => {
|
||||
if (local || !seesAll) return;
|
||||
api.accountPool
|
||||
@@ -101,10 +117,23 @@ export function ToolProfilesSettings() {
|
||||
.then(setPool)
|
||||
.catch(() => setPool(null));
|
||||
}, [local, seesAll]);
|
||||
// The accounts this user may run on: server mode, every role.
|
||||
useEffect(() => {
|
||||
if (local) return;
|
||||
api.me
|
||||
.poolAccounts()
|
||||
.then((r) => setMine(r.tools))
|
||||
.catch(() => setMine([]));
|
||||
}, [local]);
|
||||
|
||||
if (!current) return null;
|
||||
const poolCount = (tool: string) =>
|
||||
pool ? (tool === "codex" ? pool.codex.length : pool.accounts.length) : null;
|
||||
const entry = mineOf(current.id);
|
||||
const setEntry = (next: MyPoolTool) =>
|
||||
setMine((list) => (list ?? []).map((x) => (x.tool === next.tool ? next : x)));
|
||||
const poolRows = entry ? <PoolProfiles entry={entry} onChange={setEntry} /> : null;
|
||||
const merged = poolEmails(entry);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
@@ -132,11 +161,18 @@ export function ToolProfilesSettings() {
|
||||
))}
|
||||
</div>
|
||||
<div role="tabpanel" className="mt-3 space-y-3">
|
||||
{POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
|
||||
{seesAll && POOL_TOOLS.has(current.id) && poolCount(current.id) !== null && (
|
||||
<PoolLine count={poolCount(current.id)!} />
|
||||
)}
|
||||
{current.id === "claude" ? (
|
||||
<ClaudeProfiles pooled={!local && POOL_TOOLS.has("claude")} local={local} />
|
||||
{!seesAll || current.poolOnly ? (
|
||||
poolRows
|
||||
) : current.id === "claude" ? (
|
||||
<ClaudeProfiles
|
||||
pooled={!local && POOL_TOOLS.has("claude")}
|
||||
local={local}
|
||||
merged={merged}
|
||||
poolRows={poolRows}
|
||||
/>
|
||||
) : (
|
||||
<OtherProfiles
|
||||
key={current.id}
|
||||
@@ -146,6 +182,8 @@ export function ToolProfilesSettings() {
|
||||
state={others?.find((o) => o.tool === current.id) ?? null}
|
||||
loading={others === null}
|
||||
pooled={!local && POOL_TOOLS.has(current.id)}
|
||||
merged={merged}
|
||||
poolRows={poolRows}
|
||||
onChanged={loadOthers}
|
||||
/>
|
||||
)}
|
||||
@@ -295,6 +333,8 @@ function OtherProfiles({
|
||||
state,
|
||||
loading,
|
||||
pooled,
|
||||
merged,
|
||||
poolRows,
|
||||
onChanged,
|
||||
}: {
|
||||
tool: string;
|
||||
@@ -303,6 +343,10 @@ function OtherProfiles({
|
||||
state: ToolProfilesTool | null;
|
||||
loading: boolean;
|
||||
pooled: boolean;
|
||||
/** Emails of the pool rows: a saved login of one is shown as that row. */
|
||||
merged: Set<string>;
|
||||
/** The pool accounts this user may use, as rows of this list. */
|
||||
poolRows: ReactNode;
|
||||
onChanged: () => Promise<unknown>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -320,8 +364,8 @@ function OtherProfiles({
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const items = state.items;
|
||||
const activeSaved = items.some((p) => p.active);
|
||||
const activeSaved = state.items.some((p) => p.active);
|
||||
const items = state.items.filter((p) => !p.email || !merged.has(p.email.toLowerCase()));
|
||||
const remove = async (p: ToolProfile) => {
|
||||
const ok = await confirmDialog({
|
||||
title: tc("profiles.deleteConfirmTitle", { name: p.label }),
|
||||
@@ -376,7 +420,7 @@ function OtherProfiles({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{items.length === 0 && (
|
||||
{items.length === 0 && !poolRows && (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
||||
{tc("profiles.empty")}
|
||||
</div>
|
||||
@@ -445,6 +489,7 @@ function OtherProfiles({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{poolRows}
|
||||
<LoginHint text={t("toolProfiles.loginHint", { tool: label })} command={login} />
|
||||
</div>
|
||||
);
|
||||
@@ -482,7 +527,19 @@ function ActiveBadge() {
|
||||
// save button only appears while the live account is unsaved, because one
|
||||
// account is one profile.
|
||||
|
||||
function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean }) {
|
||||
function ClaudeProfiles({
|
||||
pooled,
|
||||
local,
|
||||
merged,
|
||||
poolRows,
|
||||
}: {
|
||||
pooled: boolean;
|
||||
local: boolean;
|
||||
/** Emails of the pool rows: a saved profile of one is shown as that row. */
|
||||
merged: Set<string>;
|
||||
/** The pool accounts this user may use, as rows of this list. */
|
||||
poolRows: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [items, setItems] = useState<CcProfile[] | null>(null);
|
||||
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||
@@ -538,6 +595,12 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
|
||||
)
|
||||
: null;
|
||||
const shownActive = (p: CcProfile) => (decoy ? p.name === decoy.name : p.active);
|
||||
/** Listed as its own row: not hidden by this browser, and not a pool account
|
||||
* (that one is the pool's row — one account, one row). */
|
||||
const listed = useCallback(
|
||||
(p: CcProfile) => (reveal || !isHidden(p)) && !(p.email && merged.has(p.email.toLowerCase())),
|
||||
[reveal, isHidden, merged]
|
||||
);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -586,11 +649,11 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
|
||||
if (!items) return;
|
||||
for (const p of items) {
|
||||
if (autoFetched.current.has(p.name)) continue;
|
||||
if (!reveal && isHidden(p)) continue;
|
||||
if (!listed(p)) continue;
|
||||
autoFetched.current.add(p.name);
|
||||
void checkUsage(p.name);
|
||||
}
|
||||
}, [items, checkUsage, isHidden, reveal]);
|
||||
}, [items, checkUsage, listed]);
|
||||
|
||||
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||
/** How many saved profiles hold the same account as `p`. */
|
||||
@@ -691,75 +754,73 @@ function ClaudeProfiles({ pooled, local }: { pooled: boolean; local: boolean })
|
||||
</div>
|
||||
|
||||
{items === null && !error && <SkeletonRows n={3} />}
|
||||
{items !== null && items.filter((p) => reveal || !isHidden(p)).length === 0 && (
|
||||
{items !== null && items.filter(listed).length === 0 && !poolRows && (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
|
||||
{t("profiles.empty")}
|
||||
</div>
|
||||
)}
|
||||
{items
|
||||
?.filter((p) => reveal || !isHidden(p))
|
||||
.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
data-testid={`profile-row-${p.name}`}
|
||||
onContextMenu={(e) => ctx.open(e, p)}
|
||||
onClickCapture={(e) => toggleRow(e, p)}
|
||||
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<LabelEdit
|
||||
label={p.label}
|
||||
busy={busy !== null}
|
||||
editing={renaming === p.name}
|
||||
setEditing={(on) => setRenaming(on ? p.name : null)}
|
||||
onRename={(next) =>
|
||||
void run(
|
||||
`rename:${p.name}`,
|
||||
() => api.ccConfig.renameProfile(p.name, next),
|
||||
t("profiles.renameSuccess", { name: next })
|
||||
)
|
||||
}
|
||||
/>
|
||||
{shownActive(p) && <ActiveBadge />}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{p.email || p.displayName || p.accountUuid}
|
||||
</span>
|
||||
{!shownActive(p) && (
|
||||
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
|
||||
{busy === `switch:${p.name}`
|
||||
? t("profiles.switching")
|
||||
: t("profiles.switchButton")}
|
||||
</button>
|
||||
)}
|
||||
{pooled && <MoveToPool tool="claude" name={p.name} />}
|
||||
{deletable(p) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
title={t("profiles.deleteButton")}
|
||||
aria-label={t("profiles.deleteButton")}
|
||||
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<IdentityLine identity={p} />
|
||||
<ProfileUsageLine
|
||||
usage={usage[p.name]}
|
||||
error={usageError[p.name]}
|
||||
busy={!!usageBusy[p.name]}
|
||||
disabled={busy !== null || !!usageBusy[p.name]}
|
||||
onCheck={() => void checkUsage(p.name)}
|
||||
{items?.filter(listed).map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
data-testid={`profile-row-${p.name}`}
|
||||
onContextMenu={(e) => ctx.open(e, p)}
|
||||
onClickCapture={(e) => toggleRow(e, p)}
|
||||
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<LabelEdit
|
||||
label={p.label}
|
||||
busy={busy !== null}
|
||||
editing={renaming === p.name}
|
||||
setEditing={(on) => setRenaming(on ? p.name : null)}
|
||||
onRename={(next) =>
|
||||
void run(
|
||||
`rename:${p.name}`,
|
||||
() => api.ccConfig.renameProfile(p.name, next),
|
||||
t("profiles.renameSuccess", { name: next })
|
||||
)
|
||||
}
|
||||
/>
|
||||
{p.savedAt && (
|
||||
<div className="text-[10px] text-fg-muted mt-1">
|
||||
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||
</div>
|
||||
{shownActive(p) && <ActiveBadge />}
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{p.email || p.displayName || p.accountUuid}
|
||||
</span>
|
||||
{!shownActive(p) && (
|
||||
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
|
||||
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
|
||||
</button>
|
||||
)}
|
||||
{pooled && <MoveToPool tool="claude" name={p.name} />}
|
||||
{deletable(p) && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
title={t("profiles.deleteButton")}
|
||||
aria-label={t("profiles.deleteButton")}
|
||||
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<IdentityLine identity={p} />
|
||||
<ProfileUsageLine
|
||||
usage={usage[p.name]}
|
||||
error={usageError[p.name]}
|
||||
busy={!!usageBusy[p.name]}
|
||||
disabled={busy !== null || !!usageBusy[p.name]}
|
||||
onCheck={() => void checkUsage(p.name)}
|
||||
/>
|
||||
{p.savedAt && (
|
||||
<div className="text-[10px] text-fg-muted mt-1">
|
||||
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{poolRows}
|
||||
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
|
||||
|
||||
@@ -53,6 +53,10 @@ describe("AppearanceSettings", () => {
|
||||
"Apple Light",
|
||||
"Canvas",
|
||||
"Orca Light",
|
||||
"Sunset Cloud",
|
||||
"Terminal",
|
||||
"Night Sea",
|
||||
"Sakura",
|
||||
]) {
|
||||
expect(screen.getByText(name)).toBeInTheDocument();
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* @file Tests for PoolAccountSettings — Settings → AI tools → Pool account:
|
||||
* hidden in local mode and with no usable account; one select per tool that
|
||||
* starts on the user's choice (or Automatic) and saves a pick through
|
||||
* `PUT /api/me/pool-accounts`; locked when an admin pinned the user.
|
||||
* @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 "../../../i18n";
|
||||
import { PoolAccountSettings } from "../PoolAccountSettings";
|
||||
|
||||
const perms = vi.hoisted(() => ({ local: false }));
|
||||
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
|
||||
|
||||
const m = vi.hoisted(() => ({ poolAccounts: vi.fn(), choosePoolAccount: vi.fn() }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { me: m, aiTools: { iconUrl: (id: string) => `/icons/${id}.png` } },
|
||||
}));
|
||||
|
||||
const account = (name: string) => ({
|
||||
name,
|
||||
kind: "login",
|
||||
email: `${name}@x.io`,
|
||||
expired: false,
|
||||
quotaPercent: 20,
|
||||
full: false,
|
||||
});
|
||||
|
||||
async function show() {
|
||||
render(<PoolAccountSettings />);
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
perms.local = false;
|
||||
m.poolAccounts.mockReset();
|
||||
m.choosePoolAccount.mockReset();
|
||||
});
|
||||
|
||||
describe("PoolAccountSettings", () => {
|
||||
it("renders nothing in local mode or with no account to choose", async () => {
|
||||
perms.local = true;
|
||||
await show();
|
||||
expect(m.poolAccounts).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
perms.local = false;
|
||||
m.poolAccounts.mockResolvedValue({ tools: [] });
|
||||
await show();
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
});
|
||||
|
||||
it("starts on the user's choice and saves a new pick", async () => {
|
||||
m.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{ tool: "claude", pin: null, choice: "two", accounts: [account("one"), account("two")] },
|
||||
],
|
||||
});
|
||||
m.choosePoolAccount.mockResolvedValue({ tool: "claude", choice: "one", switched: "switched" });
|
||||
await show();
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(select.value).toBe("two");
|
||||
await userEvent.selectOptions(select, "one");
|
||||
expect(m.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
|
||||
await userEvent.selectOptions(select, "");
|
||||
expect(m.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
|
||||
});
|
||||
|
||||
it("is locked on an admin's pin", async () => {
|
||||
m.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{ tool: "codex", pin: "one", choice: null, accounts: [account("one"), account("two")] },
|
||||
],
|
||||
});
|
||||
await show();
|
||||
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
||||
expect(select.disabled).toBe(true);
|
||||
expect(select.value).toBe("one");
|
||||
expect(screen.getByText(/one/, { selector: "span" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,16 @@
|
||||
* failing account costs the others nothing), and its right-click delete asks
|
||||
* first and never offers to delete the only copy of the live account; Codex's
|
||||
* tab switches through /api/tool-profiles; Antigravity says why it cannot be
|
||||
* switched; and in server mode a profile links into the account pool.
|
||||
* switched; and in server mode a profile links into the account pool, while
|
||||
* the pool accounts the user may use are rows of the same list — "Switch to"
|
||||
* picks one through `PUT /api/me/pool-accounts`, an Automatic row goes back to
|
||||
* the balancer, an admin's pin locks them, a saved profile of the same email
|
||||
* is folded into the pool row, and a developer sees only the pool rows.
|
||||
* @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 { render, screen, act, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import "../../../i18n";
|
||||
@@ -38,6 +42,7 @@ const m = vi.hoisted(() => {
|
||||
},
|
||||
tp: { list: vi.fn(), activate: vi.fn(), save: vi.fn(), rename: vi.fn(), remove: vi.fn() },
|
||||
pool: { list: vi.fn() },
|
||||
me: { poolAccounts: vi.fn(), choosePoolAccount: vi.fn() },
|
||||
};
|
||||
});
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
@@ -45,6 +50,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
ccConfig: m.cc,
|
||||
toolProfiles: m.tp,
|
||||
accountPool: m.pool,
|
||||
me: m.me,
|
||||
aiTools: { iconUrl: (id: string) => `/icons/${id}.png` },
|
||||
},
|
||||
}));
|
||||
@@ -70,6 +76,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
perms.local = true;
|
||||
perms.seesAll = true;
|
||||
m.cc.profiles.mockResolvedValue({
|
||||
items: [
|
||||
{ name: "work", label: "Work", active: true, accountUuid: "u1", email: "a@b.c" },
|
||||
@@ -106,6 +113,7 @@ beforeEach(() => {
|
||||
});
|
||||
m.tp.activate.mockResolvedValue({ ok: true, name: "b", savedOutgoing: "a" });
|
||||
m.pool.list.mockResolvedValue({ accounts: [{ name: "p1" }], codex: [], blockPercent: 90 });
|
||||
m.me.poolAccounts.mockResolvedValue({ tools: [] });
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — Claude", () => {
|
||||
@@ -218,3 +226,96 @@ describe("ToolProfilesSettings — account pool", () => {
|
||||
expect(link?.getAttribute("href")).toBe("/admin?poolAdd=claude&poolProfile=work#account-pool");
|
||||
});
|
||||
});
|
||||
|
||||
const poolAccount = (name: string, email: string | null = `${name}@x.io`) => ({
|
||||
name,
|
||||
kind: "login",
|
||||
email,
|
||||
expired: false,
|
||||
quotaPercent: 20,
|
||||
full: false,
|
||||
});
|
||||
|
||||
describe("ToolProfilesSettings — pool accounts as profiles", () => {
|
||||
beforeEach(() => {
|
||||
perms.local = false;
|
||||
m.me.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
tool: "claude",
|
||||
pin: null,
|
||||
choice: "two",
|
||||
accounts: [poolAccount("one", "d@e.f"), poolAccount("two")],
|
||||
},
|
||||
],
|
||||
});
|
||||
m.me.choosePoolAccount.mockImplementation(async (tool: string, account: string | null) => ({
|
||||
tool,
|
||||
choice: account,
|
||||
switched: account ? "switched" : null,
|
||||
}));
|
||||
});
|
||||
|
||||
it("lists the pool accounts beside the saved profiles, one row per account", async () => {
|
||||
await show();
|
||||
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("Quota 20%");
|
||||
expect(screen.getByTestId("pool-row-claude-two")).toHaveTextContent("in use");
|
||||
expect(screen.getByTestId("profile-row-work")).toBeInTheDocument();
|
||||
// "spare" is d@e.f — the pool's "one": shown once, as the pool row.
|
||||
expect(screen.queryByTestId("profile-row-spare")).toBeNull();
|
||||
expect(m.cc.profileUsage).not.toHaveBeenCalledWith("spare");
|
||||
});
|
||||
|
||||
it("switches the user's own sessions to a pool account, and back to automatic", async () => {
|
||||
const user = userEvent.setup();
|
||||
await show();
|
||||
const one = screen.getByTestId("pool-row-claude-one");
|
||||
await user.click(within(one).getByRole("button", { name: "Switch to" }));
|
||||
await settle();
|
||||
expect(m.me.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
|
||||
expect(m.cc.activateProfile).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("in use");
|
||||
const auto = screen.getByTestId("pool-row-claude-automatic");
|
||||
await user.click(within(auto).getByRole("button", { name: "Switch to" }));
|
||||
await settle();
|
||||
expect(m.me.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
|
||||
expect(screen.getByTestId("pool-row-claude-automatic")).toHaveTextContent("in use");
|
||||
});
|
||||
|
||||
it("locks every pool row on an admin's pin", async () => {
|
||||
m.me.poolAccounts.mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
tool: "claude",
|
||||
pin: "one",
|
||||
choice: null,
|
||||
accounts: [poolAccount("one"), poolAccount("two")],
|
||||
},
|
||||
],
|
||||
});
|
||||
await show();
|
||||
expect(screen.getByText(/pinned you to one/)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("pool-row-claude-automatic")).toBeNull();
|
||||
expect(screen.getByTestId("pool-row-claude-one")).toHaveTextContent("in use");
|
||||
expect(
|
||||
within(screen.getByTestId("pool-row-claude-two")).queryByRole("button", { name: "Switch to" })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a developer only the pool rows, never the host's logins", async () => {
|
||||
perms.seesAll = false;
|
||||
await show();
|
||||
expect(m.cc.profiles).not.toHaveBeenCalled();
|
||||
expect(m.tp.list).not.toHaveBeenCalled();
|
||||
expect(m.pool.list).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("pool-row-claude-one")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /codex/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("is not shown to a developer with no pool account to use", async () => {
|
||||
perms.seesAll = false;
|
||||
m.me.poolAccounts.mockResolvedValue({ tools: [] });
|
||||
await show();
|
||||
expect(screen.queryByRole("tablist")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +103,19 @@ describe("useTheme", () => {
|
||||
expect(document.documentElement.dataset.theme).toBe("orca-light");
|
||||
});
|
||||
|
||||
it("applies the ai-chat-themes palettes by id, each in its own mode only", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
for (const name of ["sunset-cloud", "terminal", "night-sea"]) {
|
||||
act(() => result.current.setThemeFor("dark", name));
|
||||
expect(document.documentElement.dataset.theme).toBe(name);
|
||||
}
|
||||
act(() => result.current.setThemeFor("dark", "sakura"));
|
||||
expect(result.current.themeFor.dark).toBe("night-sea");
|
||||
act(() => result.current.setThemeFor("light", "sakura"));
|
||||
act(() => result.current.toggleTheme());
|
||||
expect(document.documentElement.dataset.theme).toBe("sakura");
|
||||
});
|
||||
|
||||
it("the default theme carries no data-theme attribute", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
act(() => result.current.setThemeFor("dark", "darcula"));
|
||||
|
||||
@@ -22,8 +22,8 @@ export type ThemeMode = Theme | "system";
|
||||
|
||||
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
||||
export const THEMES = {
|
||||
dark: ["default", "darcula", "apple-dark", "orca-dark"],
|
||||
light: ["default", "canvas", "apple-light", "orca-light"],
|
||||
dark: ["default", "darcula", "apple-dark", "orca-dark", "sunset-cloud", "terminal", "night-sea"],
|
||||
light: ["default", "canvas", "apple-light", "orca-light", "sakura"],
|
||||
} as const satisfies Record<Theme, readonly string[]>;
|
||||
|
||||
const MODE_KEY = "theme";
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
"folders.takeOut": "Take it out of its folder",
|
||||
"page.view": "View",
|
||||
"page.viewHint": "Its copy, viewable here without the claude.ai account that published it",
|
||||
"page.noCopy": "No viewable copy: the file this page was published from is gone. Open it on claude.ai instead.",
|
||||
"page.noCopy": "No viewable copy: the file this page was published from is gone, or lies outside this workspace and its session's scratchpad. Open it on claude.ai instead.",
|
||||
"pdf.page": "Page {{n}}",
|
||||
"pdf.failed": "This PDF could not be shown. Download it instead.",
|
||||
"pdf.more": "Showing the first {{shown}} of {{total}} pages — download it for the rest.",
|
||||
|
||||
@@ -1438,18 +1438,17 @@
|
||||
}
|
||||
},
|
||||
"poolAccount": {
|
||||
"title": "Pool account",
|
||||
"description": "Which account of the shared account pool your own new sessions run on. Automatic lets AgentHub pick the least-used one.",
|
||||
"automatic": "Automatic",
|
||||
"select": "{{tool}} pool account",
|
||||
"count_one": "{{count}} account you may use",
|
||||
"count_other": "{{count}} accounts you may use",
|
||||
"pinned": "An admin pinned you to {{account}}",
|
||||
"expired": "expired",
|
||||
"full": "quota full",
|
||||
"automaticSaved": "Back to automatic — AgentHub picks the account",
|
||||
"switchedNow": "Switched to {{account}} — running sessions follow it",
|
||||
"nextSession": "{{account}} is used from your next session"
|
||||
"nextSession": "{{account}} is used from your next session",
|
||||
"inUse": "in use",
|
||||
"badge": "Pool",
|
||||
"automaticHint": "Your new sessions run on the least-used pool account",
|
||||
"quota": "Quota {{pct}}%"
|
||||
},
|
||||
"companyHub": {
|
||||
"title": "Company hub",
|
||||
|
||||
@@ -268,7 +268,7 @@
|
||||
"folders.takeOut": "Bỏ khỏi thư mục",
|
||||
"page.view": "Xem",
|
||||
"page.viewHint": "Bản sao xem được ngay tại đây, không cần tài khoản claude.ai đã tạo trang",
|
||||
"page.noCopy": "Chưa có bản xem: file gốc của trang này không còn. Hãy mở trên claude.ai.",
|
||||
"page.noCopy": "Chưa có bản xem: file gốc của trang này không còn, hoặc nằm ngoài workspace và scratchpad của phiên. Hãy mở trên claude.ai.",
|
||||
"pdf.page": "Trang {{n}}",
|
||||
"pdf.failed": "Không hiển thị được PDF này. Hãy tải về.",
|
||||
"pdf.more": "Đang hiện {{shown}}/{{total}} trang đầu — tải về để xem hết.",
|
||||
|
||||
@@ -1426,18 +1426,17 @@
|
||||
}
|
||||
},
|
||||
"poolAccount": {
|
||||
"title": "Tài khoản pool",
|
||||
"description": "Tài khoản nào trong pool tài khoản dùng chung chạy các phiên mới của bạn. Tự động để AgentHub chọn tài khoản ít dùng nhất.",
|
||||
"automatic": "Tự động",
|
||||
"select": "Tài khoản pool của {{tool}}",
|
||||
"count_one": "{{count}} tài khoản bạn được dùng",
|
||||
"count_other": "{{count}} tài khoản bạn được dùng",
|
||||
"pinned": "Admin đã ghim bạn vào {{account}}",
|
||||
"expired": "hết hạn",
|
||||
"full": "hết quota",
|
||||
"automaticSaved": "Đã về Tự động — AgentHub tự chọn tài khoản",
|
||||
"switchedNow": "Đã chuyển sang {{account}} — các phiên đang chạy theo luôn",
|
||||
"nextSession": "{{account}} được dùng từ phiên tiếp theo"
|
||||
"nextSession": "{{account}} được dùng từ phiên tiếp theo",
|
||||
"inUse": "đang dùng",
|
||||
"badge": "Pool",
|
||||
"automaticHint": "Phiên mới của bạn chạy trên tài khoản pool ít dùng nhất",
|
||||
"quota": "Quota {{pct}}%"
|
||||
},
|
||||
"companyHub": {
|
||||
"title": "Hub công ty",
|
||||
|
||||
@@ -350,6 +350,106 @@
|
||||
--syn-del: 197 0 15; /* #c5000f */
|
||||
}
|
||||
|
||||
/* Sunset Cloud, Terminal, Night Sea (dark) and Sakura (light) — the colour
|
||||
* themes of ai-chat-themes (github.com/Aohkne/ai-chat-themes, MIT), which
|
||||
* gives only a background, a text and an accent colour per theme. Every
|
||||
* other token is derived from those three and held to the bar above: text
|
||||
* ≥ 4.5:1 on a card and on its hover lift, the text accent ≥ 4.5:1 on a card
|
||||
* and on its own /15 chip (Sakura's #e0567f and Sunset's #ff8a6b darkened /
|
||||
* lightened to get there), white ≥ 4.5:1 on `--accent-solid`, each status
|
||||
* label ≥ 4.5:1 on its /20 chip. Their fonts (a monospace UI, Georgia) are
|
||||
* not taken; syntax, diff and `--kept` colours are their mode's defaults.
|
||||
* `--ansi-blue` (read only by `lib/terminalTheme.ts`) keeps a terminal's
|
||||
* blue blue where the accent is green or coral. */
|
||||
.dark[data-theme="sunset-cloud"] {
|
||||
--surface-0: 32 24 36; /* #201824 */
|
||||
--surface-1: 48 37 52; /* #302534 */
|
||||
--surface-2: 54 43 58; /* #362b3a */
|
||||
--surface-3: 60 49 63; /* #3c313f */
|
||||
--surface-4: 67 56 69; /* #433845 */
|
||||
--surface-5: 75 63 76; /* #4b3f4c */
|
||||
--border: 76 64 76; /* #4c404c */
|
||||
--border-light: 88 76 87; /* #584c57 */
|
||||
--accent: 255 159 134; /* #ff9f86 */
|
||||
--accent-hover: 255 188 170; /* #ffbcaa */
|
||||
--accent-solid: 173 94 73; /* #ad5e49 */
|
||||
--accent-solid-hover: 149 81 63; /* #95513f */
|
||||
--fg-primary: 247 232 226; /* #f7e8e2 */
|
||||
--fg-secondary: 214 203 203; /* #d6cbcb */
|
||||
--fg-muted: 176 165 168; /* #b0a5a8 */
|
||||
--status-success: 88 220 156; /* #58dc9c */
|
||||
--status-danger: 255 176 173; /* #ffb0ad */
|
||||
--status-warning: 255 202 22; /* #ffca16 */
|
||||
/* ANSI blue in the terminal: the accent is not blue here. */
|
||||
--ansi-blue: 112 184 255; /* #70b8ff */
|
||||
}
|
||||
|
||||
.dark[data-theme="terminal"] {
|
||||
--surface-0: 8 13 9; /* #080d09 */
|
||||
--surface-1: 14 23 17; /* #0e1711 */
|
||||
--surface-2: 20 30 22; /* #141e16 */
|
||||
--surface-3: 25 36 28; /* #19241c */
|
||||
--surface-4: 31 44 34; /* #1f2c22 */
|
||||
--surface-5: 37 52 41; /* #253429 */
|
||||
--border: 38 54 42; /* #26362a */
|
||||
--border-light: 49 67 53; /* #314335 */
|
||||
--accent: 57 232 122; /* #39e87a */
|
||||
--accent-hover: 116 239 162; /* #74efa2 */
|
||||
--accent-solid: 32 132 70; /* #208446 */
|
||||
--accent-solid-hover: 28 114 60; /* #1c723c */
|
||||
--fg-primary: 185 246 198; /* #b9f6c6 */
|
||||
--fg-secondary: 147 196 157; /* #93c49d */
|
||||
--fg-muted: 117 154 125; /* #759a7d */
|
||||
--status-success: 61 214 140; /* #3dd68c */
|
||||
--status-danger: 255 149 146; /* #ff9592 */
|
||||
--status-warning: 255 202 22; /* #ffca16 */
|
||||
/* ANSI blue in the terminal: the accent is not blue here. */
|
||||
--ansi-blue: 112 184 255; /* #70b8ff */
|
||||
}
|
||||
|
||||
.dark[data-theme="night-sea"] {
|
||||
--surface-0: 11 20 33; /* #0b1421 */
|
||||
--surface-1: 20 31 49; /* #141f31 */
|
||||
--surface-2: 26 37 55; /* #1a2537 */
|
||||
--surface-3: 32 43 62; /* #202b3e */
|
||||
--surface-4: 40 50 69; /* #283245 */
|
||||
--surface-5: 47 58 76; /* #2f3a4c */
|
||||
--border: 48 59 77; /* #303b4d */
|
||||
--border-light: 60 71 89; /* #3c4759 */
|
||||
--accent: 92 175 255; /* #5cafff */
|
||||
--accent-hover: 141 199 255; /* #8dc7ff */
|
||||
--accent-solid: 62 120 176; /* #3e78b0 */
|
||||
--accent-solid-hover: 53 103 151; /* #356797 */
|
||||
--fg-primary: 220 230 250; /* #dce6fa */
|
||||
--fg-secondary: 183 192 210; /* #b7c0d2 */
|
||||
--fg-muted: 146 155 173; /* #929bad */
|
||||
--status-success: 61 214 140; /* #3dd68c */
|
||||
--status-danger: 255 155 153; /* #ff9b99 */
|
||||
--status-warning: 255 202 22; /* #ffca16 */
|
||||
}
|
||||
|
||||
[data-mode="light"][data-theme="sakura"] {
|
||||
--surface-0: 242 230 232; /* #f2e6e8 */
|
||||
--surface-1: 247 235 237; /* #f7ebed */
|
||||
--surface-2: 251 240 242; /* #fbf0f2 */
|
||||
--surface-3: 255 249 250; /* #fff9fa */
|
||||
--surface-4: 255 252 253; /* #fffcfd */
|
||||
--surface-5: 255 255 255; /* #ffffff */
|
||||
--border: 224 210 213; /* #e0d2d5 */
|
||||
--border-light: 237 224 227; /* #ede0e3 */
|
||||
--accent: 167 64 95; /* #a7405f */
|
||||
--accent-hover: 137 52 78; /* #89344e */
|
||||
--accent-solid: 195 75 110; /* #c34b6e */
|
||||
--accent-solid-hover: 168 65 95; /* #a8415f */
|
||||
--fg-primary: 75 42 53; /* #4b2a35 */
|
||||
--fg-secondary: 107 80 89; /* #6b5059 */
|
||||
--fg-muted: 120 97 105; /* #786169 */
|
||||
--status-success: 23 105 45; /* #17692d */
|
||||
--status-danger: 173 28 38; /* #ad1c26 */
|
||||
--status-warning: 124 82 0; /* #7c5200 */
|
||||
--shadow-card: 0 1px 2px rgb(75 42 53 / 0.06), 0 2px 6px rgb(75 42 53 / 0.07);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-weight: 500;
|
||||
|
||||
@@ -39,6 +39,11 @@ describe("buildTerminalTheme", () => {
|
||||
expect(t.brightWhite).toBe("#edeef0");
|
||||
});
|
||||
|
||||
it("takes ANSI blue from --ansi-blue when a theme's accent is not blue", () => {
|
||||
const t = buildTerminalTheme((n) => ({ ...vars, "--ansi-blue": "112 184 255" })[n] ?? "");
|
||||
expect(t.blue).toBe("#70b8ff");
|
||||
});
|
||||
|
||||
it("makes each bright twin lighter than its base colour", () => {
|
||||
const t = buildTerminalTheme((n) => vars[n] ?? "");
|
||||
const sum = (hex: string) =>
|
||||
|
||||
@@ -324,17 +324,13 @@ export const SETTINGS_INDEX: {
|
||||
"codex",
|
||||
"omp",
|
||||
"pool",
|
||||
"pool account",
|
||||
"automatic",
|
||||
"hồ sơ",
|
||||
"tài khoản",
|
||||
],
|
||||
to: "#tool-profiles",
|
||||
},
|
||||
{
|
||||
section: "agents",
|
||||
titleKey: "poolAccount.title",
|
||||
keywords: ["pool", "account", "switch", "quota", "tài khoản", "pool account"],
|
||||
to: "#pool-account",
|
||||
},
|
||||
{ section: "agents/claude-code", titleKey: "ccConfig:backups.title", keywords: ["restore"] },
|
||||
{ section: "agents/codex", titleKey: "codexHooks.title" },
|
||||
{ section: "agents/codex", titleKey: "codexHome.title" },
|
||||
|
||||
@@ -40,7 +40,9 @@ function lighten(hex: string | undefined, amount: number): string | undefined {
|
||||
|
||||
/**
|
||||
* Build an xterm theme from a theme's CSS variables. red/green/yellow come from
|
||||
* `--status-*`, blue from `--accent`, black/white from the surface/fg ramp.
|
||||
* `--status-*`, blue from `--ansi-blue` when a theme sets one (its accent is
|
||||
* not blue: Terminal's green, Sunset Cloud's coral) else `--accent`,
|
||||
* black/white from the surface/fg ramp.
|
||||
* Magenta and cyan stay the classic ANSI hues: no token keeps that hue in every
|
||||
* theme (darcula's keyword is orange), and a program printing "magenta" should
|
||||
* get magenta.
|
||||
@@ -50,7 +52,7 @@ export function buildTerminalTheme(read: VarReader): ITheme {
|
||||
const red = v("--status-danger");
|
||||
const green = v("--status-success");
|
||||
const yellow = v("--status-warning");
|
||||
const blue = v("--accent");
|
||||
const blue = v("--ansi-blue") ?? v("--accent");
|
||||
const magenta = "#c678dd";
|
||||
const cyan = "#56b6c2";
|
||||
return {
|
||||
|
||||
@@ -74,7 +74,6 @@ import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsS
|
||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||
import { MemberPolicySettings } from "../components/settings/MemberPolicySettings";
|
||||
import { ToolProfilesSettings } from "../components/settings/ToolProfilesSettings";
|
||||
import { PoolAccountSettings } from "../components/settings/PoolAccountSettings";
|
||||
import { CompanyHubSettings } from "../components/settings/CompanyHubSettings";
|
||||
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
@@ -255,7 +254,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
};
|
||||
}, [location.key, location.hash]);
|
||||
|
||||
const { isAdmin, local, seesAll } = usePermissions();
|
||||
const { isAdmin, local } = usePermissions();
|
||||
const authState = useAuth().state;
|
||||
const signedIn = authState.mode === "server" && !!authState.user;
|
||||
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
||||
@@ -310,11 +309,10 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
<AgentInstructionsSettings />
|
||||
<SessionDefaultsSettings />
|
||||
<AiToolsSettings settingsHref={toolHref} />
|
||||
{/* Every AI tool's login profiles, linked to the account pool: the
|
||||
host's logins, a staff-only read (a developer would get 403s). */}
|
||||
{seesAll && <ToolProfilesSettings />}
|
||||
{/* Every user's own pick of a pool account (server mode; hides itself). */}
|
||||
<PoolAccountSettings />
|
||||
{/* Every AI tool's login profiles with the pool accounts the user may
|
||||
run on as rows of the same list: staff see the host's logins too,
|
||||
a developer only the pool rows (it hides itself with none). */}
|
||||
<ToolProfilesSettings />
|
||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||
<ArtifactsHubSettings />
|
||||
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
||||
|
||||
@@ -1002,8 +1002,11 @@ export function Workspace() {
|
||||
const showRoot = !inner || inner.root || repos.length === 0;
|
||||
// A file the chat asks for lies in the inner repo whose path prefixes it.
|
||||
const repoOf = (p: string) => repos.find((r) => p.startsWith(`${r.path}/`));
|
||||
// Sections folded shut ("" = the workspace folder, else the folder's id).
|
||||
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
|
||||
// The stacked sections are an accordion: ONE is open at a time ("" = the
|
||||
// workspace folder, `repo:<path>` an inner repo, else the folder's id).
|
||||
// `undefined` = the first section (also when the open one is gone); `null`
|
||||
// = every section folded.
|
||||
const [openSource, setOpenSource] = useState<string | null | undefined>(undefined);
|
||||
// Right-click on a section: close extra folders — which REMOVES them from the
|
||||
// workspace (the folder stays on disk). The workspace folder never closes.
|
||||
const sourceCtx = useContextMenu<string>();
|
||||
@@ -1062,11 +1065,7 @@ export function Workspace() {
|
||||
// Its path is the workspace folder's: unfold that section (or the
|
||||
// inner repo's that holds it).
|
||||
const repo = repoOfRef.current(path);
|
||||
setFoldedSources((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(repo ? `repo:${repo.path}` : "");
|
||||
return next;
|
||||
});
|
||||
setOpenSource(repo ? `repo:${repo.path}` : "");
|
||||
setChangesOpen(true);
|
||||
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
|
||||
};
|
||||
@@ -1270,8 +1269,12 @@ export function Workspace() {
|
||||
// editor: no page padding, no cards, dividers between its regions.
|
||||
const sessionView = !!currentLane && !home;
|
||||
previewHostRef.current = sessionView;
|
||||
// The home is locked to the viewport too: the Changes panel sits beside it
|
||||
// at full height, and the home scrolls inside its own column.
|
||||
const viewportLocked =
|
||||
sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
|
||||
sessionView ||
|
||||
(home && !!currentLane) ||
|
||||
(!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
|
||||
|
||||
const handleLaneAction = async (id: number, action: string, body?: Record<string, unknown>) => {
|
||||
setLaneActionError(null);
|
||||
@@ -1345,27 +1348,41 @@ export function Workspace() {
|
||||
it rather than under it. It collapses to a rail instead of
|
||||
unmounting nothing — there is no live subscription inside it, so
|
||||
unmounting is safe and keeps a closed panel from polling. */}
|
||||
{home && currentLane && (
|
||||
<WorkspaceHome
|
||||
lane={currentLane}
|
||||
onRenamed={() => void refreshLanes()}
|
||||
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
|
||||
sourcesVersion={sourcesVersion}
|
||||
onMenuAction={(action) => {
|
||||
if (action === "new-here") {
|
||||
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
||||
setAddLaneOpen(true);
|
||||
} else if (action === "reset" || action === "remove") {
|
||||
setDestructive({ lane: currentLane, action });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div ref={changesRowRef} className={`relative flex min-h-0 flex-1${home ? " hidden" : ""}`}>
|
||||
<div ref={changesRowRef} className="relative flex min-h-0 flex-1">
|
||||
{/* The home shares the row with the Changes panel, like the session
|
||||
view's console does — so a workspace's changes show before any
|
||||
session is open. */}
|
||||
{home && currentLane && (
|
||||
<div
|
||||
data-testid="workspace-home-column"
|
||||
// `relative`: the home's absolutely placed bits (a row's sr-only
|
||||
// label) must be clipped by THIS scroller — placed against the
|
||||
// row outside it they stretched the page into an empty scroll.
|
||||
// No visible scrollbar: the wheel and touch still scroll it.
|
||||
className={`no-scrollbar relative min-h-0 min-w-0 flex-1 overflow-y-auto${
|
||||
changesOpen && changesFull ? " hidden" : ""
|
||||
}${changesOpen && !changesFull ? " pr-5" : ""}`}
|
||||
>
|
||||
<WorkspaceHome
|
||||
lane={currentLane}
|
||||
onRenamed={() => void refreshLanes()}
|
||||
onSourcesChange={(v) => sourcesChanged(currentLane.id, v)}
|
||||
sourcesVersion={sourcesVersion}
|
||||
onMenuAction={(action) => {
|
||||
if (action === "new-here") {
|
||||
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
||||
setAddLaneOpen(true);
|
||||
} else if (action === "reset" || action === "remove") {
|
||||
setDestructive({ lane: currentLane, action });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<section
|
||||
data-testid="lane-detail"
|
||||
className={`flex min-h-0 min-w-0 flex-1 flex-col${
|
||||
currentLane && changesOpen && changesFull ? " hidden" : ""
|
||||
home || (currentLane && changesOpen && changesFull) ? " hidden" : ""
|
||||
}`}
|
||||
>
|
||||
{/* No header row: the console's tab bar is the header. Only a
|
||||
@@ -1792,10 +1809,17 @@ export function Workspace() {
|
||||
// One repo only: the one panel, as it always was.
|
||||
if (sections.length === 1) return panel;
|
||||
// Else every repo is a section, stacked like VS Code's
|
||||
// Source Control; the open ones share the height. A
|
||||
// Source Control — an accordion: opening one folds the
|
||||
// others, so the open one has the whole height. A
|
||||
// folded one stays mounted (CSS), so an unsaved edit in
|
||||
// it survives.
|
||||
const folded = foldedSources.has(key);
|
||||
const openKey =
|
||||
openSource === null
|
||||
? null
|
||||
: sections.some((x) => x.key === openSource)
|
||||
? openSource
|
||||
: sections[0]!.key;
|
||||
const folded = key !== openKey;
|
||||
return (
|
||||
<section
|
||||
key={key}
|
||||
@@ -1811,13 +1835,7 @@ export function Workspace() {
|
||||
type="button"
|
||||
aria-expanded={!folded}
|
||||
onContextMenu={(e) => sourceCtx.open(e, key)}
|
||||
onClick={() =>
|
||||
setFoldedSources((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(key)) next.add(key);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
onClick={() => setOpenSource(folded ? key : null)}
|
||||
className="flex shrink-0 items-center gap-1 py-1 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
{folded ? (
|
||||
@@ -1884,12 +1902,13 @@ export function Workspace() {
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{/* The workspace's context — the home's right column, compact. */}
|
||||
{/* The workspace's context — the home's right column, compact
|
||||
(not on the home itself, which shows that column). */}
|
||||
<section
|
||||
data-testid="panel-workspace-context"
|
||||
className={`-mx-3 flex shrink-0 flex-col border-t border-border px-3 ${
|
||||
contextOpen ? "max-h-[45%]" : ""
|
||||
}`}
|
||||
className={`-mx-3 shrink-0 flex-col border-t border-border px-3 ${
|
||||
home ? "hidden" : "flex"
|
||||
} ${contextOpen ? "max-h-[45%]" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -196,17 +196,20 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
expect(size()).toHaveAttribute("aria-label", "Full screen");
|
||||
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
|
||||
|
||||
// Full width HIDES the console beside it — hidden, still mounted.
|
||||
// The workspace's home sits beside the panel (no session is open), and
|
||||
// full width HIDES it — hidden, still mounted; same for a console.
|
||||
const beside = () => screen.getByTestId("workspace-home-column");
|
||||
expect(beside().className).not.toContain("hidden");
|
||||
fireEvent.click(size());
|
||||
expect(card().className).toContain("w-full");
|
||||
expect(screen.getByTestId("lane-detail").className).toContain("hidden");
|
||||
expect(beside().className).toContain("hidden");
|
||||
expect(size()).toHaveAttribute("aria-label", "Restore size");
|
||||
expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument();
|
||||
|
||||
// Restore brings the console back beside it.
|
||||
fireEvent.click(size());
|
||||
expect(card().className).toContain("w-[25%]");
|
||||
expect(screen.getByTestId("lane-detail").className).not.toContain("hidden");
|
||||
expect(beside().className).not.toContain("hidden");
|
||||
|
||||
// Collapsing from full screen reopens at 25/75, not back at full width.
|
||||
fireEvent.click(size());
|
||||
@@ -288,11 +291,19 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual(["alpha", "shop"]);
|
||||
expect(api.lanes.git).toHaveBeenCalledWith(1);
|
||||
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 });
|
||||
// A folded section keeps its panel mounted, only hidden.
|
||||
// An accordion: the first section starts open, the others folded.
|
||||
const toggle = sections[0]!.querySelector("button")!;
|
||||
fireEvent.click(toggle);
|
||||
const shop = sections[1]!.querySelector("button")!;
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(shop).toHaveAttribute("aria-expanded", "false");
|
||||
// Opening one folds the other; a folded section keeps its panel mounted.
|
||||
fireEvent.click(shop);
|
||||
expect(shop).toHaveAttribute("aria-expanded", "true");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(sections[0]!.querySelector('[data-testid="lane-changes"]')).not.toBeNull();
|
||||
// Clicking the open one folds it too.
|
||||
fireEvent.click(shop);
|
||||
expect(shop).toHaveAttribute("aria-expanded", "false");
|
||||
// A chat's file is the workspace folder's: its section unfolds.
|
||||
act(() => {
|
||||
openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts");
|
||||
@@ -390,13 +401,13 @@ describe("Workspace — the Changes panel's width", () => {
|
||||
fireEvent.click(screen.getByTestId("panel-git-source-rescan"));
|
||||
expect(api.lanes.repos).toHaveBeenLastCalledWith(2, true);
|
||||
// A chat's file inside an inner repo opens in that repo's section.
|
||||
fireEvent.click(sections[1]!.querySelector("button")!);
|
||||
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
|
||||
act(() => {
|
||||
openInChanges(2, "/workspace/beta", "/workspace/beta/apps/web/src/x.ts");
|
||||
});
|
||||
await settle();
|
||||
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "true");
|
||||
expect(sections[0]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
|
||||
expect(api.lanes.gitFile).toHaveBeenCalledWith(
|
||||
{ lane: 2, repo: "apps/web" },
|
||||
"src/x.ts",
|
||||
|
||||
@@ -1203,7 +1203,19 @@ describe("Workspace — home and session view", () => {
|
||||
it("opens a workspace on its home, hiding (not unmounting) the session view", async () => {
|
||||
await renderWorkspace();
|
||||
expect(screen.getByTestId("workspace-home")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-detail").parentElement).toHaveClass("hidden");
|
||||
expect(screen.getByTestId("lane-detail")).toHaveClass("hidden");
|
||||
// The Changes panel (or its rail) sits beside the home, before any session.
|
||||
expect(screen.getByTestId("workspace-home-column").parentElement).not.toHaveClass("hidden");
|
||||
// Its own scroller contains its absolutely placed bits (sr-only labels),
|
||||
// or they stretch the page past the viewport.
|
||||
expect(screen.getByTestId("workspace-home-column")).toHaveClass(
|
||||
"relative",
|
||||
"overflow-y-auto",
|
||||
"no-scrollbar"
|
||||
);
|
||||
expect(
|
||||
screen.queryByTestId("lane-changes-card") ?? screen.queryByTestId("lane-changes-expand")
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens the session view for ?run=, headed by the tab bar alone", async () => {
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.74 (rebuild client to match)
|
||||
UI build v2.8.77 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+3
-2
@@ -84,8 +84,9 @@ Internal build — all rights reserved.
|
||||
- **Updates.** Release-based update detection with an in-UI **Update all**
|
||||
(marketplaces → every plugin → AgentHub last → one session reload).
|
||||
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
|
||||
light: Default, Apple Light, Canvas, Orca Light. Each mode
|
||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark,
|
||||
Sunset Cloud, Terminal, Night Sea; light: Default, Apple Light, Canvas, Orca
|
||||
Light, Sakura (the four last from ai-chat-themes, colours only). Each mode
|
||||
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
||||
It also picks the workspace terminals' font: JetBrains Mono (default), the
|
||||
rounded Maple Mono, or the browser's own fixed-width
|
||||
|
||||
+9
-4
@@ -692,15 +692,20 @@ tools**. An account's Edit (pencil) uses the same role / No pool list.
|
||||
### A user's own pool account
|
||||
|
||||
When the pool holds more than one account a user may use, they pick which one
|
||||
their own new sessions run on: **Settings → AI tools → Pool account**, one
|
||||
select per tool (Claude, Codex, Cursor), every role, server mode only
|
||||
(`GET/PUT /api/me/pool-accounts`, `account_choices`, `server/lib/account-pins.js`).
|
||||
their own new sessions run on in **Settings → AI tools → Profiles**: each pool
|
||||
account they may use is a row of that tool's tab (Claude, Codex, Cursor) with
|
||||
its quota and a **Switch to**, beside an **Automatic** row — there is no
|
||||
separate Pool account section. Every role, server mode only; a developer's
|
||||
Profiles shows only these pool rows, staff see the host's saved logins too, and
|
||||
a saved profile with the same email as a pool account is shown once, as the
|
||||
pool row (`GET/PUT /api/me/pool-accounts`, `account_choices`,
|
||||
`server/lib/account-pins.js`).
|
||||
|
||||
- The list is only what their group allows (or the shared pool); a choice
|
||||
outside it is refused (`ENOACCOUNT`), and one later taken from the group
|
||||
falls back to the balancer.
|
||||
- **Automatic** (the default) leaves it to the balancer.
|
||||
- An admin's pin wins and locks the select (`ELOCKED`); the choice comes back
|
||||
- An admin's pin wins and locks the rows (`ELOCKED`); the choice comes back
|
||||
once the pin is removed.
|
||||
- A Claude login goes into the user's home at once, like an admin's switch,
|
||||
so running panes follow it; Codex and Cursor take it at the next session (a
|
||||
|
||||
@@ -14,7 +14,7 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
|
||||
| `plugin` | A plugin by marketplace source + name (no files) | picked from the list a marketplace link answers (`POST /api/artifacts/plugin/inspect`), or typed as `name@marketplace` |
|
||||
|
||||
- **Local mode:** one personal library, no sharing UI.
|
||||
- **Server mode:** every artifact is the OWNER's and private by default. The owner can **Share** it with everyone — it appears in the **Hub** for every signed-in user — or with chosen people only (`artifact_shares`; they find it under **Shared with you**). A `page` keeps a viewable copy — its HTML file, or the copy Claude exports of a Doc / Slides / Design (`artifacts-export/`) — so the people it is shared with need no access to the claude.ai account that published it. A shared skill or plugin can be **installed** into the viewer's own account.
|
||||
- **Server mode:** every artifact is the OWNER's and private by default. The owner can **Share** it with everyone — it appears in the **Hub** for every signed-in user — or with chosen people only (`artifact_shares`; they find it under **Shared with you**). A `page` keeps a viewable copy — its HTML file (from the workspace folder, or from the publishing session's own Claude Code scratchpad, where the Artifact tool writes a page by default), or the copy Claude exports of a Doc / Slides / Design (`artifacts-export/`) — so the people it is shared with need no access to the claude.ai account that published it. A shared skill or plugin can be **installed** into the viewer's own account.
|
||||
|
||||
## Rules (binding, same spirit as CLAUDE.md)
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.74",
|
||||
"version": "2.8.77",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.74",
|
||||
"version": "2.8.77",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.74",
|
||||
"version": "2.8.77",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -30,6 +30,8 @@ process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
// Claude Code's scratchpads (`<tmp>/claude-<uid>/…`) go under this test's own folder.
|
||||
process.env.CLAUDE_CODE_TMPDIR = ROOT;
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const { db } = require("../db");
|
||||
@@ -1223,6 +1225,92 @@ describe("a page's own copy, viewable without its claude.ai account", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// The Artifact tool writes a page into the session's own scratchpad by default.
|
||||
const scratchpad = (sid, cwd = ws) =>
|
||||
path.join(
|
||||
ROOT,
|
||||
`claude-${process.getuid()}`,
|
||||
cwd.replace(/[^A-Za-z0-9]/g, "-"),
|
||||
sid,
|
||||
"scratchpad"
|
||||
);
|
||||
const SID = "5f0c1e2a-6b7d-4c8e-9f10-112233445566";
|
||||
|
||||
it("copies a page published from its session's scratchpad", async () => {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO sessions (id, name, status, cwd, lane_id) VALUES (?, 'sp', 'active', ?, ?)"
|
||||
).run(SID, ws, lane.id);
|
||||
const dir = scratchpad(SID);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "themes.html"), "<h1>themes</h1>");
|
||||
fs.writeFileSync(path.join(dir, "app.js"), "1");
|
||||
const row = artifacts.captureFromHook(SID, {
|
||||
cwd: ws,
|
||||
tool_input: {
|
||||
action: "publish",
|
||||
file_path: path.join(dir, "themes.html"),
|
||||
files: { "app.js": path.join(dir, "app.js"), "x.html": path.join(ws, "pub", "deck.html") },
|
||||
},
|
||||
tool_response: "https://claude.ai/artifact/scratch-1",
|
||||
});
|
||||
await Promise.all(artifacts.__pageCopies());
|
||||
const kept = db.prepare("SELECT files FROM artifacts WHERE id = ?").get(row.id);
|
||||
assert.ok(kept.files, "the page has a copy");
|
||||
// A supporting file comes only from the page's own root, never the workspace.
|
||||
assert.deepEqual(
|
||||
JSON.parse(kept.files).map((f) => f.path),
|
||||
["themes.html", "app.js"]
|
||||
);
|
||||
assert.match(fs.readFileSync(artifacts.snapshotFile(null, row.id).abs, "utf8"), /themes/);
|
||||
});
|
||||
|
||||
it("never copies from another session's scratchpad or through a linked one", async () => {
|
||||
const other = scratchpad("other-session-1");
|
||||
fs.mkdirSync(other, { recursive: true });
|
||||
fs.writeFileSync(path.join(other, "x.html"), "<p>not mine</p>");
|
||||
const foreign = artifacts.captureFromHook(SID, {
|
||||
cwd: ws,
|
||||
tool_input: { action: "publish", file_path: path.join(other, "x.html") },
|
||||
tool_response: "https://claude.ai/artifact/scratch-foreign",
|
||||
});
|
||||
// A scratchpad that is a link to somewhere else is refused too.
|
||||
const sid2 = "linked-session-2";
|
||||
const real = path.join(ROOT, "elsewhere");
|
||||
fs.mkdirSync(real, { recursive: true });
|
||||
fs.writeFileSync(path.join(real, "y.html"), "<p>elsewhere</p>");
|
||||
fs.mkdirSync(path.dirname(scratchpad(sid2)), { recursive: true });
|
||||
fs.symlinkSync(real, scratchpad(sid2));
|
||||
const linked = artifacts.captureFromHook(sid2, {
|
||||
cwd: ws,
|
||||
tool_input: { action: "publish", file_path: path.join(scratchpad(sid2), "y.html") },
|
||||
tool_response: "https://claude.ai/artifact/scratch-linked",
|
||||
});
|
||||
await Promise.all(artifacts.__pageCopies());
|
||||
for (const r of [foreign, linked]) {
|
||||
assert.equal(
|
||||
db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(r.id).snapshot,
|
||||
null
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("takes an older scratchpad page's copy on first view", async () => {
|
||||
fs.writeFileSync(path.join(scratchpad(SID), "older.html"), "<p>older</p>");
|
||||
const id = Number(
|
||||
db
|
||||
.prepare(
|
||||
"INSERT INTO artifacts (kind, title, lane_id, session_id, source, url) VALUES ('page', 'older', ?, ?, ?, 'https://claude.ai/artifact/older-sp')"
|
||||
)
|
||||
.run(lane.id, SID, path.join(scratchpad(SID), "older.html")).lastInsertRowid
|
||||
);
|
||||
const r = await request("POST", `/api/artifacts/${id}/copy`, {});
|
||||
assert.equal(r.status, 200, r.text);
|
||||
assert.deepEqual(
|
||||
r.body.item.files.map((f) => f.path),
|
||||
["older.html"]
|
||||
);
|
||||
});
|
||||
|
||||
it("takes an older page's copy on first view, once", async () => {
|
||||
fs.writeFileSync(path.join(ws, "pub", "old.html"), "<p>old</p>");
|
||||
const id = Number(
|
||||
|
||||
+1
-1
@@ -2089,7 +2089,7 @@ try {
|
||||
}
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_id)`);
|
||||
// A user's OWN choice of one pool account per tool (Settings → AI tools →
|
||||
// Pool account, account-pins.js `setChoice`): below an admin's pin, above the
|
||||
// Profiles, account-pins.js `setChoice`): below an admin's pin, above the
|
||||
// balancer, and only among the accounts the user's group may use.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS account_choices (
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* `setAccountUsers` is the Users page's Edit of one account: who runs on it.
|
||||
*
|
||||
* A user may also CHOOSE an account of their own (`account_choices`, Settings
|
||||
* → AI tools → Pool account, `/api/me/pool-accounts`): `preferredFor` — the
|
||||
* → AI tools → Profiles, `/api/me/pool-accounts`): `preferredFor` — the
|
||||
* balancers' question — answers the admin's pin first, then that choice. A
|
||||
* choice is only ever among the accounts the user's group may use
|
||||
* (pool-groups.js `allowed`), checked when it is made and again at every
|
||||
@@ -109,7 +109,7 @@ function setChoice(userId, tool, account) {
|
||||
}
|
||||
|
||||
/**
|
||||
* What Settings → AI tools → Pool account shows `userId`: per tool with at
|
||||
* What Settings → AI tools → Profiles shows `userId`: per tool with at
|
||||
* least one account they may use, those accounts (never a token), the admin's
|
||||
* pin and their own choice.
|
||||
*/
|
||||
|
||||
+122
-21
@@ -494,7 +494,8 @@ function captureFromHook(sessionId, data, peer = null) {
|
||||
const file = typeof input.file_path === "string" ? input.file_path : "";
|
||||
// A publish of a page from a file: keep a copy anyone it is shared with can
|
||||
// view here, without a claude.ai login (a republish takes the new version).
|
||||
const copy = laneId != null && file ? pageCopy(laneId, data?.cwd, file, input.files) : null;
|
||||
const copy =
|
||||
laneId != null && file ? pageCopy(laneId, data?.cwd, file, input.files, sessionId) : null;
|
||||
if (same) {
|
||||
if (copy) schedulePageSnapshot(same.id, copy);
|
||||
return same;
|
||||
@@ -522,6 +523,9 @@ function captureFromHook(sessionId, data, peer = null) {
|
||||
// such file: Claude is asked (scripts/artifact-export-hint.js) to export a
|
||||
// copy to `artifacts-export/<artifact id>.(md|pdf)`, which the Stop capture
|
||||
// attaches to the row instead (`captureOutputsNow`).
|
||||
// The file is taken from the workspace folder OR from the publishing
|
||||
// session's own Claude Code scratchpad — where the Artifact tool writes a
|
||||
// page by default — and from nowhere else.
|
||||
|
||||
const PAGE_FILE_EXT = /\.html?$/i;
|
||||
const PAGE_FILES_MAX = 50;
|
||||
@@ -530,29 +534,118 @@ const PUBLISHED_RE =
|
||||
/** Where Claude is asked to export a typed artifact's copy. */
|
||||
const EXPORT_DIR = "artifacts-export";
|
||||
|
||||
const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
/** Claude Code's name for a project folder in its temp tree: every non-alphanumeric is "-". */
|
||||
const projectKey = (cwd) => String(cwd).replace(/[^A-Za-z0-9]/g, "-");
|
||||
|
||||
/** The uid a workspace's sessions run as (a member's under S3, else this process's), or null. */
|
||||
function workspaceUid(lane) {
|
||||
try {
|
||||
const acct = require("./run-as").accountForPath(lane.cwd);
|
||||
return acct ? acct.uid : typeof process.getuid === "function" ? process.getuid() : null;
|
||||
} catch {
|
||||
return null; // EMEMBERGONE: nobody to read as
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What to copy for a page: `{laneId, main, extra: [{rel, as}]}` with every
|
||||
* path workspace-relative, or null when the main file is not an HTML file
|
||||
* inside the workspace. `cwd` is the session's folder (a relative path in
|
||||
* the hook is relative to it); `files` is the Artifact tool's supporting
|
||||
* files map (`{published: source | {from}}`) or list (`[{path}]`).
|
||||
* The scratchpad Claude Code gives ONE session —
|
||||
* `<tmp>/claude-<uid>/<project key>/<session id>/scratchpad`, `<tmp>` being
|
||||
* `/tmp`, `os.tmpdir()` or `CLAUDE_CODE_TMPDIR` — as `{dir, uid}`, when `abs`
|
||||
* lies inside it; else null. Only the text is judged here: `scratchpadSafe`
|
||||
* checks the folders on disk before anything is read.
|
||||
*/
|
||||
function pageCopy(laneId, cwd, file, files) {
|
||||
function scratchpadOf(lane, sessionId, cwd, abs) {
|
||||
if (!SESSION_ID_RE.test(String(sessionId ?? ""))) return null;
|
||||
const uid = workspaceUid(lane);
|
||||
if (uid == null) return null;
|
||||
const tail = path.join(`claude-${uid}`, projectKey(cwd), String(sessionId), "scratchpad");
|
||||
const bases = new Set(
|
||||
[require("node:os").tmpdir(), "/tmp", process.env.CLAUDE_CODE_TMPDIR]
|
||||
.filter((b) => typeof b === "string" && path.isAbsolute(b))
|
||||
.map((b) => path.resolve(b))
|
||||
);
|
||||
for (const b of bases) {
|
||||
const dir = path.join(b, tail);
|
||||
const rel = path.relative(dir, abs);
|
||||
if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return { dir, uid };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Each of the scratchpad's four own folders is a real directory of its account (never a link). */
|
||||
async function scratchpadSafe(sp) {
|
||||
let d = sp.dir;
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
let st;
|
||||
try {
|
||||
st = await fs.promises.lstat(d);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!st.isDirectory() || st.uid !== sp.uid) return false;
|
||||
d = path.dirname(d);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open `rel` inside a scratchpad like a pinned file is opened in a
|
||||
* workspace: confined by its REAL path, not a link, a regular file of the
|
||||
* scratchpad's account. `{size, stream}`.
|
||||
*/
|
||||
async function openScratch(sp, rel) {
|
||||
const refused = () =>
|
||||
Object.assign(new Error("not a file of this scratchpad"), { code: "ENOFILE" });
|
||||
const root = await fs.promises.realpath(sp.dir);
|
||||
const real = await fs.promises.realpath(path.resolve(sp.dir, rel));
|
||||
const r = path.relative(root, real);
|
||||
if (!r || r.startsWith("..") || path.isAbsolute(r)) throw refused();
|
||||
const { O_RDONLY, O_NOFOLLOW = 0, O_NONBLOCK = 0 } = fs.constants;
|
||||
const fh = await fs.promises.open(real, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
|
||||
const st = await fh.stat();
|
||||
if (!st.isFile() || st.uid !== sp.uid) {
|
||||
await fh.close();
|
||||
throw refused();
|
||||
}
|
||||
return {
|
||||
size: st.size,
|
||||
stream: fh.createReadStream({ start: 0, end: Math.max(0, st.size - 1) }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What to copy for a page: `{laneId, main, extra: [{rel, as}], scratch?}`,
|
||||
* every path relative to its root — the workspace folder, or (`scratch`,
|
||||
* `{dir, uid}`) the publishing session's own scratchpad — or null when the
|
||||
* main file is not an HTML file inside either. `cwd` is the session's folder
|
||||
* (a relative path in the hook is relative to it); `files` is the Artifact
|
||||
* tool's supporting files map (`{published: source | {from}}`) or list
|
||||
* (`[{path}]`), each taken only from the main file's root.
|
||||
*/
|
||||
function pageCopy(laneId, cwd, file, files, sessionId) {
|
||||
const lane = require("./lanes").getLane(Number(laneId));
|
||||
if (!lane || !PAGE_FILE_EXT.test(file)) return null;
|
||||
const base = typeof cwd === "string" && path.isAbsolute(cwd) ? cwd : lane.cwd;
|
||||
const inLane = (p) => {
|
||||
const rel = path.relative(lane.cwd, path.resolve(base, p));
|
||||
const under = (root) => (p) => {
|
||||
const rel = path.relative(root, path.resolve(base, p));
|
||||
return rel && !rel.startsWith("..") && !path.isAbsolute(rel)
|
||||
? rel.split(path.sep).join("/")
|
||||
: null;
|
||||
};
|
||||
const main = inLane(file);
|
||||
if (!main) return null;
|
||||
let inRoot = under(lane.cwd);
|
||||
let main = inRoot(file);
|
||||
let scratch = null;
|
||||
if (!main) {
|
||||
scratch = scratchpadOf(lane, sessionId, base, path.resolve(base, file));
|
||||
if (!scratch) return null;
|
||||
inRoot = under(scratch.dir);
|
||||
main = inRoot(file);
|
||||
}
|
||||
const extra = [];
|
||||
const add = (as, src) => {
|
||||
if (typeof as !== "string" || !PUBLISHED_RE.test(as) || typeof src !== "string") return;
|
||||
const rel = inLane(src);
|
||||
const rel = inRoot(src);
|
||||
if (rel && extra.length < PAGE_FILES_MAX) extra.push({ rel, as });
|
||||
};
|
||||
if (Array.isArray(files)) {
|
||||
@@ -562,7 +655,7 @@ function pageCopy(laneId, cwd, file, files) {
|
||||
add(as, typeof v === "string" ? v : v && typeof v.from === "string" ? v.from : null);
|
||||
}
|
||||
}
|
||||
return { laneId: lane.id, main, extra };
|
||||
return scratch ? { laneId: lane.id, main, extra, scratch } : { laneId: lane.id, main, extra };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -571,9 +664,11 @@ function pageCopy(laneId, cwd, file, files) {
|
||||
* written at its published path `as`. A file that is a secret, gone or
|
||||
* outside the workspace is skipped; the total stays under FILE_MAX_BYTES.
|
||||
* Returns `{snap, files: [{path, size}]}`, or null when nothing was copied.
|
||||
* `open(rel)` → `{size, stream}` reads one entry (default: the workspace's
|
||||
* guarded download).
|
||||
*/
|
||||
async function snapshotLaneFiles(lane, entries) {
|
||||
const { openDownload } = require("./lane-file-transfer");
|
||||
async function snapshotLaneFiles(lane, entries, open) {
|
||||
const read = open || ((rel) => require("./lane-file-transfer").openDownload(lane.cwd, rel));
|
||||
const snap = newSnapshotDir();
|
||||
const files = [];
|
||||
let total = 0;
|
||||
@@ -587,7 +682,7 @@ async function snapshotLaneFiles(lane, entries) {
|
||||
continue;
|
||||
let file;
|
||||
try {
|
||||
file = await openDownload(lane.cwd, rel);
|
||||
file = await read(rel);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
@@ -633,11 +728,14 @@ function setSnapshot(rowId, shot) {
|
||||
async function snapshotPage(rowId, copy) {
|
||||
const lane = require("./lanes").getLane(Number(copy.laneId));
|
||||
if (!lane) return null;
|
||||
const sp = copy.scratch;
|
||||
if (sp && !(await scratchpadSafe(sp))) return null;
|
||||
const mainAs = path.posix.basename(copy.main);
|
||||
const shot = await snapshotLaneFiles(lane, [
|
||||
{ rel: copy.main, as: mainAs },
|
||||
...copy.extra.filter((e) => e.as !== mainAs),
|
||||
]);
|
||||
const shot = await snapshotLaneFiles(
|
||||
lane,
|
||||
[{ rel: copy.main, as: mainAs }, ...copy.extra.filter((e) => e.as !== mainAs)],
|
||||
sp ? (rel) => openScratch(sp, rel) : undefined
|
||||
);
|
||||
if (!shot || shot.files[0].path !== mainAs) {
|
||||
if (shot) dropSnapshot(shot.snap.id);
|
||||
return null;
|
||||
@@ -681,7 +779,10 @@ async function ensurePageCopy(req, id) {
|
||||
}
|
||||
if (backfillTried.has(row.id)) return view(row, req);
|
||||
backfillTried.add(row.id);
|
||||
const copy = pageCopy(row.lane_id, null, row.source, null);
|
||||
const cwd = row.session_id
|
||||
? sql("SELECT cwd FROM sessions WHERE id = ?").get(row.session_id)?.cwd
|
||||
: null;
|
||||
const copy = pageCopy(row.lane_id, cwd || null, row.source, null, row.session_id);
|
||||
const next = copy ? await schedulePageSnapshot(row.id, copy) : null;
|
||||
return view(next || row, req);
|
||||
}
|
||||
|
||||
@@ -319,7 +319,7 @@ async function poolFor(uid) {
|
||||
const wholeSession = tool !== "claude";
|
||||
out[tool] = {
|
||||
pin,
|
||||
// The user's own pick (Settings → AI tools → Pool account), below a pin.
|
||||
// The user's own pick (Settings → AI tools → Profiles), below a pin.
|
||||
choice,
|
||||
group: group ? { id: group.id, name: group.name } : null,
|
||||
accounts: status
|
||||
|
||||
Reference in New Issue
Block a user