Compare commits

..

30 Commits

Author SHA1 Message Date
nntrivi2001 decf176b4b chore: v2.8.78 — workspace and session sharing 2026-11-06 13:05:10 +07:00
nntrivi2001 dea74b777e fix(server-mode): a session-only share resumes in a free slot only
A start that names no slot means pane 0; for a user with only a session of
the workspace that was let through and answered ERUNLIVE whenever another
user's agent held pane 0 (found by the browser run). The client already
sends slot: "auto"; the scope now requires it.
2026-11-06 11:45:25 +07:00
nntrivi2001 18e574a765 feat(server-mode): pane presence and typing audit (sharing phase 4)
A pane's tab shows the other people who have it open (pane-presence.js,
in memory, fed by the /ws-pty attach; GET /api/lanes/:id/presence and the
pane_presence broadcast to the owner and full collaborators) and says
"Billed to <name>" for a pane a collaborator opened (startedBy on the run).
The first real keystroke of each attach into a shared workspace's pane by
anyone but its owner is audited as `type into <run id>` (mouse, focus and
terminal replies to tmux do not count). The pty socket now pings, so a
vanished browser is ended at the next re-check and leaves the presence list.
2026-11-06 11:32:25 +07:00
nntrivi2001 879a6bb02d feat(server-mode): share one session; scope collaborators' pane tokens (sharing phase 3)
A workspace's owner (or an admin) can share ONE session from its right-click
menu (GET/PUT /api/lanes/:id/sessions/:sid/shares, table session_shares,
audited). The user sees the workspace (access: "session") with only that
session, joins the pane holding it, resumes it on their own account in a
free slot, and uses the workspace's git and Files reads and git writes;
everything else of the workspace answers 403 ESESSIONSHARE (scope.js
sessionShareAllows in scopeGuard). Hook/runtime broadcasts stay with full
collaborators.

A collaborator's pane token (readable by the owner's Linux account) now acts
as them inside that workspace only, and never starts or bills a session
(auth.js paneLaneAllows, 403 EPANESCOPE); a Terminal tab the owner or an
admin opens acts as the owner again. Docs say plainly that under S3 a
session share hands over the owner's account inside the terminal.
2026-11-06 11:25:40 +07:00
nntrivi2001 1a365a45bd feat(server-mode): collaborators run sessions on their own account (sharing phase 2)
A user a workspace is shared with may now start and resume AI sessions in
it. The run uses THEIR OWN account — a Claude pool setup-token, a Codex/Cursor
pool API key, or an AI Proxy of theirs — never the owner's login or a pool
login (409 ENOCOLLABACCOUNT otherwise). Their quota decides (EQUOTA, their own
flag file, the sweep goes by payer).

Money is per payer by UTC hour (session_payers, server/lib/session-payers.js):
a session nobody else ran is billed as before; one a collaborator started is
theirs; a resume changes hands from the next hour. Only Claude books by the
hour, so a cross-payer resume of any other tool and a collaborator's
resumeLast are refused (409 ECOLLABRESUME); a Terminal tab stays the owner's.
Users page, details, quotas, Today (mine=1) and developer budgets count by
payer. Docs: SERVER-MODE.md, API.md, CLAUDE.md, plan status.
2026-11-06 11:10:05 +07:00
nntrivi2001 54001585e2 fix(server-mode): close the gaps the review of workspace sharing found
- A Terminal tab a collaborator opens records them (dashboard_runs.started_by):
  its pane token acts as them — and only while still shared with them —
  never as the workspace owner (userFromPaneToken).
- POST /api/run always starts the lane's AI tool: a collaborator gets
  ECOLLABSTART whatever the body says (shell: true no longer slips past).
- A collaborator disabled since no longer locks the share list: kept on it
  (shown disabled) or taken off; only a new addition must be enabled.
- The workspace home tells a collaborator to open an existing session and
  keeps Start disabled instead of failing on every submit.
2026-11-06 10:48:29 +07:00
nntrivi2001 0bf552b793 chore(openapi): regenerate openapi.yaml (stale since 11-02) 2026-11-06 10:43:16 +07:00
nntrivi2001 87ba935c6e feat(server-mode): share a workspace with other users (sharing phase 1)
A workspace's owner (or an admin) shares it from its ⋯ menu → Share…
(lane_shares, GET/PUT /api/lanes/:id/shares, audited). The users it is shared
with see it, its sessions, Changes, history and live updates, join and type
into its open terminals, commit and push — every check through scope.js
(canSeeLane, sessionWhere, laneWhere, broadcastAudience viewers). Rename,
reset, remove, delete and sharing stay the owner's or an admin's
(403 ENOTOWNER), and a collaborator starts no AI session there yet
(403 ECOLLABSTART; a plain terminal tab is fine). Budgets and Today stay on
owned rows only (ownedOnlySessionWhere). Taking a share back sends lane_access
to those users and their terminals close at the next attach re-check.

Under S3 a collaborator works as the owner's Linux account; the Share dialog
says so plainly (docs/plans/workspace-sharing/, R1 option a).
2026-11-06 10:43:16 +07:00
nntrivi2001 045e46e111 docs(plans): workspace sharing — self-review findings (R1 blocks) 2026-11-06 10:27:43 +07:00
nntrivi2001 e7573f13d9 docs(plans): workspace sharing — open questions answered 2026-11-06 10:23:40 +07:00
nntrivi2001 9d28807f2c docs(plans): workspace & session sharing — draft for review 2026-11-06 10:19:48 +07:00
nntrivi2001 e60ebe468b chore: v2.8.77 — pool accounts in Profiles, Changes panel on the workspace home 2026-11-06 10:07:52 +07:00
nntrivi2001 26977780cc feat(workspace): the home's context card follows the scroll; no scrollbar
On the workspace home the Instructions / Memory / Context / Folder card is
sticky inside the home's scroller (scrolling itself, barless, when taller
than the window), and the home column hides its scrollbar — wheel and touch
still scroll it.
2026-11-06 10:05:32 +07:00
nntrivi2001 e06472ecd9 fix(workspace): the home no longer scrolls the page into empty space
Each Recents row carries an absolutely placed sr-only label. Placed against
the row outside the home's own scroll column, those labels escaped its
clipping and stretched the page by the hidden part of the list — an empty
page-length scroll under the home and the Changes panel. The column is now
relative, so its scroller contains them.
2026-11-06 09:50:27 +07:00
nntrivi2001 ab27629139 feat(workspace): the Changes panel shows on the workspace home too
The home used to hide the whole row the Changes panel lives in, so a
workspace's changes showed only once a session tab was open. The home now
shares that row: the panel (or its rail) sits beside it at the same width,
the page is locked to the viewport and the home scrolls in its own column.
The panel's Workspace context fold is left out there, since the home shows
that column itself. The console's section stays mounted and hidden (CSS).
2026-11-06 09:42:08 +07:00
nntrivi2001 3b1fdfac51 feat(workspace): the Changes panel's repo sections are an accordion
With several repos stacked in the Changes panel, only one section is open at
a time: the first starts open, opening another folds the rest so it gets the
whole height, and clicking the open one folds it. A chat's open-file request
opens the section holding the file. Folded sections stay mounted (CSS), so an
unsaved edit survives.
2026-11-06 09:25:46 +07:00
nntrivi2001 087c1ddb5d feat(settings): pool accounts are rows of Profiles; the separate Pool account section is gone
Settings → AI tools → Profiles now lists the account-pool accounts the user
may use (GET /api/me/pool-accounts) as rows of each tool's tab, with quota,
an 'in use' badge and Switch to (PUT /api/me/pool-accounts), plus an
Automatic row; an admin's pin locks them. A saved profile with the same
email as a pool account is shown once, as the pool row. Developers now see
Profiles too, with only their pool rows; Cursor gets a tab when the pool
holds a Cursor account for the user.
2026-11-06 09:21:46 +07:00
nntrivi2001 890cd08f57 chore: v2.8.76 — four new colour themes: Sunset Cloud, Terminal, Night Sea and Sakura 2026-11-05 13:54:01 +07:00
nntrivi2001 aabc95768c feat(appearance): Sunset Cloud, Terminal, Night Sea and Sakura colour themes
Four named themes from ai-chat-themes (MIT; colours only — no wallpapers,
fonts or custom CSS): three dark ones beside Darcula / Apple Dark / Orca
Dark and Sakura beside the light ones. Each theme's background, text and
accent colour become the full token ramp, held to the existing contrast
bar (text and fg-muted >= 4.5:1 on every surface, accent on its /15 chip,
white on accent-solid, status labels on their /20 chip). Old Paper was
left out.

The terminal took its ANSI blue from --accent, which would print blue as
green (Terminal) or coral next to red (Sunset Cloud); a theme can now set
--ansi-blue, which lib/terminalTheme.ts prefers.
2026-11-05 12:15:21 +07:00
nntrivi2001 b34b201bd1 chore: v2.8.75 — a page published from Claude's scratchpad keeps a viewable copy
- An artifact page published from the session's own scratchpad (where
  Claude Code's Artifact tool writes pages by default) now keeps a copy
  that viewers can preview here. Before, Preview said the file was gone.
2026-11-05 12:00:49 +07:00
nntrivi2001 c356cf96a1 fix(artifacts): a page published from its session's scratchpad keeps a viewable copy
The Artifact tool writes a page into the session's own Claude Code
scratchpad by default (<tmp>/claude-<uid>/<project>/<session>/scratchpad),
but pageCopy took the file only from the workspace folder, so such a page
got no copy and Preview said "the file ... is gone" while it was still there.

pageCopy now also accepts that ONE scratchpad, named from the publishing
session's id, its cwd and the workspace account's uid; before reading,
each of its four folders must be a real directory of that account, and each
file is realpath-confined, opened O_NOFOLLOW and must be a regular file of
that uid. Supporting files come only from the page's own root. The older-
page backfill (POST /api/artifacts/:id/copy) uses the session's cwd too.
The noCopy message now says the file may also lie outside both.
2026-11-05 11:20:34 +07:00
nntrivi2001 2a31537b80 chore: v2.8.74 — a member's chat no longer lags a turn behind the terminal
- Under server mode with per-user accounts, a member's Claude session chat
  showed the transcript as of the last import snapshot: transcript lookups
  used access(2), which cannot see past the member's 0700 projects folder.
  They now use stat, so the chat reads the live transcript again.
2026-11-05 09:39:30 +07:00
nntrivi2001 0cd853e330 fix(chat): a member's live transcript is found by stat, not access(2)
Under S3 the chat of a member's session lagged a whole turn behind the
terminal: claude-home.js located transcripts with fs.existsSync, which is
access(2) — run by the kernel with capabilities cleared — so the unit's
CAP_DAC_READ_SEARCH could not see past the member's 0700 ~/.claude/projects.
Every lookup fell back to the import-time snapshot, which is only refreshed
now and then. Transcript finders and the /transcripts route now use stat.
2026-11-05 09:29:58 +07:00
nntrivi2001 59533b997b chore: v2.8.73 — Artifacts folder bar restyled; hidden live profile gets a stand-in
- Artifacts: folders are rounded chips with a count badge; rename/delete are
  icons beside the picked folder (double-click renames); New folder is a
  dashed chip; the empty-folder hint mentions drag and drop.
- GET /api/pricing/quota: every Claude row carries its accountUuid.
- Settings → Profiles and the status bar's Usage panel: a profile hidden in
  this browser is left out of the Usage panel too; when the live login is
  hidden, the first visible profile is shown in its place (Logged in now,
  "in use", the % used chip with that profile's own usage). Display only.
2026-11-04 16:14:27 +07:00
nntrivi2001 e21120d51d chore: v2.8.72 — update survives npm-rewritten lockfiles; drag artifacts into folders
- update: before the --ff-only merge, put back package-lock.json /
  client/package-lock.json when they are the ONLY tracked changes (an
  earlier update's npm install rewrote them; v2.8.71 changed
  client/package-lock.json and the merge refused on such installs). Any
  other change still makes the merge refuse.
- Artifacts: drag a row or card onto a folder chip to put it there, onto
  "All folders" to take it out.
2026-11-04 15:27:48 +07:00
nntrivi2001 c946b73d11 chore: v2.8.71 — remote clients (company hub), artifacts shared with chosen people, folders and viewable copies, fixes
Remote clients (docs/plans/remote-clients/README.md, B1–B3 for the Claude pool):
- server/hub/ holds the hub-only code (devices, remote sessions, upload ingest,
  LAN gateway), required only in server mode; hub-boundary.test.js keeps local
  code from importing it.
- A local install enrolls with `agenthub remote login` or Settings → AI tools →
  Company hub; the "Company account" AI Proxy (reserved name agenthub.company)
  sends a session through the hub's gateway, which adds a pool account's token —
  no credential reaches the machine. Only those sessions are uploaded, hourly
  and at their end; secret-shaped strings are redacted on the client.
- Gateway refuses: unknown device (401), a message without a session id, an
  unregistered session under AGENTHUB_REMOTE_GATE=block, a user over quota
  (EQUOTA), a purged session (410). Device tokens 90 days, conversations 30.
- New alert rule type remote_unmonitored: a company-account session its device
  never registered; a late registration clears the flag instead.
- The hub imports a session once per upload round (chunks carry `more`),
  not once per chunk.

Artifacts:
- Share an artifact with chosen people (artifact_shares, PATCH sharedWith,
  GET /api/artifacts/people) besides everyone; a "Shared with you" tab.
  Only the owner or an admin sees or edits who it is shared with; audited.
- Folders: each account's own arrangement (agenthub.artifacts.folders pref).
- A claude.ai page keeps a copy viewable without the publishing account: its
  HTML file (and supporting files) snapshotted at publish, or the copy Claude
  saves for a Doc / Slides / Design in artifacts-export/ (a synchronous
  PostToolUse hint hook, scripts/artifact-export-hint.js, admin switch),
  attached to the page at Stop. Viewed in HtmlFrame's sandbox, Markdown, or
  pdf.js (pdfjs-dist 6.4.299, legacy build, loaded only for a PDF).

Fixes:
- tmux: a cached /proc/<pid>/exe of an exited server falls back to PATH's
  tmux (stop the last pane, start again within 10 s failed with ENOENT).
- shelves: a shelf restores beside changes in other files.
- chat: a finished reply ends the turn even with no hook at all.
- Settings → AI tools: profile list display tweaks.
2026-11-04 15:16:49 +07:00
nntrivi2001 3daf7ed4cb chore: v2.8.70 — select combobox, chat turn end without Stop, no Stop flicker 2026-11-03 14:09:39 +07:00
nntrivi2001 f9869ada6d fix(chat): a finished turn ends without a Stop hook; Stop no longer flickers
Claude Code skips the Stop hook in roughly one turn in fifteen (only the
prompt-suggestion helper's SubagentStop arrives), so the main agent stayed
"working" until the idle Notification a minute later and the chat kept
showing Stop on a finished answer. The transcript's turn_duration entry is
now an end of turn too: one newer than the session's last prompt, read
while main still works with no tool open, lands it in Waiting like Stop.

The chat's Stop button now drops to Send only after every busy signal has
been quiet for 2.5 s (Stop pressed still drops at once), so a spinner line
that comes and goes while the CLI redraws no longer flips it back and forth,
and only the newest "working" read is applied.
2026-11-03 14:02:33 +07:00
nntrivi2001 0fc436dc0c chore(select): no search box for the hour pickers and the webhook type
24 fixed hours (reports, backups) are found by type-ahead and the webhook
providers are a short fixed list; a search box there is only noise.
Branches, models, AI proxies and webhook field options keep theirs.
2026-11-03 13:33:12 +07:00
nntrivi2001 cd25791160 feat(select): long lists become a combobox; the branch switcher uses it (#88)
Select opens with a focused search box when it has more than 8 options
(or searchable): typing narrows by label/value, ↑/↓ move, Enter picks,
Esc closes; role=combobox drives the listbox via aria-activedescendant,
and options may carry a group heading. The git bar's native branch
select (and the separate search box added for #88) is replaced by it,
remote branches under their own heading.
2026-11-03 13:25:16 +07:00
159 changed files with 14244 additions and 1062 deletions
+10
View File
@@ -39,6 +39,16 @@
"async": true
}
]
},
{
"matcher": "Artifact|mcp__claude_ai_.*__batch",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/artifact-export-hint.js\"",
"timeout": 5
}
]
}
],
"Stop": [
+6 -4
View File
File diff suppressed because one or more lines are too long
+116
View File
@@ -1133,6 +1133,107 @@ function renderPricingTable(rules) {
);
}
/**
* `agenthub remote login|status|logout|upload` — this LOCAL AgentHub's link
* to the company hub (server/lib/remote-client.js, through the running
* dashboard's /api/remote-client). The password goes to the hub once and is
* never stored; only a device token is, which no command prints.
*/
async function cmdRemote(flags, positional) {
const sub = positional[0] || "status";
const show = (st) => {
if (!st.enrolled) {
console.log(
st.expired
? c.yellow(
`This machine's hub login expired (${st.hub}) — run: agenthub remote login ${st.hub}`
)
: "Not enrolled with a company hub — run: agenthub remote login <hub url>"
);
return;
}
console.log(
`${c.green("●")} Enrolled with ${c.bold(st.hub)} as ${c.bold(st.user?.username || "?")}`
);
console.log(
c.dim(
` device ${st.device?.name} (#${st.device?.id}), valid until ${new Date(st.device?.expiresAt).toLocaleString()}`
)
);
console.log(
c.dim(
" Only sessions started on the Company account send their conversation and tokens to the hub (hourly)."
)
);
};
if (sub === "login") {
const hub = positional[1];
if (!hub) {
console.error(
c.red("✖ usage: agenthub remote login <hub url> [--user NAME] [--name DEVICE]")
);
process.exit(1);
}
let username = flags.user ? String(flags.user) : "";
if (!username) {
if (!process.stdin.isTTY) {
console.error(c.red("✖ --user is required when the password comes from stdin"));
process.exit(1);
}
const rl = require("node:readline").createInterface({
input: process.stdin,
output: process.stdout,
});
username = await new Promise((r) =>
rl.question("Hub username: ", (a) => (rl.close(), r(a.trim())))
);
}
const password = await askPassword(`Hub password for ${username}: `);
const r = await api(
"POST",
"/api/remote-client/login",
{ hub, username, password, deviceName: flags.name ? String(flags.name) : undefined },
{ allowError: true, timeoutMs: 30_000 }
);
if (r && r.status) {
console.error(c.red(`✖ ${r.data?.error?.message || `HTTP ${r.status}`}`));
process.exit(1);
}
show(r);
return;
}
if (sub === "logout") {
const r = await post("/api/remote-client/logout", {});
console.log(
r.wasEnrolled
? `${c.green("✔")} Logged out${r.revokedOnHub ? " — the hub revoked this device" : c.yellow(" — the hub could not be reached; the token stops working when it expires or an admin revokes it")}`
: "This machine was not enrolled."
);
return;
}
if (sub === "upload") {
const r = await post("/api/remote-client/upload", {}, { timeoutMs: 600_000 });
console.log(`${c.green("✔")} Sent ${r.bytes} bytes of ${r.sessions} session(s)`);
for (const e of r.errors || []) console.log(c.yellow(` ${e.sessionId}: ${e.message}`));
return;
}
if (sub === "status") {
const st = await get("/api/remote-client");
if (flags.json) return console.log(JSON.stringify(st, null, 2));
show(st);
for (const u of (st.uploads || []).slice(0, 10)) {
console.log(
c.dim(
` ${u.sessionId.slice(0, 8)} ${String(u.bytes || 0).padStart(9)} B ${u.lastSentAt || "not sent yet"} ${u.cwd || ""}`
)
);
}
return;
}
console.error(c.red(`✖ unknown: agenthub remote ${sub} (login | status | logout | upload)`));
process.exit(1);
}
async function cmdPricing(flags, positional) {
const sub = positional[0];
if (sub === "set" && positional[1]) {
@@ -2934,6 +3035,19 @@ const COMMAND_GROUPS = [
["remote-sources rm <id>", "[--purge]", "Remove a source (--purge also deletes its data)"],
],
],
[
"Company hub",
[
[
"remote login <hub url>",
"[--user NAME --name DEVICE]",
"Enroll this machine (Company account)",
],
["remote status", "[--json]", "Enrollment and what was sent to the hub"],
["remote upload", "", "Send the Company-account sessions' new lines now"],
["remote logout", "", "Revoke this machine on the hub and forget it"],
],
],
[
"Workspaces",
[
@@ -4549,6 +4663,8 @@ async function runCommand(argv) {
case "remote-sources":
case "remotes":
return cmdRemoteSources(flags, positional);
case "remote":
return cmdRemote(flags, positional);
case "pricing":
return cmdPricing(flags, positional);
case "import":
+247
View File
@@ -25,6 +25,7 @@
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.61.0",
"pdfjs-dist": "6.4.299",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-i18next": "^17.0.4",
@@ -1194,6 +1195,241 @@
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
"license": "MIT"
},
"node_modules/@napi-rs/canvas": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.1.0.tgz",
"integrity": "sha512-k+ph/D8H9GS8ZZ48okvHrKeJynkqAA1kMUZRuzRWNlQUDHXngq7oRaWMZBdmSfd9ZhVy9bwNKei/7wMboI/uMg==",
"optional": true,
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.1.0",
"@napi-rs/canvas-darwin-arm64": "1.1.0",
"@napi-rs/canvas-darwin-x64": "1.1.0",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.1.0",
"@napi-rs/canvas-linux-arm64-gnu": "1.1.0",
"@napi-rs/canvas-linux-arm64-musl": "1.1.0",
"@napi-rs/canvas-linux-riscv64-gnu": "1.1.0",
"@napi-rs/canvas-linux-x64-gnu": "1.1.0",
"@napi-rs/canvas-linux-x64-musl": "1.1.0",
"@napi-rs/canvas-win32-arm64-msvc": "1.1.0",
"@napi-rs/canvas-win32-x64-msvc": "1.1.0"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.1.0.tgz",
"integrity": "sha512-mKrSmRyhWK4q699Q1xthbZIZi7JBvLtsRNnOZAeFaLPOFT1yo8y82aRC2nNZbXTGJXU7fVDONgGQPr0vo3kerw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.1.0.tgz",
"integrity": "sha512-iqwST4g0oH+x8VCpBIYh+MYokv/mVvkM+v9oJGB0d9A3P2qBh42ogJlVyWq9SpPCrAjS7tplMb1D3OIfxl+utw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.1.0.tgz",
"integrity": "sha512-jkeA8eVTxDum03LTvc32YJ28F6KB1U0gYwnWMfOAUQZvWWEh/kvONby4iWGJx3+Tw45UqMGMXjtw2GKNVZDhVg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.1.0.tgz",
"integrity": "sha512-Y4YkKG1ijoqkgUdTEEBNG4k+2BK5bR5jGL6S56YxCXYPwGfUd5nED8/74xyc7z+bpixPasCjSYoP3xYPOIGNxw==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.1.0.tgz",
"integrity": "sha512-dE/XthqYkiWcBVtKFlZbUXwAYfDuQGqDWixznipJNmevLGpprNnTVQZyftm1RZJudkIOn2EkzYZDMMETM08Row==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.1.0.tgz",
"integrity": "sha512-DqZ/CMcHlhU56pPjv9AP0h3U7MFQ5LzC1HzMyRMotL+PFsJmZJA/ziApPYVyyz78DaVLhI8t3So3ztqtijbYpg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.1.0.tgz",
"integrity": "sha512-kJ7m5n2VffxWXM5QGa5lfq8ZN4LdOJEoz3aAJAmIqYvKiZw1gRdXqpucnRXsF6D9nLYiiIlevaAtAclR7c5ilg==",
"cpu": [
"riscv64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.1.0.tgz",
"integrity": "sha512-uQ9JuJlRhQdHyttcMWOiwLkfIqkqaB0YFUVe835LUe12THt2b51HXkTItq0fxWT1jdhUPX2ylWZ1walkJyD6GQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.1.0.tgz",
"integrity": "sha512-has4CPPAkkCac/LZ13VAw5p+MJLdaKVoyrhumN/A7uJd6960xYkJNDZgUebyxiha82AFzXx6RV+QJg5qGG079g==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.1.0.tgz",
"integrity": "sha512-MJnktLKNijmXqdjTughHDZ9TDLZ9uUm2hvFXqaOqn0+w7lphXFnft6bpSgQAc9U1exc20UGIBlzGu9+kiCj9Ag==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.1.0.tgz",
"integrity": "sha512-08575bFk7iiadxap4h+JxgGut9xDCHAGum6wh0eInN53zEMImkmEHi5MushX2FOmNO7qRNX7BPiDC5ZYBwHeEg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -5406,6 +5642,17 @@
"node": ">= 14.16"
}
},
"node_modules/pdfjs-dist": {
"version": "6.4.299",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.4.299.tgz",
"integrity": "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.10"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+1
View File
@@ -28,6 +28,7 @@
"i18next": "^26.0.8",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.61.0",
"pdfjs-dist": "6.4.299",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-i18next": "^17.0.4",
@@ -132,6 +132,8 @@ const RULE_TYPES: AlertRuleType[] = [
"cost_threshold",
"budget",
];
/** Only a hub (server mode) has remote clients to alert about. */
const SERVER_RULE_TYPES: AlertRuleType[] = ["remote_unmonitored"];
type TabKey = "rules" | "channels" | "activity";
@@ -243,6 +245,8 @@ function buildConfig(form: RuleFormState): AlertRule["config"] {
if (form.budget_tool) config.tool = form.budget_tool;
return config;
}
case "remote_unmonitored":
return {};
}
}
@@ -278,6 +282,8 @@ function describeRule(rule: AlertRule, t: (key: string, opts?: Record<string, un
period: t(`periods.${c.period ?? "month"}`),
thresholds: (c.thresholds ?? []).join("%, ") + "%",
});
case "remote_unmonitored":
return t("ruleDesc.remoteUnmonitored");
}
}
@@ -286,7 +292,8 @@ export function AlertsNotifications() {
const { t: ts } = useTranslation("settings");
// Rules and channels (webhooks) fire for every user's sessions: an admin's.
const { isAdmin } = usePermissions();
const { isAdmin, local } = usePermissions();
const ruleTypes = local ? RULE_TYPES : [...RULE_TYPES, ...SERVER_RULE_TYPES];
const [tab, setTab] = useState<TabKey>(isAdmin ? "rules" : "activity");
// Rules
@@ -613,7 +620,7 @@ export function AlertsNotifications() {
title={editingId ? t("rules.typeFixed") : undefined}
className="select w-full"
>
{RULE_TYPES.map((type) => (
{ruleTypes.map((type) => (
<option key={type} value={type}>
{t(`ruleTypes.${type}`)}
</option>
+4
View File
@@ -72,6 +72,10 @@ export function usePermissions() {
// A developer only ever sees their own rows, so everything shown is theirs.
canEditLane: (lane?: { owner_id?: number | null } | null) =>
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
// Owner-only actions on a workspace (rename, reset, remove, share): its
// owner or an admin. A workspace shared with this user is not theirs.
ownsLane: (lane?: { owner_id?: number | null } | null) =>
isAdmin || (!!user && lane?.owner_id === user.id),
};
}
+102
View File
@@ -0,0 +1,102 @@
/**
* @file PdfView.tsx
* @description A PDF drawn by pdf.js onto canvases, page after page — the
* Artifacts preview's PDF viewer. The browser's own viewer cannot run inside
* the sandbox the preview keeps (it blocks plugins), and opening a shared PDF
* on the dashboard's origin would be the risk the sandbox exists for; pdf.js
* only DRAWS the file (no scripting sandbox loaded, no XFA, no links
* followed), so nothing in it runs. pdf.js and its worker are loaded the
* first time a PDF is opened (a separate chunk), never with the dashboard.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
/** Pages drawn at most: a 500-page export would otherwise hold every canvas. */
const MAX_PAGES = 200;
const MAX_BYTES = 64 * 1024 * 1024;
async function loadPdfJs() {
// The legacy build: the modern one needs `Promise.try` and friends, which a
// browser a year old lacks — the PDF would then just fail to open.
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
if (!pdfjs.GlobalWorkerOptions.workerSrc) {
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
import.meta.url
).toString();
}
return pdfjs;
}
export function PdfView({ url, title }: { url: string; title: string }) {
const { t } = useTranslation("artifacts");
const box = useRef<HTMLDivElement>(null);
const [state, setState] = useState<"loading" | "ready" | "failed">("loading");
const [pages, setPages] = useState(0);
useEffect(() => {
let live = true;
let destroy: (() => void) | null = null;
setState("loading");
(async () => {
const res = await fetch(url, { credentials: "same-origin" });
if (!res.ok) throw new Error(String(res.status));
const data = new Uint8Array(await res.arrayBuffer());
if (data.byteLength > MAX_BYTES) throw new Error("too big");
const pdfjs = await loadPdfJs();
const task = pdfjs.getDocument({
data,
enableXfa: false,
disableAutoFetch: true,
});
destroy = () => void task.destroy();
const doc = await task.promise;
if (!live || !box.current) return;
setPages(doc.numPages);
const width = Math.max(320, box.current.clientWidth - 32);
const ratio = window.devicePixelRatio || 1;
box.current.replaceChildren();
for (let n = 1; n <= Math.min(doc.numPages, MAX_PAGES); n++) {
const page = await doc.getPage(n);
if (!live || !box.current) return;
const base = page.getViewport({ scale: 1 });
const viewport = page.getViewport({ scale: (width / base.width) * ratio });
const canvas = document.createElement("canvas");
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
canvas.style.width = `${Math.floor(viewport.width / ratio)}px`;
canvas.className = "mx-auto mb-3 block bg-white shadow";
canvas.setAttribute("aria-label", t("pdf.page", { n }));
box.current.appendChild(canvas);
await page.render({ canvas, viewport }).promise;
if (n === 1 && live) setState("ready");
}
})().catch(() => live && setState("failed"));
return () => {
live = false;
destroy?.();
};
}, [url, t]);
return (
<div className="relative h-full overflow-auto bg-surface-2 p-4" data-testid="pdf-view">
{state === "loading" && (
<div className="absolute inset-0 flex items-center justify-center text-fg-muted">
<Loader2 className="h-5 w-5 animate-spin" aria-label={t("loading")} />
</div>
)}
{state === "failed" && (
<p className="p-6 text-center text-sm text-fg-secondary">{t("pdf.failed")}</p>
)}
<div ref={box} role="document" aria-label={title} />
{pages > MAX_PAGES && (
<p className="py-2 text-center text-xs text-fg-muted">
{t("pdf.more", { shown: MAX_PAGES, total: pages })}
</p>
)}
</div>
);
}
+129 -52
View File
@@ -15,6 +15,14 @@
* the next option whose label starts with it. Focus returns to the trigger on
* every close except Tab.
*
* ## Search — a combobox once the list is long
* With more than `SEARCH_FROM` options (or `searchable`), the popup opens with
* a search box on top, focused: typing narrows the list to options whose
* label or value contains the text (case-insensitive), ↑/↓ move, Enter picks,
* Escape closes. The box is `role="combobox"` controlling the listbox through
* `aria-activedescendant`. Options may carry a `group`: a heading is drawn
* where the group changes.
*
* ## Consumers
* Used on the Run Claude page and webhook settings form wherever a compact
* enum picker is needed.
@@ -73,7 +81,11 @@
* ----------------------------------------------------------------------------- */
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { ChevronDown, Check } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Check, Search } from "lucide-react";
/** A list longer than this gets a search box (unless `searchable` says otherwise). */
export const SEARCH_FROM = 8;
/** Room above and below `r` (the trigger's rect) for a menu it opens: inside
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
@@ -95,6 +107,8 @@ export interface SelectOption<T extends string> {
icon?: ReactNode;
/** Draw a rule above this option: it starts a group of its own. */
divider?: boolean;
/** The group this option belongs to: a heading is drawn where it changes. */
group?: string;
}
/** Props for {@link Select}. */
@@ -111,6 +125,10 @@ export interface SelectProps<T extends string> {
variant?: "field" | "ghost";
/** Accessible name and tooltip of the trigger (a ghost one has no label). */
label?: string;
/** Show the search box: default only for lists longer than `SEARCH_FROM`. */
searchable?: boolean;
/** The search box's name and placeholder (default: "Search…"). */
searchLabel?: string;
}
/**
@@ -124,15 +142,26 @@ export function Select<T extends string>({
disabled,
variant = "field",
label,
searchable,
searchLabel,
}: SelectProps<T>) {
const { t } = useTranslation("common");
const ghost = variant === "ghost";
const canSearch = searchable ?? options.length > SEARCH_FROM;
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const [openUp, setOpenUp] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const searchRef = useRef<HTMLInputElement | null>(null);
const typed = useRef({ text: "", at: 0 });
// The options shown: all of them, or those the search text matches.
const q = canSearch ? query.trim().toLowerCase() : "";
const shown = q
? options.filter((o) => `${o.label} ${o.value}`.toLowerCase().includes(q))
: options;
const id = useId();
const optionId = (idx: number) => `${id}-opt-${idx}`;
@@ -150,6 +179,7 @@ export function Select<T extends string>({
// above the trigger (288 px = `max-h-72`), and move focus into the list.
useEffect(() => {
if (!open) return;
setQuery("");
const idx = options.findIndex((o) => o.value === value);
setActive(idx >= 0 ? idx : 0);
if (buttonRef.current) {
@@ -159,14 +189,14 @@ export function Select<T extends string>({
);
setOpenUp(below < 288 && above > below);
}
listRef.current?.focus({ preventScroll: true });
(canSearch ? searchRef.current : listRef.current)?.focus({ preventScroll: true });
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
}, [open]);
useEffect(() => {
if (open) document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
// eslint-disable-next-line react-hooks/exhaustive-deps -- id is stable
}, [open, active]);
}, [open, active, q]);
const close = (refocus = true) => {
setOpen(false);
@@ -185,8 +215,12 @@ export function Select<T extends string>({
}
};
const onListKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
const last = options.length - 1;
const onListKey = (e: React.KeyboardEvent<HTMLElement>) => {
const last = shown.length - 1;
// In the search box, keys that edit text stay the box's own.
const inSearch = e.currentTarget === searchRef.current;
if (inSearch && (e.key === " " || e.key === "Home" || e.key === "End")) return;
if (inSearch && e.key === "Enter" && e.nativeEvent.isComposing) return;
const move: Record<string, (a: number) => number> = {
ArrowDown: (a) => Math.min(last, a + 1),
ArrowUp: (a) => Math.max(0, a - 1),
@@ -200,7 +234,7 @@ export function Select<T extends string>({
setActive(move[e.key]!);
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
const opt = options[active];
const opt = shown[active];
if (opt) choose(opt);
} else if (e.key === "Escape") {
// Stop here: a Select inside a dialog must not close the dialog too.
@@ -209,7 +243,7 @@ export function Select<T extends string>({
close();
} else if (e.key === "Tab") {
close(false);
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
} else if (!inSearch && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
// Type-ahead: keys typed within 500 ms build one prefix; a repeated single
// character cycles through the options that start with it.
const now = Date.now();
@@ -258,57 +292,100 @@ export function Select<T extends string>({
</button>
{open && (
<div
ref={listRef}
id={`${id}-list`}
role="listbox"
tabIndex={-1}
aria-label={label}
aria-activedescendant={options[active] ? optionId(active) : undefined}
onKeyDown={onListKey}
onBlur={(e) => {
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
}}
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 outline-none ${
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} flex max-h-72 flex-col overflow-hidden rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 ${
openUp ? "bottom-full mb-1" : "top-full mt-1"
}`}
>
{options.map((opt, idx) => {
const isSelected = opt.value === value;
const isActive = idx === active;
return (
<div
key={opt.value || "__default__"}
id={optionId(idx)}
role="option"
aria-selected={isSelected}
onMouseDown={(e) => e.preventDefault()}
onClick={() => choose(opt)}
onMouseEnter={() => setActive(idx)}
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
opt.divider ? "mt-1 border-t border-border pt-2" : ""
} ${
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
}`}
>
<div className="flex items-center gap-2">
{opt.icon}
<span
className={`text-[11px] flex-1 truncate ${
isSelected ? "text-accent font-medium" : "text-fg-secondary"
{canSearch && (
<div className="flex shrink-0 items-center gap-1.5 border-b border-border px-2.5 py-1.5">
<Search className="h-3 w-3 shrink-0 text-fg-muted" aria-hidden />
<input
ref={searchRef}
role="combobox"
aria-label={searchLabel ?? t("select.search")}
aria-expanded
aria-controls={`${id}-list`}
aria-autocomplete="list"
aria-activedescendant={shown[active] ? optionId(active) : undefined}
placeholder={searchLabel ?? t("select.search")}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
}}
onKeyDown={onListKey}
onBlur={(e) => {
if (!containerRef.current?.contains(e.relatedTarget as Node | null))
setOpen(false);
}}
className="min-w-0 flex-1 bg-transparent text-[11px] text-fg-primary placeholder:text-fg-muted focus:outline-none"
/>
</div>
)}
<div
ref={listRef}
id={`${id}-list`}
role="listbox"
tabIndex={-1}
aria-label={label}
aria-activedescendant={!canSearch && shown[active] ? optionId(active) : undefined}
onKeyDown={onListKey}
onBlur={(e) => {
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
}}
className="min-h-0 flex-1 overflow-auto py-1 outline-none"
>
{canSearch && shown.length === 0 && (
<div className="px-3 py-1.5 text-[11px] text-fg-muted">{t("select.noMatch")}</div>
)}
{shown.map((opt, idx) => {
const isSelected = opt.value === value;
const isActive = idx === active;
const heading = opt.group && opt.group !== shown[idx - 1]?.group ? opt.group : null;
return (
<div key={opt.value || "__default__"} role="none">
{heading && (
<div
role="presentation"
className="mt-1 border-t border-border px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-fg-muted"
>
{heading}
</div>
)}
<div
id={optionId(idx)}
role="option"
aria-selected={isSelected}
onMouseDown={(e) => e.preventDefault()}
onClick={() => choose(opt)}
onMouseEnter={() => setActive(idx)}
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
opt.divider ? "mt-1 border-t border-border pt-2" : ""
} ${
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
}`}
>
{opt.label}
</span>
{isSelected && (
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
)}
<div className="flex items-center gap-2">
{opt.icon}
<span
className={`text-[11px] flex-1 truncate ${
isSelected ? "text-accent font-medium" : "text-fg-secondary"
}`}
>
{opt.label}
</span>
{isSelected && (
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
)}
</div>
{opt.hint && (
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
)}
</div>
</div>
{opt.hint && (
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
)}
</div>
);
})}
);
})}
</div>
</div>
)}
</div>
+44 -2
View File
@@ -29,7 +29,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router-dom";
@@ -38,6 +38,12 @@ import { api, type RunHandle, type UserQuotaStatus } from "../lib/api";
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatDateTime, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import {
HIDDEN_PROFILES_EVENT,
isHiddenProfilesKey,
readHiddenProfiles,
standIn,
} from "../lib/hiddenProfilesStorage";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
import { useSpin } from "../hooks/useSpin";
import { sessionChangeMatcher } from "../lib/sessionChange";
@@ -276,10 +282,44 @@ function QuotaPanel({
);
}
/** The profiles hidden in Settings → AI tools → Profiles, kept current. */
function useHiddenProfiles(): string[] {
const [hidden, setHidden] = useState<string[]>(readHiddenProfiles);
useEffect(() => {
const again = () => setHidden(readHiddenProfiles());
const onStorage = (e: StorageEvent) => isHiddenProfilesKey(e.key) && again();
window.addEventListener(HIDDEN_PROFILES_EVENT, again);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(HIDDEN_PROFILES_EVENT, again);
window.removeEventListener("storage", onStorage);
};
}, []);
return hidden;
}
/**
* Claude rows without the hidden profiles. When the live login is one of
* them, the first visible profile stands in as the live row — its own usage
* on the chip and the panel — so the panel never names the hidden account.
*/
export function withoutHidden(tools: ToolQuota[], hidden: string[]): ToolQuota[] {
if (!hidden.length) return tools;
const claude = tools.filter((q) => q.id === "claude");
const isLive = (q: ToolQuota) => q.active !== false;
const decoy = standIn(claude, isLive, hidden);
const keep = (q: ToolQuota) =>
q.id !== "claude" || !q.accountUuid || !hidden.includes(q.accountUuid);
const shown = tools.filter(keep);
if (!decoy) return shown;
// The stand-in leads the Claude rows, as the live login does.
return [{ ...decoy, active: true }, ...shown.filter((q) => q !== decoy)];
}
/** The right-end quota chips; nothing at all until a tool reports one. */
function QuotaChips() {
const { t } = useTranslation("nav");
const [tools, setTools] = useState<ToolQuota[]>([]);
const [fetched, setTools] = useState<ToolQuota[]>([]);
const [open, setOpen] = useState(false);
// Leaving the chips closes the panel only after a beat, and entering the
// panel cancels that: the pointer crosses the gap to read it.
@@ -303,6 +343,8 @@ function QuotaChips() {
);
const [refreshing, refresh] = useSpin(() => load(true));
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
const hidden = useHiddenProfiles();
const tools = useMemo(() => withoutHidden(fetched, hidden), [fetched, hidden]);
if (!tools.length) return null;
return (
<>
@@ -559,6 +559,8 @@ export function WebhookSettings() {
headerRows: [],
})
}
// A short, fixed list of providers: no search box.
searchable={false}
options={providers.map((p) => ({ value: p.type, label: p.label }))}
/>
</div>
+10 -2
View File
@@ -90,6 +90,9 @@ import { UserAvatar } from "./users/UserAvatar";
const AddLaneModal = lazy(() =>
import("./lanes/AddLaneModal").then((m) => ({ default: m.AddLaneModal }))
);
const WorkspaceShareModal = lazy(() =>
import("./lanes/WorkspaceShareModal").then((m) => ({ default: m.WorkspaceShareModal }))
);
const DestructiveLaneModal = lazy(() =>
import("./lanes/DestructiveLaneModal").then((m) => ({ default: m.DestructiveLaneModal }))
);
@@ -116,7 +119,9 @@ export function WorkspaceTree() {
const selected = selectedFrom(location.pathname, location.search);
const { lanes, refresh: refreshLanes } = useLanes();
const { state: auth } = useAuth();
const { seesAll, canEditLane } = usePermissions();
const { seesAll, canEditLane, ownsLane } = usePermissions();
// Sharing a workspace with other users (server mode): the open dialog's lane.
const [sharing, setSharing] = useState<Lane | null>(null);
const meId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
// Server mode staff see every user's workspaces, so the list says whose.
const grouped = auth.mode === "server" && seesAll;
@@ -375,6 +380,7 @@ export function WorkspaceTree() {
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
if (action === "rename") setRenamingId(lane.id);
else if (action === "share") setSharing(lane);
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
else if (action === "reset" || action === "remove") setDestructive({ lane, action });
};
@@ -559,7 +565,8 @@ export function WorkspaceTree() {
onNewSession={() => select(lane.id)}
onReorder={nudge}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
// A workspace shared with this user keeps its owner's name.
onRenameStart={() => ownsLane(lane) && setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
)}
@@ -688,6 +695,7 @@ export function WorkspaceTree() {
</ContextMenu>
)}
<Suspense fallback={null}>
{sharing && <WorkspaceShareModal lane={sharing} onClose={() => setSharing(null)} />}
{destructive && (
<DestructiveLaneModal
lane={destructive.lane}
@@ -0,0 +1,58 @@
/**
* @file Tests for PdfView: pdf.js (mocked — jsdom has no canvas) draws one
* canvas per page, scripting off, and a PDF that cannot be read says so.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import "../../i18n";
const getDocument = vi.fn();
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
GlobalWorkerOptions: { workerSrc: "" },
getDocument: (...a: unknown[]) => getDocument(...a),
}));
import { PdfView } from "../PdfView";
const realFetch = globalThis.fetch;
beforeEach(() => {
getDocument.mockReset();
globalThis.fetch = vi.fn(
async () => new Response(new Uint8Array([37, 80, 68, 70]))
) as typeof fetch;
});
afterEach(() => {
globalThis.fetch = realFetch;
});
describe("PdfView", () => {
it("draws every page on its own canvas, with XFA off", async () => {
const render1 = vi.fn(() => ({ promise: Promise.resolve() }));
getDocument.mockReturnValue({
destroy: vi.fn(),
promise: Promise.resolve({
numPages: 3,
getPage: async () => ({
getViewport: ({ scale }: { scale: number }) => ({
width: 600 * scale,
height: 800 * scale,
}),
render: render1,
}),
}),
});
const { container } = render(<PdfView url="/api/artifacts/1/raw?path=a.pdf" title="a.pdf" />);
await waitFor(() => expect(container.querySelectorAll("canvas")).toHaveLength(3));
expect(render1).toHaveBeenCalledTimes(3);
expect(getDocument.mock.calls[0]![0]).toMatchObject({ enableXfa: false });
expect(screen.getByLabelText("Page 2")).toBeInTheDocument();
});
it("says so when the file cannot be read", async () => {
getDocument.mockReturnValue({ destroy: vi.fn(), promise: Promise.reject(new Error("bad")) });
render(<PdfView url="/x" title="x.pdf" />);
expect(await screen.findByText(/could not be shown/)).toBeInTheDocument();
});
});
@@ -105,7 +105,61 @@ describe("Select (listbox button)", () => {
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
vi.stubGlobal("innerHeight", 2000);
fireEvent.click(button);
expect(screen.getByRole("listbox").className).toContain("bottom-full");
expect(screen.getByRole("listbox").parentElement!.className).toContain("bottom-full");
vi.unstubAllGlobals();
});
it("turns a long list into a combobox: type to narrow, Enter picks, groups keep headings", async () => {
const onChange = vi.fn();
const options = [
{ value: "", label: "Switch branch…" },
...["develop", "main", "feat/campaign-pause", "fix/campaign-step", "chore/a", "chore/b"].map(
(v) => ({ value: v, label: v })
),
...["origin/feat/campaign-statistic", "origin/x", "origin/y"].map((v) => ({
value: v,
label: v,
group: "On the remote",
})),
];
render(<Select value="" onChange={onChange} options={options} label="Switch branch…" />);
fireEvent.click(screen.getByRole("button", { name: "Switch branch…" }));
const search = screen.getByRole("combobox", { name: "Search…" });
expect(search).toHaveFocus();
expect(screen.getByText("On the remote")).toBeInTheDocument();
fireEvent.change(search, { target: { value: "CAMPAIGN" } });
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
"feat/campaign-pause",
"fix/campaign-step",
"origin/feat/campaign-statistic",
]);
fireEvent.keyDown(search, { key: "ArrowDown" });
fireEvent.keyDown(search, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith("fix/campaign-step");
expect(screen.queryByRole("listbox")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Switch branch…" }));
fireEvent.change(screen.getByRole("combobox"), { target: { value: "zzz" } });
expect(screen.getByText("No match")).toBeInTheDocument();
fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" });
expect(screen.queryByRole("listbox")).toBeNull();
});
it("keeps a short list a plain listbox (no search box)", () => {
render(
<Select
value="a"
onChange={vi.fn()}
options={[
{ value: "a", label: "A" },
{ value: "b", label: "B" },
]}
label="Pick"
/>
);
fireEvent.click(screen.getByRole("button", { name: "Pick" }));
expect(screen.queryByRole("combobox")).toBeNull();
expect(screen.getByRole("listbox")).toHaveFocus();
});
});
@@ -162,7 +162,7 @@ vi.mock("../../lib/api", () => ({
}));
import { api } from "../../lib/api";
import { StatusBar } from "../StatusBar";
import { StatusBar, withoutHidden } from "../StatusBar";
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
const at = (url: string, ui: React.JSX.Element) =>
@@ -307,3 +307,35 @@ describe("StatusBar", () => {
expect(api.pricing.quota).toHaveBeenLastCalledWith(true);
});
});
describe("withoutHidden", () => {
const row = (accountUuid: string, active: boolean, pct: number) => ({
id: "claude" as const,
active,
accountUuid,
account: `${accountUuid}@x.y`,
windows: [{ label: "5h", usedPercent: pct, resetsAt: null }],
});
const codex = { id: "codex" as const, active: true, windows: [] };
it("leaves the list alone when nothing is hidden", () => {
const list = [row("u1", true, 82), row("u2", false, 19), codex];
expect(withoutHidden(list, [])).toBe(list);
});
it("drops a hidden saved profile", () => {
const out = withoutHidden([row("u1", true, 82), row("u2", false, 19), codex], ["u2"]);
expect(out.map((q) => q.accountUuid ?? q.id)).toEqual(["u1", "codex"]);
});
it("puts the first visible profile, with its own usage, in place of a hidden live login", () => {
const out = withoutHidden(
[row("u1", true, 82), row("u2", false, 19), row("u3", false, 0), codex],
["u1"]
);
expect(out.map((q) => q.accountUuid ?? q.id)).toEqual(["u2", "u3", "codex"]);
expect(out[0]?.active).toBe(true);
expect(out[0]?.windows[0]?.usedPercent).toBe(19);
expect(out[1]?.active).toBe(false);
});
});
+27 -75
View File
@@ -57,6 +57,7 @@ import { isSubmitEnter } from "../../lib/keys";
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
import { restoreShelf } from "./LaneShelves";
import { BranchesButton } from "./git/BranchesButton";
import { Select } from "../Select";
type Action =
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
@@ -132,8 +133,6 @@ export function LaneGitBar({
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
const [picked, setPicked] = useState("");
// Issue #88: typing narrows the switcher to the branches whose name holds it.
const [branchQuery, setBranchQuery] = useState("");
const [newBranch, setNewBranch] = useState("");
// Set when git needed a login for `action`; the form resends that action.
const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null);
@@ -162,7 +161,6 @@ export function LaneGitBar({
setRemoteBranches([]);
setWorktrees([]);
setPicked("");
setBranchQuery("");
setNewBranch("");
setLogin(null);
setPassword("");
@@ -179,26 +177,6 @@ export function LaneGitBar({
// A remote branch: switching creates its local tracking branch; it cannot
// be deleted here or given a sibling worktree before that.
const pickedRemote = remoteBranches.includes(picked);
// The switcher's lists, narrowed by the search box (case-insensitive, any
// part of the name). The picked branch always stays in its list, so the
// select never shows a value it has no option for.
const query = branchQuery.trim().toLowerCase();
const matches = (b: string) => !query || b.toLowerCase().includes(query) || b === picked;
const shownBranches = (branches || []).filter(matches);
const shownRemote = remoteBranches.filter(matches);
const matchCount = query
? [...(branches || []), ...remoteBranches].filter((b) => b.toLowerCase().includes(query))
.length
: null;
const searchBranches = (text: string) => {
setBranchQuery(text);
const q = text.trim().toLowerCase();
if (!q) return;
const hits = [...(branches || []), ...remoteBranches].filter((b) =>
b.toLowerCase().includes(q)
);
if (hits.length === 1) setPicked(hits[0]!);
};
const merging = !!git.merging;
const run = async (action: Action, body?: RunBody) => {
@@ -549,58 +527,32 @@ export function LaneGitBar({
{switching && (
<div className="flex flex-wrap items-center gap-1.5">
<input
type="search"
aria-label={t("gitBar.branchSearch")}
placeholder={t("gitBar.branchSearch")}
value={branchQuery}
disabled={!!busy}
onChange={(e) => searchBranches(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape" && branchQuery) {
e.stopPropagation();
setBranchQuery("");
} else if (isSubmitEnter(e)) {
e.preventDefault();
const first = [...shownBranches, ...shownRemote].find(
(b) => b.toLowerCase().includes(query) && b !== git.branch
);
if (first) setPicked(first);
}
}}
className="w-36 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<select
aria-label={t("gitBar.switchLabel")}
value={picked}
disabled={!!busy}
onChange={(e) => setPicked(e.target.value)}
className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
>
<option value="">
{matchCount === null
? t("gitBar.switchLabel")
: t("gitBar.branchMatches", { count: matchCount })}
</option>
{shownBranches.map((b) => {
const held = heldBy.get(b);
return (
<option key={b} value={b}>
{b}
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
</option>
);
})}
{shownRemote.length > 0 && (
<optgroup label={t("gitBar.remoteBranches")}>
{shownRemote.map((b) => (
<option key={`remote:${b}`} value={b}>
{b}
</option>
))}
</optgroup>
)}
</select>
{/* A combobox once the list is long (Select's search box, #88):
type to narrow the local and remote branches. */}
<div className="min-w-[8rem] flex-1 font-mono">
<Select
label={t("gitBar.switchLabel")}
searchLabel={t("gitBar.branchSearch")}
searchable
value={picked}
disabled={!!busy}
onChange={setPicked}
options={[
{ value: "", label: t("gitBar.switchLabel") },
...(branches || []).map((b) => {
const held = heldBy.get(b);
const note =
b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : "";
return { value: b, label: `${b}${note}` };
}),
...remoteBranches.map((b) => ({
value: b,
label: b,
group: t("gitBar.remoteBranches"),
})),
]}
/>
</div>
<Btn
label={t("gitBar.switchApply")}
busy={busy === "switch"}
@@ -31,6 +31,7 @@ export function SessionMenu({
onArchive,
onUnarchive,
onPin,
onShare,
}: MenuPoint & {
laneId: number;
session: Session | null;
@@ -42,6 +43,8 @@ export function SessionMenu({
onUnarchive?: () => void;
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
onPin?: () => void;
/** Share this session on its own (server mode, the workspace's owner). */
onShare?: () => void;
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
@@ -81,6 +84,11 @@ export function SessionMenu({
{t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")}
</MenuItem>
)}
{onShare && session && (
<MenuItem testId="session-menu-share" onSelect={pick(onShare)}>
{t("sessionMenu.share")}
</MenuItem>
)}
{session && (
<>
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
@@ -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">
+27 -4
View File
@@ -57,6 +57,8 @@ import { AiToolIcon } from "../AiToolIcon";
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
import { Select } from "../Select";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
import { WorkspaceShareModal } from "./WorkspaceShareModal";
import { usePermissions } from "../AuthGate";
import { workspaceName } from "./WorkspaceRow";
import { SessionMenu } from "./SessionMenu";
import { menuPoint, useContextMenu } from "../ContextMenu";
@@ -118,6 +120,7 @@ function HomeBody({
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const aiTools = useAiTools();
const { ownsLane } = usePermissions();
// The NEW session's AI tool — its own choice, the workspace's by default.
const [tool, setTool] = useState(lane.tool);
const entry = aiTools.byId(tool);
@@ -148,7 +151,7 @@ function HomeBody({
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
// A profile picked for a tool that can use one; its models replace the tool's own.
const profile = proxyCapable
? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null)
? (proxies?.find((r) => r.name === proxy && !r.builtin && !r.company) ?? null)
: null;
const proxyModels: ModelChoice[] | undefined = profile
? [
@@ -195,6 +198,7 @@ function HomeBody({
const [nameDraft, setNameDraft] = useState("");
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
const [title, setTitle] = useState<string | null>(null);
const [sharing, setSharing] = useState(false);
const recentCtx = useContextMenu<Session>();
// A new pane, a pane gone, a session id learned — all move these lane fields.
@@ -270,10 +274,17 @@ function HomeBody({
};
const text = promptWithImages(prompt, images, claude);
// A workspace shared with this user: a session they start runs on their
// own account and is billed to them (the server answers ENOCOLLABACCOUNT
// when they have none).
const collab = !ownsLane(lane);
// Only a session of it is shared with them (phase 3): no new session here —
// they open that session and send it messages (the server answers ESESSIONSHARE).
const sessionOnly = lane.access === "session";
const start = async () => {
// Enter reaches here past the disabled button: the same guards again.
// A ref, not only the state: two Enters in one tick both read `starting` false.
if (startingRef.current || uploading || !text) return;
if (sessionOnly || startingRef.current || uploading || !text) return;
startingRef.current = true;
setStarting(true);
setError(null);
@@ -529,7 +540,7 @@ function HomeBody({
type="button"
data-testid="workspace-home-start"
onClick={() => void start()}
disabled={starting || uploading || !text}
disabled={sessionOnly || starting || uploading || !text}
aria-label={t("home.start")}
title={t("home.start")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
@@ -539,6 +550,11 @@ function HomeBody({
</div>
</div>
</div>
{collab && (
<p data-testid="workspace-home-collab" className="mt-2 text-xs text-fg-muted">
{t(sessionOnly ? "shared.sessionOnly" : "shared.ownAccount")}
</p>
)}
{/* The session's quieter choices, under the box like Claude Desktop's. */}
<div
data-testid="workspace-home-options"
@@ -787,9 +803,16 @@ function HomeBody({
x={menuAt.x}
y={menuAt.y}
onClose={() => setMenuAt(null)}
onAction={(a) => (a === "rename" ? setTitle(lane.title || "") : onMenuAction(a))}
onAction={(a) =>
a === "rename"
? setTitle(lane.title || "")
: a === "share"
? setSharing(true)
: onMenuAction(a)
}
/>
)}
{sharing && <WorkspaceShareModal lane={lane} onClose={() => setSharing(false)} />}
</div>
);
}
@@ -2,11 +2,13 @@
* @file WorkspaceRowMenu — the sidebar workspace row's context menu, opened at
* the pointer (right-click) or under the row's `⋯` button. Every item is an
* existing call: Rename (the row's inline field), New workspace here (the
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* Add modal prefilled), Copy path, Share (server mode: `WorkspaceShareModal`),
* and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* managed lane — plus Move to group (the sidebar's own groups,
* `lib/workspaceGroups.ts`: one of them, a new one, or out of its group). The
* server's guards are the real ones; the menu only hides
* what the lane's kind or tool makes meaningless. The shell (position,
* what the lane's kind or tool makes meaningless, and the owner-only items
* (Rename, Share, Reset, Remove) on a workspace shared with this user. The shell (position,
* Escape / outside-click close) is `ContextMenu`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -17,8 +19,9 @@ import { Check } from "lucide-react";
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
import { copyText } from "../../lib/clipboard";
import { usePermissions } from "../AuthGate";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
export type WorkspaceMenuAction = "rename" | "new-here" | "share" | "reset" | "remove";
export function WorkspaceRowMenu({
lane,
@@ -43,9 +46,11 @@ export function WorkspaceRowMenu({
onGroup?: (target: string | null | "new") => void;
}) {
const { t } = useTranslation("lanes");
const { local, ownsLane } = usePermissions();
const owner = ownsLane(lane);
return (
<ContextMenu x={x} y={y} onClose={onClose} testId={`workspace-row-menu-${lane.id}`}>
<MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>
{owner && <MenuItem onSelect={() => onAction("rename")}>{t("menu.rename")}</MenuItem>}
<MenuItem onSelect={() => onAction("new-here")}>{t("menu.newHere")}</MenuItem>
<MenuItem
onSelect={() => {
@@ -55,6 +60,11 @@ export function WorkspaceRowMenu({
>
{t("menu.copyPath")}
</MenuItem>
{owner && !local && (
<MenuItem testId="workspace-menu-share" onSelect={() => onAction("share")}>
{t("menu.share")}
</MenuItem>
)}
{onGroup && (
<>
<MenuSeparator />
@@ -84,15 +94,19 @@ export function WorkspaceRowMenu({
)}
</>
)}
<MenuSeparator />
{lane.kind === "managed" && (
<MenuItem danger onSelect={() => onAction("reset")}>
{t("menu.reset")}
</MenuItem>
{owner && (
<>
<MenuSeparator />
{lane.kind === "managed" && (
<MenuItem danger onSelect={() => onAction("reset")}>
{t("menu.reset")}
</MenuItem>
)}
<MenuItem danger onSelect={() => onAction("remove")}>
{t("menu.remove")}
</MenuItem>
</>
)}
<MenuItem danger onSelect={() => onAction("remove")}>
{t("menu.remove")}
</MenuItem>
</ContextMenu>
);
}
@@ -31,7 +31,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState, type ReactNode } from "react";
import { lazy, Suspense, useEffect, useState, type ReactNode } from "react";
import { endRuns } from "../../lib/stopSession";
import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
@@ -46,6 +46,11 @@ import { useAiTools } from "../../hooks/useAiTools";
import { useThrottledValue } from "../../hooks/useThrottledValue";
import { useContextMenu } from "../ContextMenu";
import { SessionMenu } from "./SessionMenu";
import { usePermissions } from "../AuthGate";
const WorkspaceShareModal = lazy(() =>
import("./WorkspaceShareModal").then((m) => ({ default: m.WorkspaceShareModal }))
);
export const SESSION_LIMIT = 6;
@@ -256,6 +261,9 @@ export function WorkspaceSessionList({
const [busy, setBusy] = useState(false);
const ctx = useContextMenu<RunHandle>();
const sessionCtx = useContextMenu<Session>();
// Share one session (server mode, the workspace's owner or an admin).
const { local, ownsLane } = usePermissions();
const [sharing, setSharing] = useState<Session | null>(null);
// Rows shown; See all raises it to the whole list.
const [limit, setLimit] = useState(SESSION_LIMIT);
@@ -637,8 +645,18 @@ export function WorkspaceSessionList({
onPin={() =>
void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at })
}
onShare={!local && ownsLane(lane) ? () => setSharing(sessionCtx.menu!.item) : undefined}
/>
)}
{sharing && (
<Suspense fallback={null}>
<WorkspaceShareModal
lane={lane}
session={{ id: sharing.id, name: sharing.name }}
onClose={() => setSharing(null)}
/>
</Suspense>
)}
</>
);
}
@@ -0,0 +1,220 @@
/**
* @file WorkspaceShareModal — share a workspace, or one of its sessions
* (`session`), with other users (server mode, docs/plans/workspace-sharing/
* phases 1 and 3). Ticks people from `GET /api/artifacts/people` (names only)
* and saves the whole list through `PUT /api/lanes/:id/shares` or
* `PUT /api/lanes/:id/sessions/:sid/shares`. It says plainly what sharing means on this
* server: the people it is shared with work as the owner's account and can
* read everything in the owner's home. Only the owner or an admin opens it —
* the server refuses anyone else (`ENOTOWNER`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ShieldAlert, X } from "lucide-react";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { useDialog } from "../../hooks/useDialog";
import { Checkbox } from "../Checkbox";
type Person = { id: number; username: string; name: string; disabled?: boolean };
export function WorkspaceShareModal({
lane,
session,
onClose,
}: {
lane: Lane;
/** Share this one session instead of the whole workspace. */
session?: { id: string; name?: string | null };
onClose: () => void;
}) {
const { t } = useTranslation("lanes");
const panelRef = useRef<HTMLDivElement>(null);
useDialog(true, panelRef, onClose);
const [people, setPeople] = useState<Person[] | null>(null);
const [initial, setInitial] = useState<Set<number>>(new Set());
const [picked, setPicked] = useState<Set<number>>(new Set());
const [query, setQuery] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
let alive = true;
const sid = session?.id;
Promise.all([
api.artifacts.people(),
sid ? api.lanes.sessionShares(lane.id, sid) : api.lanes.shares(lane.id),
])
.then(([p, s]) => {
if (!alive) return;
// Never the owner: a workspace is not shared with them. Someone it is
// already shared with but who was disabled since is not in `people`:
// listed anyway, so they can be taken off.
const known = new Set(p.people.map((x) => x.id));
setPeople([
...p.people.filter((x) => x.id !== lane.owner_id),
...s.shares.filter((x) => !known.has(x.id)).map((x) => ({ ...x, disabled: true })),
]);
const ids = new Set(s.shares.map((x) => x.id));
setInitial(ids);
setPicked(new Set(ids));
})
.catch((err) => {
if (!alive) return;
toast.error(err);
setPeople([]);
});
return () => {
alive = false;
};
}, [lane.id, lane.owner_id, session?.id]);
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
return (people ?? []).filter(
(p) => !q || p.name.toLowerCase().includes(q) || p.username.toLowerCase().includes(q)
);
}, [people, query]);
const added = [...picked].filter((id) => !initial.has(id));
const changed = added.length > 0 || [...initial].some((id) => !picked.has(id));
const name = session ? session.name || session.id : lane.title || lane.cwd;
const save = async () => {
if (added.length > 0) {
const names = (people ?? []).filter((p) => added.includes(p.id)).map((p) => p.name);
const ok = await confirmDialog({
title: t("share.confirmTitle", { name }),
message: t("share.confirmBody", { people: names.join(", ") }),
confirmLabel: t("share.confirm"),
});
if (!ok) return;
}
setBusy(true);
try {
const r = session
? await api.lanes.setSessionShares(lane.id, session.id, [...picked])
: await api.lanes.setShares(lane.id, [...picked]);
toast.success(
r.shares.length ? t("share.saved", { count: r.shares.length }) : t("share.savedNone")
);
onClose();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
};
const toggle = (id: number, on: boolean) =>
setPicked((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="workspace-share-title"
data-testid="workspace-share-modal"
className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-xl"
>
<div className="flex items-start gap-3 border-b border-border px-5 py-4">
<div className="min-w-0 flex-1">
<h2 id="workspace-share-title" className="text-base font-semibold text-fg-primary">
{t(session ? "share.sessionTitle" : "share.title")}
</h2>
<p className="mt-0.5 truncate text-xs text-fg-muted" title={lane.cwd}>
{name}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label={t("share.close")}
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-5 py-4">
<p className="text-xs text-fg-secondary">
{t(session ? "share.sessionWhat" : "share.what")}
</p>
<div
data-testid="workspace-share-warning"
className="flex gap-2 rounded-lg border border-status-warning/30 bg-status-warning/10 px-3 py-2 text-xs text-status-warning"
>
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{t("share.warning")}</span>
</div>
{people === null ? (
<p className="text-xs text-fg-muted">{t("share.loading")}</p>
) : people.length === 0 ? (
<p className="text-xs text-fg-muted">{t("share.nobody")}</p>
) : (
<>
{people.length > 8 && (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("share.search")}
aria-label={t("share.search")}
className="input w-full text-sm"
/>
)}
<ul className="space-y-1" data-testid="workspace-share-people">
{shown.map((p) => (
<li key={p.id}>
<Checkbox
checked={picked.has(p.id)}
onChange={(v) => toggle(p.id, v)}
disabled={busy}
label={
<span>
{p.name}
{p.name !== p.username && (
<span className="ml-1.5 text-fg-muted">@{p.username}</span>
)}
{p.disabled && (
<span className="ml-1.5 text-fg-muted">{t("share.disabled")}</span>
)}
</span>
}
/>
</li>
))}
</ul>
</>
)}
</div>
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
{t("share.cancel")}
</button>
<button
type="button"
className="btn-primary text-sm"
data-testid="workspace-share-save"
onClick={() => void save()}
disabled={busy || !changed || people === null}
>
{busy ? t("share.saving") : t("share.save")}
</button>
</div>
</div>
</div>
);
}
@@ -171,21 +171,23 @@ beforeEach(() => {
vi.mocked(h.commit).mockReset().mockResolvedValue({ available: false });
vi.mocked(h.sequence).mockReset().mockResolvedValue({ available: true, active: false });
vi.mocked(h.pick).mockReset();
vi.mocked(h.pickPlan).mockReset().mockImplementation(async (_t, op, shas) => ({
available: true,
op,
commits: shas.map((sha) => ({
sha,
short: sha.slice(0, 7),
subject: "feat: the commit",
author: "t",
date: "2026-09-22T01:00:00Z",
})),
branch: "feat/thing",
clean: true,
repoRoot: true,
busy: false,
}));
vi.mocked(h.pickPlan)
.mockReset()
.mockImplementation(async (_t, op, shas) => ({
available: true,
op,
commits: shas.map((sha) => ({
sha,
short: sha.slice(0, 7),
subject: "feat: the commit",
author: "t",
date: "2026-09-22T01:00:00Z",
})),
branch: "feat/thing",
clean: true,
repoRoot: true,
busy: false,
}));
vi.mocked(h.fileHistory)
.mockReset()
.mockResolvedValue({
@@ -538,7 +540,8 @@ describe("LaneChangesPanel", () => {
await screen.findByTitle("only-on-thing.ts");
await user.click(screen.getByRole("button", { name: /^Branch$/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "other");
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
await user.click(screen.getByRole("option", { name: "other" }));
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() => expect(screen.getByTestId("lane-git")).toHaveTextContent("other"));
@@ -1344,9 +1347,7 @@ describe("LaneChangesPanel", () => {
await user.click(within(await commitMenu(user)).getByText("Cherry-pick onto feat/thing"));
await user.click(await screen.findByTestId("pick-confirm"));
await waitFor(() =>
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", [
"a".repeat(40),
])
expect(api.lanes.gitHistory.pick).toHaveBeenCalledWith(7, "cherry-pick", ["a".repeat(40)])
);
});
@@ -13,13 +13,22 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneGitBar } from "../LaneGitBar";
import { api } from "../../../lib/api";
import { confirmDialog } from "../../../lib/confirm";
import type { LaneGitFacts } from "../../../lib/types";
/** Picks `name` in the branch switcher — a Select combobox (#88). */
async function pickBranch(user: ReturnType<typeof userEvent.setup>, name: string) {
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await user.click(
within(screen.getByRole("listbox")).getByRole("option", { name: new RegExp(`^${esc}( —|$)`) })
);
}
// A merge asks through the themed confirm (its host lives in App): yes.
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
vi.mock("../../../lib/api", () => ({
@@ -210,7 +219,7 @@ describe("LaneGitBar", () => {
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
// Nothing is sent before the user picks what happens to the changes.
expect(api.lanes.gitAction).not.toHaveBeenCalled();
@@ -235,7 +244,7 @@ describe("LaneGitBar", () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await user.click(screen.getByRole("button", { name: "Bring my changes" }));
expect(await screen.findByTestId("lane-git-carry-conflicts")).toHaveTextContent(
@@ -258,7 +267,7 @@ describe("LaneGitBar", () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await user.click(screen.getByRole("button", { name: "Shelve and switch" }));
await waitFor(() =>
@@ -291,7 +300,7 @@ describe("LaneGitBar", () => {
const onChanged = vi.fn();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={onChanged} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "unshelve", { sha: "abc1234def" })
@@ -319,8 +328,8 @@ describe("LaneGitBar", () => {
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await user.selectOptions(select, "development");
await screen.findByRole("button", { name: "Switch branch…" });
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
@@ -332,12 +341,12 @@ describe("LaneGitBar", () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
});
it("narrows the branch list as you type, picks a single match and Enter's first one (#88)", async () => {
it("opens the branch switcher as a combobox: type to narrow, Enter picks (#88)", async () => {
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
available: true,
branches: ["development", "feat/campaign-pause", "fix/campaign-step", "main"],
@@ -348,34 +357,24 @@ describe("LaneGitBar", () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
const search = screen.getByLabelText("Search branches…");
const options = () =>
Array.from(select.querySelectorAll("option"))
.map((o) => o.value)
.filter(Boolean);
await user.type(search, "CAMPAIGN");
expect(options()).toEqual([
const trigger = await screen.findByRole("button", { name: "Switch branch…" });
await user.click(trigger);
const search = screen.getByRole("combobox", { name: "Search branches…" });
expect(search).toHaveFocus();
await user.keyboard("CAMPAIGN");
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
"feat/campaign-pause",
"fix/campaign-step",
"origin/feat/campaign-statistic",
]);
expect(select.querySelector("option")).toHaveTextContent("3 matching branches");
expect(select).toHaveValue("");
await user.keyboard("{Enter}");
expect(select).toHaveValue("feat/campaign-pause");
await user.clear(search);
await user.type(search, "unit_t");
// One match: picked at once (the earlier pick stays listed until replaced).
expect(select).toHaveValue("origin/feature/thienpt/unit_test");
expect(options()).toEqual(["origin/feature/thienpt/unit_test"]);
await user.keyboard("{Escape}");
expect(search).toHaveValue("");
expect(options()).toHaveLength(6);
expect(trigger).toHaveTextContent("feat/campaign-pause");
await user.click(screen.getByRole("button", { name: "Switch branch" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", {
branch: "feat/campaign-pause",
})
);
});
it("lists branches only the remote has, switches to one, and re-reads the list after a fetch", async () => {
@@ -396,11 +395,12 @@ describe("LaneGitBar", () => {
/>
);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await user.click(await screen.findByRole("button", { name: "Switch branch…" }));
expect(
screen.getByRole("group", { name: "On the remote — switching creates the local branch" })
screen.getByText("On the remote — switching creates the local branch")
).toBeInTheDocument();
await user.selectOptions(select, "origin/feat/new");
await user.keyboard("{Escape}");
await pickBranch(user, "origin/feat/new");
// Not a local branch yet: nothing to delete, no worktree to start on it.
expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled();
expect(screen.getByRole("button", { name: /New workspace here/ })).toBeDisabled();
@@ -436,7 +436,7 @@ describe("LaneGitBar", () => {
})
);
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "New workspace here" }));
expect(onNewLane).toHaveBeenCalledWith("development");
});
@@ -454,18 +454,18 @@ describe("LaneGitBar", () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await screen.findByRole("button", { name: "Switch branch…" });
const newLane = () => screen.getByRole("button", { name: "New workspace here" });
// This lane's own branch: its worktree is this lane.
await user.selectOptions(select, "feat/thing");
await pickBranch(user, "feat/thing");
expect(newLane()).toBeDisabled();
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
await user.selectOptions(select, "development");
await pickBranch(user, "development");
expect(newLane()).toBeDisabled();
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
await user.selectOptions(select, "feat/free");
await pickBranch(user, "feat/free");
expect(newLane()).toBeEnabled();
});
@@ -484,13 +484,15 @@ describe("LaneGitBar", () => {
const user = userEvent.setup();
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
await user.click(screen.getByRole("button", { name: /Branch/ }));
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
expect(await screen.findByRole("button", { name: "Switch branch…" })).toHaveTextContent(
"Switch branch…"
);
});
it("sends one branch-create for a double Enter", async () => {
@@ -517,7 +519,7 @@ describe("LaneGitBar", () => {
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
const select = await screen.findByLabelText("Switch branch…");
await screen.findByRole("button", { name: "Switch branch…" });
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
// Lane 3's answer lands late: its worktree held `development`.
resolveOld({
@@ -526,7 +528,7 @@ describe("LaneGitBar", () => {
current: "feat/thing",
worktrees: [{ path: "/repo/dev", branch: "development" }],
});
await user.selectOptions(select, "development");
await pickBranch(user, "development");
const newLane = screen.getByRole("button", { name: "New workspace here" });
expect(newLane).toBeEnabled();
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
@@ -613,7 +615,7 @@ describe("merge (issue #45)", () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
await pickBranch(user, "development");
await user.click(screen.getByRole("button", { name: "Merge into current" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
@@ -0,0 +1,199 @@
/**
* @file Tests for workspace sharing in the client: WorkspaceShareModal starts
* on the current list, never offers the owner, warns what sharing means, asks
* once before adding someone and saves the WHOLE list; WorkspaceRowMenu offers
* Share (server mode) and the other owner-only items only to the workspace's
* owner or an admin; given a session, it lists and saves THAT session's
* shares instead.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act, fireEvent } from "@testing-library/react";
import "../../../i18n";
import i18n from "../../../i18n";
import { settleConfirm } from "../../../lib/confirm";
import { WorkspaceShareModal } from "../WorkspaceShareModal";
import { WorkspaceRowMenu } from "../WorkspaceRowMenu";
import type { Lane } from "../../../lib/types";
const perms = vi.hoisted(() => ({ local: false, me: 1, admin: false }));
vi.mock("../../AuthGate", () => ({
usePermissions: () => ({
local: perms.local,
isAdmin: perms.admin,
ownsLane: (l?: { owner_id?: number | null } | null) =>
perms.local || perms.admin || l?.owner_id === perms.me,
}),
}));
const m = vi.hoisted(() => ({
people: vi.fn(),
shares: vi.fn(),
setShares: vi.fn(),
sessionShares: vi.fn(),
setSessionShares: vi.fn(),
}));
vi.mock("../../../lib/api", () => ({
api: {
artifacts: { people: m.people },
lanes: {
shares: m.shares,
setShares: m.setShares,
sessionShares: m.sessionShares,
setSessionShares: m.setSessionShares,
},
},
}));
const lane = {
id: 7,
title: "Shop",
cwd: "/home/alice/shop",
owner_id: 1,
kind: "adopted",
} as Lane;
async function settle() {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
beforeEach(() => {
i18n.changeLanguage("en");
perms.local = false;
perms.me = 1;
perms.admin = false;
m.people.mockResolvedValue({
people: [
{ id: 1, username: "alice", name: "Alice" },
{ id: 2, username: "bob", name: "Bob" },
{ id: 3, username: "carol", name: "Carol" },
],
});
m.shares.mockResolvedValue({
shares: [{ id: 2, username: "bob", name: "Bob", sharedAt: "2026-11-06T00:00:00Z" }],
canManage: true,
});
m.setShares.mockImplementation(async (_id: number, ids: number[]) => ({
shares: ids.map((id) => ({ id, username: `u${id}`, name: `U${id}`, sharedAt: "" })),
}));
});
describe("WorkspaceShareModal", () => {
it("starts on the current list, never offers the owner and says what sharing means", async () => {
render(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
await settle();
const list = screen.getByTestId("workspace-share-people");
expect(list).not.toHaveTextContent("Alice");
expect(screen.getByRole("checkbox", { name: /Bob/ })).toHaveAttribute("aria-checked", "true");
expect(screen.getByRole("checkbox", { name: /Carol/ })).toHaveAttribute(
"aria-checked",
"false"
);
expect(screen.getByTestId("workspace-share-warning")).toHaveTextContent(/your home folder/i);
// Nothing changed yet: nothing to save.
expect(screen.getByTestId("workspace-share-save")).toBeDisabled();
});
it("asks once before adding someone, then saves the whole list", async () => {
const onClose = vi.fn();
render(<WorkspaceShareModal lane={lane} onClose={onClose} />);
await settle();
fireEvent.click(screen.getByRole("checkbox", { name: /Carol/ }));
fireEvent.click(screen.getByTestId("workspace-share-save"));
await settle();
expect(m.setShares).not.toHaveBeenCalled();
await act(async () => settleConfirm(true));
await settle();
expect(m.setShares).toHaveBeenCalledWith(7, [2, 3]);
expect(onClose).toHaveBeenCalled();
});
it("lists a collaborator disabled since, so they can be taken off", async () => {
m.shares.mockResolvedValue({
shares: [
{ id: 2, username: "bob", name: "Bob", sharedAt: "" },
{ id: 9, username: "dan", name: "Dan", sharedAt: "", disabled: true },
],
canManage: true,
});
render(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
await settle();
const dan = screen.getByRole("checkbox", { name: /Dan/ });
expect(dan).toHaveAttribute("aria-checked", "true");
expect(dan).toHaveTextContent("(disabled)");
fireEvent.click(dan);
fireEvent.click(screen.getByTestId("workspace-share-save"));
await settle();
expect(m.setShares).toHaveBeenCalledWith(7, [2]);
});
it("takes someone off without asking", async () => {
render(<WorkspaceShareModal lane={lane} onClose={() => {}} />);
await settle();
fireEvent.click(screen.getByRole("checkbox", { name: /Bob/ }));
fireEvent.click(screen.getByTestId("workspace-share-save"));
await settle();
expect(m.setShares).toHaveBeenCalledWith(7, []);
});
});
describe("WorkspaceRowMenu on a shared workspace", () => {
const open = () =>
render(<WorkspaceRowMenu lane={lane} x={0} y={0} onClose={() => {}} onAction={() => {}} />);
it("offers its owner Share, Rename and Delete", () => {
open();
expect(screen.getByTestId("workspace-menu-share")).toBeInTheDocument();
expect(screen.getByText("Rename")).toBeInTheDocument();
expect(screen.getByText("Delete workspace…")).toBeInTheDocument();
});
it("offers a user it is shared with none of the owner-only items", () => {
perms.me = 2;
open();
expect(screen.queryByTestId("workspace-menu-share")).toBeNull();
expect(screen.queryByText("Rename")).toBeNull();
expect(screen.queryByText("Delete workspace…")).toBeNull();
expect(screen.getByText("Copy path")).toBeInTheDocument();
});
it("has no Share in local mode", () => {
perms.local = true;
open();
expect(screen.queryByTestId("workspace-menu-share")).toBeNull();
expect(screen.getByText("Rename")).toBeInTheDocument();
});
});
describe("WorkspaceShareModal for one session", () => {
it("reads and saves that session's own list, never the workspace's", async () => {
m.shares.mockClear();
m.setShares.mockClear();
m.sessionShares.mockResolvedValue({ shares: [], canManage: true });
m.setSessionShares.mockResolvedValue({
shares: [{ id: 3, username: "carol", name: "Carol", sharedAt: "" }],
});
render(
<WorkspaceShareModal
lane={lane}
session={{ id: "s-1", name: "Fix cart" }}
onClose={() => {}}
/>
);
await settle();
expect(m.sessionShares).toHaveBeenCalledWith(7, "s-1");
expect(m.shares).not.toHaveBeenCalled();
expect(screen.getByText("Share session")).toBeTruthy();
expect(screen.getByText("Fix cart")).toBeTruthy();
fireEvent.click(screen.getByRole("checkbox", { name: /Carol/ }));
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await settle();
await act(async () => settleConfirm(true));
await settle();
expect(m.setSessionShares).toHaveBeenCalledWith(7, "s-1", [3]);
expect(m.setShares).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,240 @@
/**
* @file RemoteDevices.tsx
* @description The hub's view of remote clients (server mode, server/hub/):
* a user's enrolled machines with Revoke (the viewer's own password again —
* the server checks it and writes the audit entry), and their Company-account
* sessions grouped device › folder with what the gateway counted. Shown in
* Settings for the signed-in user's own machines and on the Users page's user
* detail for an admin/analyst (`userId`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Laptop, ShieldAlert } from "lucide-react";
import { Link } from "react-router-dom";
import { api, type RemoteDevice, type RemoteSessionRow } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { ConfirmModal } from "../ConfirmModal";
const fmt = (n: number) =>
n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n);
export function RemoteDevices({ userId, canRevoke }: { userId?: number; canRevoke: boolean }) {
const { t } = useTranslation("settings");
const [devices, setDevices] = useState<RemoteDevice[] | null>(null);
const [sessions, setSessions] = useState<RemoteSessionRow[]>([]);
const [revoking, setRevoking] = useState<RemoteDevice | null>(null);
const load = useCallback(() => {
api.remoteHub
.devices(userId)
.then((r) => setDevices(r.devices))
.catch(() => setDevices([]));
api.remoteHub
.sessions(userId)
.then((r) => setSessions(r.sessions))
.catch(() => setSessions([]));
}, [userId]);
useEffect(load, [load]);
// device › folder › sessions, the newest first (the API's order).
const groups = useMemo(() => {
const byDevice = new Map<string, Map<string, RemoteSessionRow[]>>();
for (const s of sessions) {
const d = s.deviceName;
if (!byDevice.has(d)) byDevice.set(d, new Map());
const folders = byDevice.get(d)!;
const f = s.cwd || "—";
if (!folders.has(f)) folders.set(f, []);
folders.get(f)!.push(s);
}
return [...byDevice.entries()];
}, [sessions]);
if (devices === null) return null;
return (
<div className="space-y-4" data-testid="remote-devices">
{devices.length === 0 ? (
<p className="text-xs text-fg-muted">{t("companyHub.noDevices")}</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{devices.map((d) => (
<li key={d.id} className="flex flex-wrap items-center gap-2 px-3 py-2 text-xs">
<Laptop className="h-3.5 w-3.5 text-fg-muted" />
<span className="font-medium text-fg-primary">{d.name}</span>
<span className="text-fg-muted">
{d.revokedAt
? t("companyHub.revokedAt", { time: formatDateTime(d.revokedAt) })
: d.active
? t("companyHub.validUntil", { time: formatDateTime(d.expiresAt) })
: t("companyHub.expired")}
</span>
{d.lastSeenAt && (
<span className="text-fg-muted">
·{" "}
{t("companyHub.lastSeen", {
time: formatDateTime(d.lastSeenAt),
ip: d.lastIp || "?",
})}
</span>
)}
{canRevoke && d.active && (
<button
type="button"
className="btn-ghost ml-auto h-7 px-2 text-xs text-status-danger"
onClick={() => setRevoking(d)}
>
{t("companyHub.revoke")}
</button>
)}
</li>
))}
</ul>
)}
{groups.length > 0 && (
<div className="space-y-3">
<h4 className="text-xs font-semibold text-fg-secondary">{t("companyHub.sessions")}</h4>
{groups.map(([device, folders]) => (
<div key={device} className="space-y-1.5">
<div className="text-xs font-medium text-fg-primary">{device}</div>
{[...folders.entries()].map(([folder, rows]) => (
<div key={folder} className="ml-3 space-y-1">
<div className="truncate font-mono text-[11px] text-fg-muted" title={folder}>
{folder}
</div>
<ul className="ml-3 space-y-0.5">
{rows.map((s) => (
<li
key={s.sessionId}
className="flex flex-wrap items-center gap-2 text-[11px]"
>
{s.purgedAt ? (
<span className="text-fg-muted">
{s.title || s.sessionId.slice(0, 8)}
</span>
) : (
<Link
to={`/sessions/${s.sessionId}`}
className="text-accent hover:underline"
>
{s.title || s.sessionId.slice(0, 8)}
</Link>
)}
{s.unmonitored ? (
<span className="inline-flex items-center gap-1 text-status-warning">
<ShieldAlert className="h-3 w-3" />
{t("companyHub.unmonitored")}
</span>
) : null}
<span className="text-fg-muted">
{t("companyHub.sessionLine", {
requests: s.requests,
input: fmt(s.inputTokens + s.cacheReadTokens + s.cacheWriteTokens),
output: fmt(s.outputTokens),
account: s.account || "—",
})}
</span>
<span className="text-fg-muted">
· {formatDateTime(s.lastRequestAt || s.registeredAt)}
</span>
{s.purgedAt && (
<span className="text-fg-muted">· {t("companyHub.purged")}</span>
)}
</li>
))}
</ul>
</div>
))}
</div>
))}
</div>
)}
{revoking && (
<RevokeDialog
device={revoking}
onClose={() => setRevoking(null)}
onDone={() => {
setRevoking(null);
load();
}}
/>
)}
</div>
);
}
function RevokeDialog({
device,
onClose,
onDone,
}: {
device: RemoteDevice;
onClose: () => void;
onDone: () => void;
}) {
const { t } = useTranslation("settings");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
if (!password || busy) return;
setBusy(true);
setError(null);
try {
await api.remoteHub.revoke(device.id, password);
toast.success(t("companyHub.revoked", { name: device.name }));
onDone();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={t("companyHub.revokeTitle", { name: device.name })}
message={t("companyHub.revokeHint")}
confirmLabel={t("companyHub.revoke")}
cancelLabel={t("common:cancel")}
busy={busy}
disabled={!password}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-2"
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<label className="block">
<span className="text-xs font-medium text-fg-secondary">
{t("companyHub.yourPassword")}
</span>
<input
type="password"
className="input mt-1 w-full text-sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
aria-label={t("companyHub.yourPassword")}
/>
</label>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
+42 -3
View File
@@ -85,6 +85,8 @@ import {
slotOfKey,
} from "../../lib/paneLayout";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { useAuth } from "../AuthGate";
import { usePanePresence } from "../../hooks/usePanePresence";
type AiToolsState = ReturnType<typeof useAiTools>;
@@ -234,6 +236,19 @@ export function ConsoleTabs({
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
const menuRef = useRef<HTMLDivElement>(null);
const tabCtx = useContextMenu<Tab>();
// Server mode: who else has these panes open, and whose money a pane is.
const presence = usePanePresence(lane.id);
const { state: auth } = useAuth();
const meId = auth.mode === "server" ? auth.user?.id : undefined;
const othersIn = (tab: Tab) => {
const seen = new Map<number, { id: number; name: string }>();
for (const r of tab.runs) {
for (const u of presence[r.id] ?? []) if (u.id !== meId) seen.set(u.id, u);
}
return [...seen.values()];
};
const billedTo = (tab: Tab) =>
[...new Set(tab.runs.map((r) => r.startedBy?.name).filter(Boolean))] as string[];
// A group whose runs the list has not caught up with yet is not drawn.
const tabs = useMemo<Tab[]>(
@@ -532,6 +547,7 @@ export function ConsoleTabs({
{tabs.map((tab, i) => {
const active = !settings?.active && tab.index === groups.active;
const single = tab.runs.length === 1 ? tab.focus : null;
const others = othersIn(tab);
const state = tabState(tab);
if (single && renaming === single.id) {
return (
@@ -606,7 +622,7 @@ export function ConsoleTabs({
// Ctrl/Cmd+click brings a one-pane tab into the tab on screen.
onClick={(e) => onOpen(tab.focus.id, !!single && (e.ctrlKey || e.metaKey))}
onDoubleClick={() => single && startRename(single)}
title={
title={[
single
? [
toolLabel(single),
@@ -617,8 +633,14 @@ export function ConsoleTabs({
]
.filter(Boolean)
.join("\n")
: tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n")
}
: tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n"),
...billedTo(tab).map((name) => t("tabs.billedTo", { name })),
others.length
? t("tabs.here", { names: others.map((u) => u.name).join(", ") })
: null,
]
.filter(Boolean)
.join("\n")}
className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm"
>
<span className="flex shrink-0 items-center gap-1">
@@ -627,6 +649,23 @@ export function ConsoleTabs({
))}
</span>
<span className="truncate">{tabLabel(tab)}</span>
{others.length > 0 && (
<span
data-testid={`console-tab-people-${tab.key}`}
className="flex shrink-0 -space-x-1"
aria-label={t("tabs.here", { names: others.map((u) => u.name).join(", ") })}
>
{others.slice(0, 3).map(({ id, name }) => (
<span
key={id}
aria-hidden
className="flex h-4 w-4 items-center justify-center rounded-full border border-surface-1 bg-accent-solid text-[9px] font-semibold uppercase text-white"
>
{name.slice(0, 1)}
</span>
))}
</span>
)}
{state && <RunDot state={state} />}
</button>
{tab.runs.some(canSleep) && (
+67 -16
View File
@@ -57,7 +57,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowUp,
@@ -124,6 +124,11 @@ const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v ==
const OUTPUT_FETCH_MS = 1000;
/** A tool with no known busy line counts as at work this long after its pane last drew. */
const OUTPUT_BUSY_MS = 2000;
/** How long Stop stays up after every busy signal dropped: the screen's busy
* line comes and goes while the CLI redraws, and without a hold the button
* flickered between Stop and Send mid-turn. An explicit end (Stop pressed)
* is not held. */
const BUSY_HOLD_MS = 2500;
const SIDE_KEY = "agenthub.chat.panelWidth";
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
@@ -140,6 +145,37 @@ const storedSide = () => {
* older; every Claude model since (Sonnet/Opus 4.6+, the 5 family) has 1M
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
/** When the conversation's last turn ended — its newest reply's timestamp
* when that reply finished (`end_turn`, the "Done in …" line) and no prompt
* came after it — else null. */
export function endedTurnAt(messages: readonly TranscriptMessage[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
if (m.type === "session_event") continue;
const sender = m.sender ?? (m.type === "assistant" ? "assistant" : "user");
if (sender === "assistant") return m.stop_reason === "end_turn" ? m.timestamp : null;
if (sender === "user" && m.content.some((c) => c.type === "text")) return null;
}
return null;
}
/** `value`, except that a drop to false shows only once it has lasted
* `holdMs`; a change of `release` (Stop pressed) lets it drop at once. */
export function useHeldTrue(value: boolean, holdMs: number, release: unknown): boolean {
const [held, setHeld] = useState(value);
const released = useRef(release);
useEffect(() => {
if (value) return setHeld(true);
if (released.current !== release) {
released.current = release;
return setHeld(false);
}
const timer = window.setTimeout(() => setHeld(false), holdMs);
return () => window.clearTimeout(timer);
}, [value, holdMs, release]);
return value || held;
}
export function contextWindow(model: string | null): number | null {
// A Codex/omp chat reports its own window (ContextMeter's `window`).
if (!model?.startsWith("claude-")) return null;
@@ -150,13 +186,7 @@ export function contextWindow(model: string | null): number | null {
/** How full the context is: a ring and a percentage, amber then red as
* Claude Code's auto-compact comes near. */
function ContextMeter({
tokens,
model,
}: {
tokens: number;
model: string | null;
}) {
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) {
const { t } = useTranslation("run");
const size = contextWindow(model);
if (!size || !tokens) return null;
@@ -616,19 +646,27 @@ export const PaneChat = memo(function PaneChat({
const scope: PreviewScope = { laneId, laneCwd: laneCwd || cwd, cwd, sessionId };
const text = promptWithImages(prompt, images);
// The conversation on screen: an artifact's versions are rebuilt from it,
// and its finished reply ends the turn (below).
const [convMessages, setConvMessages] = useState<TranscriptMessage[]>([]);
// The main agent is "working" from a prompt's submit to its Stop hook, and
// waits on the user from a Notification (a permission prompt) to the next hook.
const [working, setWorking] = useState(false);
const [waiting, setWaiting] = useState(false);
// Stop presses, for the conversation's live line (issue #74).
const [stops, setStops] = useState(0);
const readWorking = useCallback(
() =>
// Reads overlap (every throttled transcript change starts one); only the
// newest may land, or a slow older answer put back a state already left.
const readSeq = useRef(0);
const readWorking = useCallback(() => {
const seq = ++readSeq.current;
return (
api.agents
.list({ session_id: sessionId, status: "working" })
// With session_id the server answers every agent of the session, its
// status filter ignored: check the status here.
.then((r) => {
if (seq !== readSeq.current) return;
const main = r.agents.filter((a) => a.type === "main");
// A fresh start, resume or `/clear` leaves main "working" at an
// empty prompt (SessionStart's flag) until the first prompt.
@@ -637,9 +675,9 @@ export const PaneChat = memo(function PaneChat({
);
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
})
.catch(() => {}),
[sessionId]
);
.catch(() => {})
);
}, [sessionId]);
useEffect(() => {
void readWorking();
}, [readWorking]);
@@ -714,7 +752,20 @@ export const PaneChat = memo(function PaneChat({
}, 700);
return () => window.clearInterval(timer);
}, [runId, onScreen]);
const busy = working || screenBusy;
// The reply the chat already shows as "Done in …" ends the turn, hook or
// not: a turn may end with no hook at all (no Stop, no SubagentStop) until
// the idle Notification a minute later. Only a reply that is not the one
// already there when `working` began counts, so a new prompt whose line the
// transcript has not caught up with still shows Stop.
const endedAt = useMemo(() => endedTurnAt(convMessages), [convMessages]);
const endedRef = useRef(endedAt);
endedRef.current = endedAt;
const [staleEnd, setStaleEnd] = useState<string | null>(null);
useEffect(() => {
if (working) setStaleEnd(endedRef.current);
}, [working]);
const turnOver = endedAt !== null && endedAt !== staleEnd;
const busy = useHeldTrue((working && !turnOver) || screenBusy, BUSY_HOLD_MS, stops);
const asking = waiting || menu !== null;
// Gone until the pane redraws: no second press on a stale menu.
const choose = (index: number) => {
@@ -762,8 +813,6 @@ export const PaneChat = memo(function PaneChat({
() => showTerminal(runId, sessionId, run.tool),
[runId, sessionId, run.tool]
);
// The conversation on screen: an artifact's versions are rebuilt from it.
const [convMessages, setConvMessages] = useState<TranscriptMessage[]>([]);
// An artifact card opens in this chat's own panel, like Claude Desktop's —
// not the workspace's Changes panel a plain file link goes to.
const openArtifact = useCallback(
@@ -812,6 +861,8 @@ export const PaneChat = memo(function PaneChat({
if (!text || uploading) return;
const out = promptForPane(prompt, images, claude);
if (!(await command(out.text, out.files))) return;
// A new turn: the reply now on screen is the previous one's.
setStaleEnd(endedRef.current);
setFollow((n) => n + 1);
setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
+34 -8
View File
@@ -6,7 +6,10 @@
* reads to list the proxy's models). A tool that cannot use a proxy (only
* Claude and Codex can) shows Native, disabled. The last pick is remembered
* for the next session (`agenthub.session.proxy`; the pre-rename
* `agenthub.session.router` is still read when that is unset).
* `agenthub.session.router` is still read when that is unset). On a local
* machine enrolled with the company hub (`/api/remote-client`) the list ends
* with the Company account (`agenthub.company`): a Claude session there runs through
* the hub's gateway, and only such sessions are reported to the hub.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -39,14 +42,30 @@ export function useSessionProxy(): [string, (v: string) => void] {
return [value, set];
}
/** The saved profiles (Native included), null until they arrive. */
/** The remote client's Company account, as the list shows it. */
const COMPANY: AiProxy = {
name: "agenthub.company",
label: "Company account",
builtin: false,
company: true,
baseUrl: "",
hasKey: false,
model: "",
models: {},
};
/** The saved profiles (Native included, the Company account last when enrolled), null until they arrive. */
export function useAiProxies(): AiProxy[] | null {
const [proxies, setProxies] = useState<AiProxy[] | null>(null);
useEffect(() => {
let alive = true;
api.aiProxies
.list()
.then((r) => alive && setProxies(r.items))
// A hub (server mode) answers 404 here, and a failure only hides the entry.
const enrolled = Promise.resolve()
.then(() => api.remoteClient.status())
.then((r) => r.enrolled)
.catch(() => false);
Promise.all([api.aiProxies.list().then((r) => r.items), enrolled])
.then(([items, company]) => alive && setProxies(company ? [...items, COMPANY] : items))
.catch(() => alive && setProxies([]));
return () => {
alive = false;
@@ -82,12 +101,19 @@ export function ProxyPicker({
// A remembered profile that has since been deleted falls back to native,
// or the start would be refused for a proxy the user cannot see.
useEffect(() => {
if (proxies && value !== "native" && !proxies.some((r) => r.name === value)) onChange("native");
}, [proxies, value, onChange]);
if (!proxies || value === "native") return;
const pick = proxies.find((r) => r.name === value);
if (!pick || (pick.company && tool !== "claude")) onChange("native");
}, [proxies, value, onChange, tool]);
const options =
capable && proxies?.length
? proxies.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
? proxies
.filter((r) => !r.company || tool === "claude")
.map((r) => ({
value: r.name,
label: r.builtin ? native : r.company ? t("proxy.company") : r.label,
}))
: [{ value: "native", label: native }];
return (
<div data-testid="proxy-picker">
@@ -0,0 +1,79 @@
/**
* @file ConsoleTabs.presence.test.tsx
* @description Workspace sharing phase 4 on the tab bar: a tab shows who ELSE
* has its panes open (never the viewer), and its tooltip says whose money a
* pane a collaborator opened is.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import "../../../i18n";
import type { RunHandle } from "../../../lib/api";
import type { Lane } from "../../../lib/types";
import { pane, type PaneGroups } from "../../../lib/paneLayout";
import { ConsoleTabs } from "../ConsoleTabs";
vi.mock("../../../lib/api", () => ({
api: {
aiTools: {
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
},
},
}));
vi.mock("../../AuthGate", () => ({
useAuth: () => ({ state: { mode: "server", user: { id: 1, username: "alice" } } }),
usePermissions: () => ({ local: false, isAdmin: false }),
}));
vi.mock("../../../hooks/usePanePresence", () => ({
usePanePresence: () => ({
"agenthub-lane-1": [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
}),
}));
const LANE = { id: 1, tool: "claude", cwd: "/w", title: "w" } as unknown as Lane;
const RUN: RunHandle = {
id: "agenthub-lane-1",
laneId: 1,
slot: 0,
status: "running",
cwd: "/w",
model: null,
permissionMode: null,
effort: null,
resumeSessionId: null,
sessionId: null,
startedAt: null,
promptPreview: null,
startedBy: { id: 2, name: "Bob" },
tool: "claude",
};
const GROUPS: PaneGroups = { groups: [{ root: pane(0), focus: 0 }], active: 0 };
describe("ConsoleTabs presence", () => {
it("shows who else is in a pane, and whose money it is", () => {
render(
<MemoryRouter>
<ConsoleTabs
lane={LANE}
tabs={[RUN]}
groups={GROUPS}
onGroupsChange={vi.fn()}
onOpen={vi.fn()}
onClosed={vi.fn()}
/>
</MemoryRouter>
);
const people = screen.getAllByTestId(/^console-tab-people-/)[0]!;
expect(people.textContent).toBe("B");
expect(people.getAttribute("aria-label")).toBe("Also here: Bob");
const tab = screen.getAllByRole("tab")[0]!;
expect(tab.getAttribute("title")).toContain("Billed to Bob");
expect(tab.getAttribute("title")).not.toContain("Alice");
});
});
@@ -18,7 +18,7 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PaneChat, contextWindow } from "../PaneChat";
import { PaneChat, contextWindow, endedTurnAt } from "../PaneChat";
import { api } from "../../../lib/api";
import {
answerRunMenu,
@@ -33,16 +33,21 @@ import {
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
import { eventBus } from "../../../lib/eventBus";
// What the stand-in conversation reports as on screen when asked.
const conv = vi.hoisted(() => ({ messages: [] as unknown[] }));
vi.mock("../../conversation/ConversationView", () => ({
// Stands in for a turn's "Edited N files" card: a click names a file.
ConversationView: ({
onOpenFile,
onNotStarted,
onMessages,
}: {
onOpenFile?: (path: string) => void;
onNotStarted?: (notStarted: boolean) => void;
onMessages?: (messages: never[]) => void;
}) => (
<div data-testid="conversation">
<button data-testid="conv-messages" onClick={() => onMessages?.(conv.messages as never[])} />
<button data-testid="edited-in" onClick={() => onOpenFile?.("/w/src/a.ts")} />
<button data-testid="edited-out" onClick={() => onOpenFile?.("/elsewhere/b.ts")} />
{/* The session has no row yet: the CLI has not started it. */}
@@ -215,12 +220,51 @@ describe("PaneChat — Stop", () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
// Send comes back once nothing has said busy for BUSY_HOLD_MS.
await act(async () => {
await vi.advanceTimersByTimeAsync(2600);
});
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
} finally {
vi.useRealTimers();
}
});
it("drops Stop once the conversation shows the finished reply, with no hook at all", async () => {
// Claude Code may end a turn with no Stop and no SubagentStop: main stays
// "working" until the idle Notification a minute later.
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.mocked(api.agents.list).mockResolvedValue({
agents: [{ type: "main", status: "working" }],
} as never);
try {
renderChat();
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
conv.messages = [
{
type: "user",
timestamp: "2026-11-04T05:00:32Z",
content: [{ type: "text", text: "hi" }],
},
{
type: "assistant",
timestamp: "2026-11-04T05:01:25Z",
stop_reason: "end_turn",
content: [{ type: "text", text: "done" }],
},
];
fireEvent.click(screen.getByTestId("conv-messages"));
await act(async () => {
await vi.advanceTimersByTimeAsync(2600);
});
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
} finally {
vi.useRealTimers();
conv.messages = [];
vi.mocked(api.agents.list).mockResolvedValue({ agents: [] } as never);
}
});
it("shows Stop while the pane's own busy line shows, with no hook saying so", async () => {
// omp, Cursor or Codex without its hooks: only the terminal knows.
vi.useFakeTimers({ shouldAdvanceTime: true });
@@ -233,6 +277,10 @@ describe("PaneChat — Stop", () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(800);
});
// Send comes back once nothing has said busy for BUSY_HOLD_MS.
await act(async () => {
await vi.advanceTimersByTimeAsync(2600);
});
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
} finally {
vi.useRealTimers();
@@ -240,6 +288,32 @@ describe("PaneChat — Stop", () => {
}
});
it("does not flicker to Send when the busy line drops out for a moment", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.mocked(readRunBusy).mockReturnValue(true);
try {
renderChat();
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
// A redraw: the spinner line is gone for a poll or two.
vi.mocked(readRunBusy).mockReturnValue(false);
await act(async () => {
await vi.advanceTimersByTimeAsync(1500);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(50);
});
expect(screen.getByTestId("pane-chat-stop")).toBeInTheDocument();
vi.mocked(readRunBusy).mockReturnValue(true);
await act(async () => {
await vi.advanceTimersByTimeAsync(800);
});
expect(screen.getByTestId("pane-chat-stop")).toBeInTheDocument();
} finally {
vi.useRealTimers();
vi.mocked(readRunBusy).mockReturnValue(false);
}
});
it("counts a tool with no known busy line as working while its pane draws", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.mocked(readRunBusy).mockReturnValue(null);
@@ -261,6 +335,10 @@ describe("PaneChat — Stop", () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(2800);
});
// Send comes back once nothing has said busy for BUSY_HOLD_MS.
await act(async () => {
await vi.advanceTimersByTimeAsync(2600);
});
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
} finally {
vi.useRealTimers();
@@ -710,3 +788,24 @@ describe("PaneChat — a Codex terminal", () => {
expect(screen.queryByTestId("pane-chat-codex-model")).toBeNull();
});
});
describe("endedTurnAt", () => {
const user = (text: string) => ({
type: "user",
timestamp: "u",
content: [{ type: "text", text }],
});
const reply = (stop_reason: string, timestamp = "a") => ({
type: "assistant",
timestamp,
stop_reason,
content: [{ type: "text", text: "x" }],
});
const toolResult = { type: "user", timestamp: "r", content: [{ type: "tool_result" }] };
it("is the finished reply's time, and null mid-turn or after a new prompt", () => {
expect(endedTurnAt([user("hi"), reply("end_turn", "t1")] as never)).toBe("t1");
expect(endedTurnAt([user("hi"), reply("tool_use"), toolResult] as never)).toBeNull();
expect(endedTurnAt([user("hi"), reply("end_turn"), user("again")] as never)).toBeNull();
expect(endedTurnAt([] as never)).toBeNull();
});
});
@@ -28,6 +28,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
}),
},
aiProxies: { list: vi.fn() },
remoteClient: { status: vi.fn() },
},
};
});
@@ -40,6 +41,55 @@ beforeEach(() => {
vi.mocked(api.aiProxies.list)
.mockReset()
.mockResolvedValue({ items: [native, nine] } as never);
vi.mocked(api.remoteClient.status).mockReset().mockResolvedValue({ enrolled: false });
});
describe("the Company account", () => {
it("is listed last only on a machine enrolled with the hub", async () => {
vi.mocked(api.remoteClient.status).mockResolvedValue({ enrolled: true });
const { result } = renderHook(() => useAiProxies());
await waitFor(() => expect(result.current).not.toBeNull());
expect(result.current!.map((p) => p.name)).toEqual(["native", "9router", "agenthub.company"]);
expect(result.current![2]!.company).toBe(true);
});
it("is absent when not enrolled or when the status call fails (a hub answers 404)", async () => {
const off = renderHook(() => useAiProxies());
await waitFor(() => expect(off.result.current).not.toBeNull());
expect(off.result.current!.map((p) => p.name)).toEqual(["native", "9router"]);
vi.mocked(api.remoteClient.status).mockRejectedValue(new Error("404"));
const err = renderHook(() => useAiProxies());
await waitFor(() => expect(err.result.current).not.toBeNull());
expect(err.result.current!.map((p) => p.name)).toEqual(["native", "9router"]);
});
it("is offered for Claude only; a remembered pick on another tool falls back to Native", async () => {
const company = {
name: "agenthub.company",
label: "Company account",
builtin: false,
company: true,
} as AiProxy;
render(
<ProxyPicker
tool="claude"
proxies={[native, company]}
value="agenthub.company"
onChange={() => {}}
/>
);
expect(await screen.findByText("Company account")).toBeInTheDocument();
const onChange = vi.fn();
render(
<ProxyPicker
tool="codex"
proxies={[native, company]}
value="agenthub.company"
onChange={onChange}
/>
);
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
});
});
describe("ProxyPicker", () => {
@@ -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,8 +1,8 @@
/**
* @file ArtifactsHubSettings — Settings → AI tools → Artifacts Hub: the
* admin's server-wide switch that allows or blocks installing a shared skill
* or plugin from the Hub into a user's own account (`GET/PUT
* /api/artifacts/settings`). Renders nothing in local mode (there is no Hub)
* admin's server-wide switches: installing a shared skill or plugin from the
* Hub into a user's own account, and asking Claude to save a viewable copy of
* a Doc / Slides / Design it publishes (`GET/PUT /api/artifacts/settings`). Renders nothing in local mode (there is no Hub)
* or for anyone the server does not let manage it (`canManage`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -24,17 +24,32 @@ export function ArtifactsHubSettings() {
.catch(() => setSettings(null));
}, []);
if (!settings?.serverMode || !settings.canManage) return null;
if (!settings?.canManage) return null;
return (
<SettingsSection id="artifacts-hub" title={t("title")}>
{settings.serverMode && (
<SettingsSwitchRow
label={t("installsSwitch")}
description={t("installsSwitchHint")}
checked={settings.installsEnabled}
onChange={async (v) => {
try {
const r = await api.artifacts.setInstallsEnabled(v);
setSettings({ ...settings, installsEnabled: r.installsEnabled });
} catch (err) {
toast.error(err);
}
}}
/>
)}
<SettingsSwitchRow
label={t("installsSwitch")}
description={t("installsSwitchHint")}
checked={settings.installsEnabled}
label={t("exportsSwitch")}
description={t("exportsSwitchHint")}
checked={settings.exportsEnabled !== false}
onChange={async (v) => {
try {
const r = await api.artifacts.setInstallsEnabled(v);
setSettings({ ...settings, installsEnabled: r.installsEnabled });
const r = await api.artifacts.setExportsEnabled(v);
setSettings({ ...settings, exportsEnabled: r.exportsEnabled });
} catch (err) {
toast.error(err);
}
@@ -131,6 +131,8 @@ export function BackupSettings() {
onChange={(v) => setHour(Number(v))}
label={t("backup.hour")}
disabled={!state || owned?.hour}
// 24 fixed hours: type-ahead finds one; a search box would only add noise.
searchable={false}
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
/>
</div>
@@ -0,0 +1,273 @@
/**
* @file CompanyHubSettings.tsx
* @description Settings → AI tools → Company hub (docs/plans/remote-clients).
* On a LOCAL install: enroll this machine with the company hub (hub address +
* the user's hub password, which goes to the hub once and is not kept — the
* same as `agenthub remote login`), what is and is not sent, the
* Company-account sessions sent and when, Send now and Log out
* (`/api/remote-client`). On the HUB (server mode, signed in): the user's own
* enrolled machines with Revoke, their Company-account sessions, and who
* (admin/analyst) opened their sessions (`/api/remote/*`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Building2, LogOut, Upload } from "lucide-react";
import { Link } from "react-router-dom";
import { api, type RemoteClientStatus } from "../../lib/api";
import { formatBytes, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { usePermissions } from "../AuthGate";
import { RemoteDevices } from "../remote/RemoteDevices";
import { SettingsSection } from "./SettingsRow";
export function CompanyHubSettings() {
const { local } = usePermissions();
return local ? <LocalHub /> : <HubSide />;
}
function LocalHub() {
const { t } = useTranslation("settings");
const [st, setSt] = useState<RemoteClientStatus | null>(null);
const [hub, setHub] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
api.remoteClient
.status()
.then(setSt)
.catch(() => setSt(null));
}, []);
useEffect(load, [load]);
const run = async (fn: () => Promise<void>) => {
setBusy(true);
setError(null);
try {
await fn();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const enroll = () =>
run(async () => {
setSt(await api.remoteClient.login({ hub, username, password }));
setPassword("");
toast.success(t("companyHub.enrolledToast"));
load();
});
const logout = async () => {
const ok = await confirmDialog({
title: t("companyHub.logoutTitle"),
message: t("companyHub.logoutHint"),
confirmLabel: t("companyHub.logout"),
});
if (!ok) return;
await run(async () => {
await api.remoteClient.logout();
load();
});
};
const sendNow = () =>
run(async () => {
const r = await api.remoteClient.upload();
toast.success(
t("companyHub.sentToast", { bytes: formatBytes(r.bytes), sessions: r.sessions })
);
if (r.errors.length) setError(r.errors.map((e) => e.message).join("\n"));
load();
});
if (!st) return null;
return (
<SettingsSection
id="company-hub"
title={t("companyHub.title")}
description={t("companyHub.description")}
>
<ul className="mb-3 list-disc space-y-0.5 pl-5 text-[11px] text-fg-muted">
<li>{t("companyHub.ruleSent")}</li>
<li>{t("companyHub.ruleNotSent")}</li>
<li>{t("companyHub.ruleKey")}</li>
<li>{t("companyHub.ruleOffice")}</li>
</ul>
{st.enrolled ? (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-xs">
<Building2 className="h-4 w-4 text-accent" />
<span className="text-fg-primary">
{t("companyHub.enrolledAs", { hub: st.hub, user: st.user?.username || "?" })}
</span>
<span className="text-fg-muted">
· {st.device?.name} ·{" "}
{t("companyHub.validUntil", { time: formatDateTime(st.device?.expiresAt || "") })}
</span>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="btn-secondary h-8 text-xs"
disabled={busy}
onClick={() => void sendNow()}
>
<Upload className="h-3.5 w-3.5" />
{t("companyHub.sendNow")}
</button>
<button
type="button"
className="btn-ghost h-8 text-xs"
disabled={busy}
onClick={() => void logout()}
>
<LogOut className="h-3.5 w-3.5" />
{t("companyHub.logout")}
</button>
</div>
<div>
<h4 className="mb-1 text-xs font-semibold text-fg-secondary">
{t("companyHub.sentTitle")}
</h4>
{st.uploads && st.uploads.length > 0 ? (
<ul
className="divide-y divide-border rounded-lg border border-border"
data-testid="company-uploads"
>
{st.uploads.map((u) => (
<li
key={u.sessionId}
className="flex flex-wrap items-center gap-2 px-3 py-1.5 text-[11px]"
>
<Link to={`/sessions/${u.sessionId}`} className="text-accent hover:underline">
{u.title || u.sessionId.slice(0, 8)}
</Link>
<span className="truncate font-mono text-fg-muted">{u.cwd}</span>
<span className="ml-auto text-fg-muted">
{u.lastSentAt
? t("companyHub.sentLine", {
bytes: formatBytes(u.bytes || 0),
time: formatDateTime(u.lastSentAt),
})
: t("companyHub.notSentYet")}
{u.ended ? ` · ${t("companyHub.ended")}` : ""}
</span>
</li>
))}
</ul>
) : (
<p className="text-xs text-fg-muted">{t("companyHub.nothingSent")}</p>
)}
</div>
</div>
) : (
<div
className="grid max-w-md gap-2"
onKeyDown={(e) => {
if (isSubmitEnter(e) && hub && username && password) {
e.preventDefault();
void enroll();
}
}}
>
{st.expired && (
<p className="text-xs text-status-warning">{t("companyHub.expiredHint")}</p>
)}
<input
className="input text-sm"
placeholder="http://10.1.13.7:4820"
value={hub}
onChange={(e) => setHub(e.target.value)}
aria-label={t("companyHub.hubAddress")}
/>
<input
className="input text-sm"
placeholder={t("companyHub.username")}
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
aria-label={t("companyHub.username")}
/>
<input
type="password"
className="input text-sm"
placeholder={t("companyHub.password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
aria-label={t("companyHub.password")}
/>
<button
type="button"
className="btn-primary h-8 w-fit text-xs"
disabled={busy || !hub || !username || !password}
onClick={() => void enroll()}
>
{t("companyHub.enroll")}
</button>
<p className="text-[11px] text-fg-muted">{t("companyHub.cliHint")}</p>
</div>
)}
{error && (
<p role="alert" className="mt-2 whitespace-pre-line text-xs text-status-danger">
{error}
</p>
)}
</SettingsSection>
);
}
function HubSide() {
const { t } = useTranslation("settings");
const [audit, setAudit] = useState<
| {
id: number;
at: string;
sessionId: string;
reader: string | null;
readerDisplay: string | null;
}[]
| null
>(null);
useEffect(() => {
api.remoteHub
.audit()
.then((r) => setAudit(r.entries))
.catch(() => setAudit(null));
}, []);
// A hub without the remote routes (an older server) answers 404: show nothing.
if (audit === null) return null;
return (
<SettingsSection
id="company-hub"
title={t("companyHub.hubTitle")}
description={t("companyHub.hubDescription")}
>
<RemoteDevices canRevoke />
<div className="mt-4">
<h4 className="mb-1 text-xs font-semibold text-fg-secondary">{t("companyHub.whoRead")}</h4>
{audit.length === 0 ? (
<p className="text-xs text-fg-muted">{t("companyHub.nobodyRead")}</p>
) : (
<ul className="space-y-0.5 text-[11px]" data-testid="company-audit">
{audit.map((a) => (
<li key={a.id} className="text-fg-muted">
{formatDateTime(a.at)} ·{" "}
<span className="text-fg-primary">{a.readerDisplay || a.reader || "?"}</span> ·{" "}
<Link to={`/sessions/${a.sessionId}`} className="text-accent hover:underline">
{a.sessionId.slice(0, 8)}
</Link>
</li>
))}
</ul>
)}
</div>
</SettingsSection>
);
}
@@ -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>
);
}
@@ -222,6 +222,8 @@ export function ReportSettings() {
value={String(form.value.hour)}
onChange={(v) => set({ hour: Number(v) })}
label={t("reports.hour")}
// 24 fixed hours: type-ahead finds one; a search box would only add noise.
searchable={false}
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
/>
</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 } 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,11 +39,19 @@ import {
type CcProfile,
type CcProfileUsage,
type CcUsageWindow,
type MyPoolTool,
type ToolProfile,
type ToolProfileIdentity,
type ToolProfilesTool,
} from "../../lib/api";
import { toast } from "../../lib/toast";
import {
profileKey,
readHiddenProfiles,
standIn,
toggleHidden,
writeHiddenProfiles,
} from "../../lib/hiddenProfilesStorage";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { useAiTools } from "../../hooks/useAiTools";
@@ -46,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" },
];
@@ -65,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(
() =>
@@ -84,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
@@ -94,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
@@ -125,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")} />
{!seesAll || current.poolOnly ? (
poolRows
) : current.id === "claude" ? (
<ClaudeProfiles
pooled={!local && POOL_TOOLS.has("claude")}
local={local}
merged={merged}
poolRows={poolRows}
/>
) : (
<OtherProfiles
key={current.id}
@@ -139,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}
/>
)}
@@ -288,6 +333,8 @@ function OtherProfiles({
state,
loading,
pooled,
merged,
poolRows,
onChanged,
}: {
tool: string;
@@ -296,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");
@@ -313,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 }),
@@ -369,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>
@@ -438,6 +489,7 @@ function OtherProfiles({
)}
</div>
))}
{poolRows}
<LoginHint text={t("toolProfiles.loginHint", { tool: label })} command={login} />
</div>
);
@@ -475,7 +527,19 @@ function ActiveBadge() {
// save button only appears while the live account is unsaved, because one
// account is one profile.
function ClaudeProfiles({ pooled }: { pooled: 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);
@@ -492,6 +556,51 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
/** Names already auto-fetched, so the effect below fires once per profile. */
const autoFetched = useRef<Set<string>>(new Set());
const ctx = useContextMenu<CcProfile>();
/** Local mode only: profiles this browser leaves out (Alt+Shift+click a row);
* Alt+Shift+H shows them, dimmed, until pressed again. */
const [hidden, setHidden] = useState<string[]>(() => (local ? readHiddenProfiles() : []));
const [reveal, setReveal] = useState(false);
useEffect(() => {
if (!local) return;
const onKey = (e: KeyboardEvent) => {
if (e.altKey && e.shiftKey && e.code === "KeyH") {
e.preventDefault();
setReveal((r) => !r);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [local]);
const isHidden = useCallback(
(p: { accountUuid: string | null; name?: string }) => local && hidden.includes(profileKey(p)),
[local, hidden]
);
const toggleRow = (e: MouseEvent, p: CcProfile) => {
if (!local || !e.altKey || !e.shiftKey) return;
e.preventDefault();
e.stopPropagation();
const next = toggleHidden(hidden, profileKey(p));
setHidden(next);
writeHiddenProfiles(next);
};
const activeHidden = !!active && !reveal && isHidden(active);
/** Shown as the live login while the real one is hidden: the first visible
* profile, with its own usage — a "—" there would only draw the eye. */
const decoy =
activeHidden && items
? standIn(
items.map((p) => ({ ...p, accountUuid: p.accountUuid ?? null })),
(p) => p.active || (!!active?.accountUuid && p.accountUuid === active.accountUuid),
hidden
)
: 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 {
@@ -540,10 +649,11 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
if (!items) return;
for (const p of items) {
if (autoFetched.current.has(p.name)) continue;
if (!listed(p)) continue;
autoFetched.current.add(p.name);
void checkUsage(p.name);
}
}, [items, checkUsage]);
}, [items, checkUsage, listed]);
const activeSaved = items?.some((p) => p.active) ?? false;
/** How many saved profiles hold the same account as `p`. */
@@ -587,7 +697,9 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
<span>
{t("profiles.mismatch", {
config: mismatchEmail,
owner: active?.email || active?.accountUuid || "?",
owner: activeHidden
? decoy?.email || "?"
: active?.email || active?.accountUuid || "?",
})}
</span>
</p>
@@ -603,7 +715,18 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
{t("profiles.activeLabel")}
</div>
{active ? (
{active && activeHidden ? (
decoy ? (
<>
<div className="text-sm text-fg-primary">
{decoy.email || decoy.displayName || decoy.accountUuid}
</div>
<IdentityLine identity={decoy} />
</>
) : (
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
)
) : active ? (
<>
<div className="text-sm text-fg-primary">
{active.email || active.displayName || active.accountUuid}
@@ -613,7 +736,7 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
) : (
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
)}
{active && !activeSaved && (
{active && !activeSaved && !activeHidden && (
<div className="pt-1.5 space-y-1.5">
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
<button
@@ -631,17 +754,18 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
</div>
{items === null && !error && <SkeletonRows n={3} />}
{items !== null && items.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?.map((p) => (
{items?.filter(listed).map((p) => (
<div
key={p.name}
data-testid={`profile-row-${p.name}`}
onContextMenu={(e) => ctx.open(e, p)}
className={rowCls}
onClickCapture={(e) => toggleRow(e, p)}
className={`${rowCls}${isHidden(p) ? " opacity-50" : ""}`}
>
<div className="flex items-center gap-2 flex-wrap">
<LabelEdit
@@ -657,11 +781,11 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
)
}
/>
{p.active && <ActiveBadge />}
{shownActive(p) && <ActiveBadge />}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{p.email || p.displayName || p.accountUuid}
</span>
{!p.active && (
{!shownActive(p) && (
<button disabled={busy !== null} onClick={() => switchTo(p)} className={btnCls}>
{busy === `switch:${p.name}` ? t("profiles.switching") : t("profiles.switchButton")}
</button>
@@ -696,10 +820,12 @@ function ClaudeProfiles({ pooled }: { pooled: boolean }) {
</div>
))}
{poolRows}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="profile-menu">
<MenuItem onSelect={pick((p) => setRenaming(p.name))}>{t("profiles.rename")}</MenuItem>
{!ctx.menu.item.active && (
{!shownActive(ctx.menu.item) && !ctx.menu.item.active && (
<MenuItem disabled={busy !== null} onSelect={pick(switchTo)}>
{t("profiles.switchButton")}
</MenuItem>
@@ -53,6 +53,10 @@ describe("AppearanceSettings", () => {
"Apple Light",
"Canvas",
"Orca Light",
"Sunset Cloud",
"Terminal",
"Night Sea",
"Sakura",
]) {
expect(screen.getByText(name)).toBeInTheDocument();
}
@@ -12,7 +12,9 @@ import { ArtifactsHubSettings } from "../ArtifactsHubSettings";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: { artifacts: { settings: vi.fn(), setInstallsEnabled: vi.fn() } },
api: {
artifacts: { settings: vi.fn(), setInstallsEnabled: vi.fn(), setExportsEnabled: vi.fn() },
},
}));
const settings = vi.mocked(api.artifacts.settings);
@@ -32,15 +34,23 @@ describe("ArtifactsHubSettings", () => {
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "false"));
});
it("renders nothing for a non-manager or in local mode", async () => {
it("renders nothing for a non-manager, and only the copy switch in local mode", async () => {
settings.mockResolvedValue({ ...base, canManage: false });
const { container, unmount } = render(<ArtifactsHubSettings />);
await waitFor(() => expect(settings).toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
unmount();
settings.mockResolvedValue({ ...base, serverMode: false });
const r = render(<ArtifactsHubSettings />);
await waitFor(() => expect(settings).toHaveBeenCalledTimes(2));
expect(r.container).toBeEmptyDOMElement();
settings.mockResolvedValue({ ...base, serverMode: false, exportsEnabled: true });
vi.mocked(api.artifacts.setExportsEnabled).mockResolvedValue({
installsEnabled: true,
exportsEnabled: false,
});
render(<ArtifactsHubSettings />);
const sw = await screen.findByRole("switch", {
name: "Keep a viewable copy of Docs and Slides",
});
expect(screen.queryByRole("switch", { name: "Allow installing from the Hub" })).toBeNull();
fireEvent.click(sw);
await waitFor(() => expect(api.artifacts.setExportsEnabled).toHaveBeenCalledWith(false));
});
});
@@ -0,0 +1,144 @@
/**
* @file CompanyHubSettings.test.tsx
* @description Settings → Company hub. Local: the enroll form sends the hub
* address, username and password once and clears the password; enrolled, it
* lists what was sent and never shows a token; Log out asks first. Hub
* (server mode): the user's own machines with Revoke (their password again)
* and who opened their sessions.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import "../../../i18n";
import { settleConfirm } from "../../../lib/confirm";
import { CompanyHubSettings } from "../CompanyHubSettings";
import { api } from "../../../lib/api";
const perms = vi.hoisted(() => ({ local: true, isAdmin: true, seesAll: true }));
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
remoteClient: { status: vi.fn(), login: vi.fn(), logout: vi.fn(), upload: vi.fn() },
remoteHub: { devices: vi.fn(), sessions: vi.fn(), revoke: vi.fn(), audit: vi.fn() },
},
};
});
const enrolled = {
enrolled: true,
hub: "http://10.1.13.7:4820",
user: { id: 3, username: "mia", role: "developer" },
device: { id: 7, name: "mia-pc", expiresAt: "2027-02-01T00:00:00.000Z" },
uploads: [
{
sessionId: "11111111-2222-3333-4444-555555555555",
bytes: 2048,
lastSentAt: "2026-11-04T02:00:00.000Z",
ended: 0,
title: "fix the cart",
cwd: "/home/mia/shop",
startedAt: null,
},
],
};
const show = async () => {
render(
<MemoryRouter>
<CompanyHubSettings />
</MemoryRouter>
);
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
};
beforeEach(() => {
vi.clearAllMocks();
perms.local = true;
});
describe("CompanyHubSettings — local", () => {
it("enrolls with the hub address, username and password, then clears the password", async () => {
vi.mocked(api.remoteClient.status)
.mockResolvedValueOnce({ enrolled: false })
.mockResolvedValue(enrolled);
vi.mocked(api.remoteClient.login).mockResolvedValue(enrolled);
const user = userEvent.setup();
await show();
await user.type(screen.getByLabelText(/hub address|địa chỉ hub/i), "http://10.1.13.7:4820");
await user.type(screen.getByLabelText(/hub username|tên đăng nhập hub/i), "mia");
const pw = screen.getByLabelText(/^(hub password|mật khẩu hub)$/i);
await user.type(pw, "secret-1");
await user.click(screen.getByRole("button", { name: /enroll this machine|đăng ký máy này/i }));
expect(api.remoteClient.login).toHaveBeenCalledWith({
hub: "http://10.1.13.7:4820",
username: "mia",
password: "secret-1",
});
await waitFor(() => expect(screen.getByTestId("company-uploads")).toBeInTheDocument());
expect(pw).not.toBeInTheDocument();
});
it("lists what was sent and logs out only after confirming", async () => {
vi.mocked(api.remoteClient.status).mockResolvedValue(enrolled);
vi.mocked(api.remoteClient.logout).mockResolvedValue({ ok: true, wasEnrolled: true });
const user = userEvent.setup();
await show();
expect(screen.getByText("fix the cart")).toBeInTheDocument();
expect(document.body.textContent).not.toMatch(/ahdev_/);
await user.click(screen.getByRole("button", { name: /log out|đăng xuất/i }));
expect(api.remoteClient.logout).not.toHaveBeenCalled();
await act(async () => settleConfirm(true));
await waitFor(() => expect(api.remoteClient.logout).toHaveBeenCalled());
});
});
describe("CompanyHubSettings — hub", () => {
it("shows the user's machines, revokes with their password, and who read their sessions", async () => {
perms.local = false;
vi.mocked(api.remoteHub.audit).mockResolvedValue({
entries: [
{
id: 1,
at: "2026-11-04T03:00:00.000Z",
sessionId: "abcdef12-0000",
reader: "boss",
readerDisplay: "Boss",
},
],
});
vi.mocked(api.remoteHub.devices).mockResolvedValue({
devices: [
{
id: 7,
userId: 3,
name: "mia-pc",
createdAt: "2026-11-01T00:00:00.000Z",
expiresAt: "2027-02-01T00:00:00.000Z",
lastSeenAt: null,
lastIp: null,
revokedAt: null,
active: true,
},
],
});
vi.mocked(api.remoteHub.sessions).mockResolvedValue({ sessions: [] });
vi.mocked(api.remoteHub.revoke).mockResolvedValue({ ok: true } as never);
const user = userEvent.setup();
await show();
await waitFor(() => expect(screen.getByText("mia-pc")).toBeInTheDocument());
expect(screen.getByTestId("company-audit")).toHaveTextContent("Boss");
await user.click(screen.getByRole("button", { name: /^(revoke|thu hồi)$/i }));
await user.type(screen.getByLabelText(/your password|mật khẩu của bạn/i), "my-pass-1");
const buttons = screen.getAllByRole("button", { name: /^(revoke|thu hồi)$/i });
await user.click(buttons[buttons.length - 1]!);
await waitFor(() => expect(api.remoteHub.revoke).toHaveBeenCalledWith(7, "my-pass-1"));
});
});
@@ -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` },
},
}));
@@ -68,7 +74,9 @@ async function show() {
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" },
@@ -105,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", () => {
@@ -137,6 +146,47 @@ describe("ToolProfilesSettings — Claude", () => {
expect(screen.getByTestId("profile-menu")).toBeInTheDocument();
expect(screen.queryByTestId("profile-menu-delete")).toBeNull();
});
it("leaves a profile this browser hid out of the list and asks nothing about it", async () => {
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));
await show();
await settle();
expect(screen.queryByTestId("profile-row-spare")).toBeNull();
expect(screen.queryByText("d@e.f")).toBeNull();
expect(m.cc.profileUsage).not.toHaveBeenCalledWith("spare");
expect(screen.getByTestId("profile-row-work")).toBeInTheDocument();
});
it("hides a row on Alt+Shift+click and shows it again with Alt+Shift+H", async () => {
const user = userEvent.setup();
await show();
await user.keyboard("{Alt>}{Shift>}");
await user.click(screen.getByTestId("profile-row-spare"));
await user.keyboard("{/Shift}{/Alt}");
expect(screen.queryByTestId("profile-row-spare")).toBeNull();
expect(JSON.parse(localStorage.getItem("agenthub.cc.hiddenProfiles")!)).toEqual(["u2"]);
await user.keyboard("{Alt>}{Shift>}H{/Shift}{/Alt}");
expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument();
});
it("shows the first visible profile as the live login while the live one is hidden", async () => {
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u1"]));
await show();
await settle();
expect(screen.queryByTestId("profile-row-work")).toBeNull();
expect(screen.queryByText("a@b.c")).toBeNull();
expect(screen.queryByText("—")).toBeNull();
const spare = screen.getByTestId("profile-row-spare");
expect(spare).toHaveTextContent(/active/);
expect(screen.queryByRole("button", { name: "Switch to" })).toBeNull();
expect(screen.getAllByText("d@e.f").length).toBe(2);
});
it("does not hide anything in server mode", async () => {
perms.local = false;
localStorage.setItem("agenthub.cc.hiddenProfiles", JSON.stringify(["u2"]));
await show();
expect(screen.getByTestId("profile-row-spare")).toBeInTheDocument();
});
});
describe("ToolProfilesSettings — other tools", () => {
@@ -176,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();
});
});
@@ -25,6 +25,8 @@ import { AiToolIcon } from "../AiToolIcon";
import { SessionStatusBadge } from "../StatusBadge";
import { MiniChart } from "../admin/MiniChart";
import { QuotaBar, windowsOf } from "./AccountPoolCard";
import { RemoteDevices } from "../remote/RemoteDevices";
import { usePermissions } from "../AuthGate";
const POOL_LABEL: Record<PoolTool, string> = {
claude: "Claude Code",
@@ -35,6 +37,7 @@ const pctText = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
export function UserDetail({ userId }: { userId: number }) {
const { t } = useTranslation("users");
const { isAdmin } = usePermissions();
const { label: toolLabel } = useAiTools();
const [data, setData] = useState<UserDetails | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -289,6 +292,12 @@ export function UserDetail({ userId }: { userId: number }) {
</ul>
</Panel>
)}
{/* Their own machines (AgentHub local) on the company hub, and those
machines' Company-account sessions — revocable by an admin. */}
<Panel title={t("detail.devicesTitle")}>
<RemoteDevices userId={userId} canRevoke={isAdmin} />
</Panel>
</div>
);
}
@@ -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"));
+5 -1
View File
@@ -53,12 +53,16 @@ function load(): Promise<void> {
}
function onMessage(msg: WSMessage) {
// A workspace shared with this user, or taken back: re-read the list.
if (msg.type === "lane_access") return void load();
if (msg.type !== "lane_update") return;
const payload = msg.data as { lane?: Lane; removed?: number };
const lane = payload.lane;
if (payload.removed !== undefined || !lane || !lanes) return void load();
const i = lanes.findIndex((l) => l.id === lane.id);
if (i === -1) return void load();
// A broadcast carries no viewer's `access`: a lane seen through a shared
// session re-reads it (its access may have changed too).
if (i === -1 || lanes[i]?.access) return void load();
const next = [...lanes];
next[i] = lane;
publish(next);
+54
View File
@@ -0,0 +1,54 @@
/**
* @file usePanePresence.ts
* @description Who has each of a workspace's panes open (server mode, workspace
* sharing phase 4): `GET /api/lanes/:id/presence` once, then every
* `pane_presence` frame for that workspace, and a re-read on a WebSocket
* reconnect (frames missed while away are never replayed). Local mode has
* one user and reads nothing. Returns `{runId: [{id, name}]}`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { usePermissions } from "../components/AuthGate";
export type PanePeople = Record<string, { id: number; name: string }[]>;
export function usePanePresence(laneId: number | null | undefined): PanePeople {
const { local } = usePermissions();
const [panes, setPanes] = useState<PanePeople>({});
useEffect(() => {
setPanes({});
if (local || laneId == null) return;
let alive = true;
const load = () =>
Promise.resolve()
.then(() => api.lanes.presence(laneId))
.then((r) => alive && setPanes(r.panes))
.catch(() => {
/* presence is a nicety: no list, no chips */
});
void load();
const offMsg = eventBus.subscribe((msg) => {
if (msg.type !== "pane_presence") return;
const d = msg.data as { laneId?: number; runId?: string; users?: PanePeople[string] };
if (d.laneId !== laneId || !d.runId) return;
setPanes((prev) => {
const next = { ...prev };
if (d.users?.length) next[d.runId!] = d.users;
else delete next[d.runId!];
return next;
});
});
const offConn = eventBus.onConnection((up) => {
if (up) void load();
});
return () => {
alive = false;
offMsg();
offConn();
};
}, [laneId, local]);
return panes;
}
+2 -2
View File
@@ -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";
+6 -3
View File
@@ -9,7 +9,8 @@
"status_duration": "Stuck agent",
"token_threshold": "Token threshold",
"cost_threshold": "Cost threshold",
"budget": "Spend budget"
"budget": "Spend budget",
"remote_unmonitored": "Company account outside AgentHub"
},
"ruleTypeHints": {
"event_pattern": "Fires when an ingested event matches the pattern. Set a count above 1 to require N matching events within a time window (e.g. more than 5 errors in 2 minutes).",
@@ -17,7 +18,8 @@
"status_duration": "Fires when an agent has remained in the selected status with no activity for the configured number of minutes.",
"token_threshold": "Fires when a session's total token usage (input + output + cache) exceeds the threshold.",
"cost_threshold": "Fires when a session's cost reaches the amount, priced like the session's own cost.",
"budget": "Fires once per period at each threshold of a spend limit — per day, week or month in your local time, optionally for one AI tool."
"budget": "Fires once per period at each threshold of a spend limit — per day, week or month in your local time, optionally for one AI tool.",
"remote_unmonitored": "Fires when a user's machine uses the company account for a session it did not start from AgentHub (nothing of it is sent to the hub). Server mode only."
},
"ruleDesc": {
"eventPattern": "Matches {{pattern}}",
@@ -26,7 +28,8 @@
"statusDuration": "Agent stuck in {{status}} for {{minutes}} min",
"tokenThreshold": "Session tokens exceed {{tokens}}",
"costThreshold": "Session cost reaches {{usd}}",
"budget": "{{limit}} per {{period}} · alerts at {{thresholds}}"
"budget": "{{limit}} per {{period}} · alerts at {{thresholds}}",
"remoteUnmonitored": "session not started from AgentHub"
},
"rules": {
"title": "Alert rules",
+47 -2
View File
@@ -27,7 +27,7 @@
"share": "Share",
"unshare": "Unshare",
"shareDone": "Shared to the Hub",
"unshareDone": "Removed from the Hub",
"unshareDone": "No longer shared",
"reshare": "Share again",
"reshareDone": "New version shared — people who installed it are offered Update",
"reshareSame": "Already the newest version — shared",
@@ -234,5 +234,50 @@
"pack.updateN": "Update {{count}}",
"pack.updateTitle": "Update {{count}} skills of {{prefix}}-*?",
"pack.updateBody": "Each copy is replaced by the newest version; the current ones are moved to ~/.claude/agenthub-replaced-skills/:",
"pack.updated": "Updated {{ok}} · failed {{failed}}"
"pack.updated": "Updated {{ok}} · failed {{failed}}",
"tab.withme": "Shared with you",
"sharing": "Sharing…",
"shareTitle": "Share “{{title}}”",
"shareMode.people": "Only the people I pick",
"shareMode.peopleHint": "Only them (and admins) see it; nobody else finds it in the Hub.",
"shareMode.everyone": "Everyone (the Hub)",
"shareMode.everyoneHint": "Every user sees it in the Hub.",
"shareMode.nobody": "Stop sharing",
"shareMode.nobodyHint": "Only you (and admins) see it again.",
"sharePeople.search": "Find a person",
"sharePeople.none": "Nobody matches.",
"sharePeople.picked_one": "{{count}} person picked",
"sharePeople.picked_other": "{{count}} people picked",
"shareSave": "Save",
"sharePeopleDone_one": "Shared with {{count}} person",
"sharePeopleDone_other": "Shared with {{count}} people",
"sharedWithCount_one": "Shared with {{count}} person",
"sharedWithCount_other": "Shared with {{count}} people",
"sharedWithMe": "Shared with you",
"withme.empty": "Nothing has been shared with you by name yet.",
"folders.all": "All folders",
"folders.new": "New folder",
"folders.newName": "Folder name",
"folders.rename": "Rename",
"folders.delete": "Delete folder",
"folders.deleteTitle": "Delete the folder “{{name}}”?",
"folders.deleteBody": "Only the folder goes; its artifacts stay where they are.",
"folders.empty": "This folder is empty. Drag an artifact onto the folder, or use ⋯ → Move to folder.",
"folders.move": "Move to folder…",
"folders.moveTitle": "Move “{{title}}” to a folder",
"folders.private": "Your folders are yours alone: nobody else sees them.",
"folders.createHere": "New folder",
"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, 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.",
"exportsSwitch": "Keep a viewable copy of Docs and Slides",
"exportsSwitchHint": "When Claude publishes a Claude Docs doc, a deck or a design from an AgentHub session, it is asked to also save a copy (Markdown, PDF or HTML) that the Hub shows to the people it is shared with — they need no claude.ai access. Applies to sessions started afterwards.",
"folders.moved": "Moved to “{{name}}”",
"folders.takenOut": "Taken out of its folder",
"folders.dropHint": "Drop an artifact here to put it in this folder",
"folders.dropOutHint": "Drop an artifact here to take it out of its folder"
}
+4
View File
@@ -223,5 +223,9 @@
"errors": "Steps that failed. A few are normal; many may mean the AI got stuck.",
"tokenFlow": "Input = sent to the AI, Output = written by the AI, Cache = reused from memory (cheaper)."
}
},
"select": {
"search": "Search…",
"noMatch": "No match"
}
}
+28 -3
View File
@@ -196,8 +196,6 @@
"gitBar.switch": "Branch",
"gitBar.switchApply": "Switch branch",
"gitBar.branchSearch": "Search branches…",
"gitBar.branchMatches_one": "{{count}} matching branch",
"gitBar.branchMatches_other": "{{count}} matching branches",
"gitBar.switchCurrent": "current",
"gitBar.remoteBranches": "On the remote — switching creates the local branch",
"gitBar.switchDirty": "Commit, discard or shelve the uncommitted changes first",
@@ -279,6 +277,7 @@
"renameLabel": "Rename this workspace",
"proxy.native": "Native ({{tool}})",
"proxy.label": "AI Proxy",
"proxy.company": "Company account",
"runtime.down": "down",
"runtime.healthy": "healthy",
"runtime.partial": "partial",
@@ -387,6 +386,32 @@
"menu.copyPath": "Copy path",
"menu.reset": "Reset worktree…",
"menu.remove": "Delete workspace…",
"menu.share": "Share…",
"share.title": "Share workspace",
"share.what": "People you share it with see this workspace and its sessions, join its open terminals, type into them and commit — like you. Only you rename, reset, delete or share it. A session they start runs on their own account and is billed to them.",
"share.warning": "They work as YOUR account on this server: they can read everything in your home folder (your other workspaces, SSH keys and AI logins) and their commits carry your name. Share only with people you would hand your keyboard to.",
"share.loading": "Loading…",
"share.nobody": "There is no one else to share with.",
"share.search": "Search people",
"share.cancel": "Cancel",
"share.close": "Close",
"share.save": "Save",
"share.saving": "Saving…",
"share.confirmTitle": "Share \"{{name}}\"?",
"share.confirmBody": "{{people}} will work in it as your account and can read everything in your home folder.",
"share.confirm": "Share",
"share.saved_one": "Shared with {{count}} person",
"share.saved_other": "Shared with {{count}} people",
"share.disabled": "(disabled)",
"share.savedNone": "No longer shared with anyone",
"shared.ownAccount": "This workspace is shared with you: a session you start here runs on your own account — a pool account or an AI Proxy of yours — and is billed to you.",
"share.sessionTitle": "Share session",
"share.sessionWhat": "People you share it with see this session and the workspace's changes, join its open tab, type into it, send it messages (on their own account, billed to them) and commit — nothing else of the workspace. Only you share it. On this server they work as YOUR account inside its terminal, so they can still reach the rest of your home from there.",
"sessionMenu.share": "Share…",
"shared.sessionOnly": "Only a session of this workspace is shared with you: open it from the list to work in it.",
"tabs.billedTo": "Billed to {{name}}",
"tabs.here": "Also here: {{names}}",
"shared.badge": "Shared with you",
"panel.changes": "Changes",
"panel.analyst": "Analyst",
"panel.preview": "Preview",
@@ -751,7 +776,7 @@
"shelves.deleted": "Shelf deleted",
"shelves.restored": "Shelved changes restored",
"shelves.restoredConflicts": "Restored with {{count}} conflicted file(s): resolve them in the list. The shelf is kept until you delete it.",
"shelves.restoreDirty": "Commit, discard or shelve the current changes before restoring a shelf",
"shelves.restoreDirty": "Commit, discard or unstage the changes in this shelf's files (and anything staged) before restoring it",
"shelves.shelveTicked": "Shelve ({{count}})",
"shelves.shelveHint": "Set the ticked files aside on a shelf and clean them from the working tree",
"shelves.shelveApply": "Shelve {{count}} file(s)",
+49 -6
View File
@@ -1438,17 +1438,60 @@
}
},
"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",
"description": "Use the company's shared Claude accounts from this machine. Only what costs the company is reported.",
"ruleSent": "Sessions you start on \"Company account\" send their conversation and token usage to the hub — every hour and when the session ends. Secret-looking strings (keys, tokens, passwords) are removed first.",
"ruleNotSent": "Sessions on your own login or your own AI Proxy send nothing — not even the folder name. The hub never reads your files and never controls this machine.",
"ruleKey": "The company account's credential stays on the hub; this machine keeps only a device token (90 days, revocable).",
"ruleOffice": "The hub is on the company network: away from it, Company-account sessions cannot start; your own login works as usual.",
"enrolledAs": "Enrolled with {{hub}} as {{user}}",
"validUntil": "valid until {{time}}",
"expired": "expired",
"expiredHint": "This machine's hub login expired — enroll again.",
"sendNow": "Send now",
"logout": "Log out",
"logoutTitle": "Log this machine out of the hub?",
"logoutHint": "Its device token is revoked on the hub. Company-account sessions stop working here until you enroll again.",
"sentTitle": "Sent to the hub",
"sentLine": "{{bytes}} · {{time}}",
"notSentYet": "not sent yet",
"ended": "ended",
"nothingSent": "No Company-account session yet.",
"sentToast": "Sent {{bytes}} of {{sessions}} session(s)",
"enrolledToast": "This machine is enrolled with the hub",
"hubAddress": "Hub address",
"username": "Hub username",
"password": "Hub password",
"enroll": "Enroll this machine",
"cliHint": "Or in a terminal: agenthub remote login <hub address>",
"hubTitle": "Your machines",
"hubDescription": "Machines running AgentHub local that you enrolled with this hub, and their Company-account sessions.",
"noDevices": "No machine enrolled.",
"revokedAt": "revoked {{time}}",
"lastSeen": "seen {{time}} from {{ip}}",
"revoke": "Revoke",
"revokeTitle": "Revoke {{name}}?",
"revokeHint": "Its device token stops working at once; the machine must enroll again.",
"revoked": "{{name}} revoked",
"yourPassword": "Your password",
"sessions": "Company-account sessions",
"unmonitored": "not started from AgentHub",
"sessionLine": "{{requests}} requests · {{input}} in · {{output}} out · {{account}}",
"purged": "conversation removed (retention)",
"whoRead": "Who opened your sessions",
"nobodyRead": "Nobody has opened your sessions."
}
}
+2 -1
View File
@@ -409,7 +409,8 @@
"activeLabel": "Running now",
"activeValue": "{{sessions}} sessions · {{panes}} panes",
"nowTitle": "Running now",
"poolTheirs7d": "theirs 7 d {{cost}}"
"poolTheirs7d": "theirs 7 d {{cost}}",
"devicesTitle": "Machines (AgentHub local)"
},
"activity": "Activity",
"spend": "Spend today",
+6 -3
View File
@@ -9,7 +9,8 @@
"status_duration": "Agent bị kẹt",
"token_threshold": "Ngưỡng token",
"cost_threshold": "Ngưỡng chi phí",
"budget": "Ngân sách chi tiêu"
"budget": "Ngân sách chi tiêu",
"remote_unmonitored": "Tài khoản công ty dùng ngoài AgentHub"
},
"ruleTypeHints": {
"event_pattern": "Kích hoạt khi một sự kiện khớp với mẫu. Đặt số lần lớn hơn 1 để yêu cầu N sự kiện khớp trong khung thời gian (ví dụ: hơn 5 lỗi trong 2 phút).",
@@ -17,7 +18,8 @@
"status_duration": "Kích hoạt khi một agent giữ nguyên trạng thái đã chọn mà không có hoạt động trong số phút đã cấu hình.",
"token_threshold": "Kích hoạt khi tổng mức dùng token của phiên (đầu vào + đầu ra + bộ nhớ đệm) vượt ngưỡng.",
"cost_threshold": "Kích hoạt khi chi phí của một phiên chạm mức này, tính giá như chi phí riêng của phiên.",
"budget": "Kích hoạt một lần mỗi kỳ ở từng ngưỡng của hạn mức chi tiêu — theo ngày, tuần hoặc tháng theo giờ địa phương, có thể chỉ cho một công cụ AI."
"budget": "Kích hoạt một lần mỗi kỳ ở từng ngưỡng của hạn mức chi tiêu — theo ngày, tuần hoặc tháng theo giờ địa phương, có thể chỉ cho một công cụ AI.",
"remote_unmonitored": "Kích hoạt khi máy của một người dùng tài khoản công ty cho một phiên không mở từ AgentHub (phiên đó không được gửi về hub). Chỉ có ở chế độ server."
},
"ruleDesc": {
"eventPattern": "Khớp {{pattern}}",
@@ -26,7 +28,8 @@
"statusDuration": "Agent kẹt ở trạng thái {{status}} trong {{minutes}} phút",
"tokenThreshold": "Token của phiên vượt {{tokens}}",
"costThreshold": "Chi phí phiên chạm {{usd}}",
"budget": "{{limit}} mỗi {{period}} · cảnh báo ở {{thresholds}}"
"budget": "{{limit}} mỗi {{period}} · cảnh báo ở {{thresholds}}",
"remoteUnmonitored": "phiên không mở từ AgentHub"
},
"rules": {
"title": "Quy tắc cảnh báo",
+47 -2
View File
@@ -27,7 +27,7 @@
"share": "Chia sẻ",
"unshare": "Bỏ chia sẻ",
"shareDone": "Đã chia sẻ lên Hub",
"unshareDone": "Đã gỡ khỏi Hub",
"unshareDone": "Đã ngừng chia sẻ",
"reshare": "Share lại",
"reshareDone": "Đã share bản mới — người đã cài sẽ thấy nút Cập nhật",
"reshareSame": "Đã là bản mới nhất — đã share",
@@ -232,5 +232,50 @@
"pack.updateN": "Cập nhật {{count}}",
"pack.updateTitle": "Cập nhật {{count}} skill của bộ {{prefix}}-*?",
"pack.updateBody": "Mỗi bản sẽ được thay bằng phiên bản mới nhất; bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/:",
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}"
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}",
"tab.withme": "Chia sẻ với bạn",
"sharing": "Chia sẻ…",
"shareTitle": "Chia sẻ “{{title}}”",
"shareMode.people": "Chỉ những người tôi chọn",
"shareMode.peopleHint": "Chỉ họ (và admin) thấy; người khác không thấy trên Hub.",
"shareMode.everyone": "Mọi người (Hub)",
"shareMode.everyoneHint": "Mọi người dùng đều thấy trên Hub.",
"shareMode.nobody": "Ngừng chia sẻ",
"shareMode.nobodyHint": "Chỉ bạn (và admin) thấy như trước.",
"sharePeople.search": "Tìm người",
"sharePeople.none": "Không có ai khớp.",
"sharePeople.picked_one": "Đã chọn {{count}} người",
"sharePeople.picked_other": "Đã chọn {{count}} người",
"shareSave": "Lưu",
"sharePeopleDone_one": "Đã chia sẻ với {{count}} người",
"sharePeopleDone_other": "Đã chia sẻ với {{count}} người",
"sharedWithCount_one": "Chia sẻ với {{count}} người",
"sharedWithCount_other": "Chia sẻ với {{count}} người",
"sharedWithMe": "Chia sẻ với bạn",
"withme.empty": "Chưa có ai chia sẻ riêng với bạn.",
"folders.all": "Tất cả thư mục",
"folders.new": "Thư mục mới",
"folders.newName": "Tên thư mục",
"folders.rename": "Đổi tên",
"folders.delete": "Xóa thư mục",
"folders.deleteTitle": "Xóa thư mục “{{name}}”?",
"folders.deleteBody": "Chỉ xóa thư mục; các artifact bên trong vẫn giữ nguyên.",
"folders.empty": "Thư mục này trống. Kéo artifact thả vào thư mục, hoặc dùng ⋯ → Chuyển vào thư mục.",
"folders.move": "Chuyển vào thư mục…",
"folders.moveTitle": "Chuyển “{{title}}” vào thư mục",
"folders.private": "Thư mục là của riêng bạn: người khác không thấy.",
"folders.createHere": "Thư mục mới",
"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, 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.",
"exportsSwitch": "Giữ bản xem được của Docs và Slides",
"exportsSwitchHint": "Khi Claude tạo một Claude Docs, slide hoặc design trong phiên AgentHub, Claude được nhắc lưu thêm một bản sao (Markdown, PDF hoặc HTML) để Hub hiển thị cho người được chia sẻ — họ không cần quyền trên claude.ai. Áp dụng cho các phiên mở sau đó.",
"folders.moved": "Đã chuyển vào “{{name}}”",
"folders.takenOut": "Đã bỏ khỏi thư mục",
"folders.dropHint": "Thả artifact vào đây để đưa vào thư mục này",
"folders.dropOutHint": "Thả artifact vào đây để bỏ khỏi thư mục"
}
+4
View File
@@ -223,5 +223,9 @@
"errors": "Các bước bị lỗi. Vài lỗi là bình thường; nhiều lỗi có thể nghĩa là AI bị kẹt.",
"tokenFlow": "Input = gửi cho AI, Output = AI viết ra, Cache = dùng lại từ bộ nhớ đệm (rẻ hơn)."
}
},
"select": {
"search": "Tìm…",
"noMatch": "Không có kết quả"
}
}
+28 -3
View File
@@ -196,8 +196,6 @@
"gitBar.switch": "Nhánh",
"gitBar.switchApply": "Chuyển nhánh",
"gitBar.branchSearch": "Tìm nhánh…",
"gitBar.branchMatches_one": "{{count}} nhánh khớp",
"gitBar.branchMatches_other": "{{count}} nhánh khớp",
"gitBar.switchCurrent": "hiện tại",
"gitBar.remoteBranches": "Trên remote — switch sẽ tạo nhánh local",
"gitBar.switchDirty": "Hãy commit, bỏ hoặc cất (shelve) các thay đổi chưa commit trước",
@@ -279,6 +277,7 @@
"renameLabel": "Đổi tên workspace này",
"proxy.native": "Native ({{tool}})",
"proxy.label": "AI Proxy",
"proxy.company": "Tài khoản công ty",
"runtime.down": "tắt",
"runtime.healthy": "khoẻ",
"runtime.partial": "một phần",
@@ -387,6 +386,32 @@
"menu.copyPath": "Sao chép đường dẫn",
"menu.reset": "Đặt lại worktree…",
"menu.remove": "Xóa workspace…",
"menu.share": "Chia sẻ…",
"share.title": "Chia sẻ workspace",
"share.what": "Người được chia sẻ thấy workspace này và các session của nó, vào được terminal đang mở, gõ và commit — như bạn. Chỉ bạn được đổi tên, đặt lại, xoá hay chia sẻ nó. Session họ mở chạy bằng tài khoản của chính họ và tính tiền cho họ.",
"share.warning": "Họ làm việc bằng TÀI KHOẢN CỦA BẠN trên server này: đọc được mọi thứ trong thư mục home của bạn (các workspace khác, SSH key, đăng nhập AI) và commit của họ mang tên bạn. Chỉ chia sẻ với người bạn tin như đưa bàn phím cho họ.",
"share.loading": "Đang tải…",
"share.nobody": "Không còn ai khác để chia sẻ.",
"share.search": "Tìm người",
"share.cancel": "Huỷ",
"share.close": "Đóng",
"share.save": "Lưu",
"share.saving": "Đang lưu…",
"share.confirmTitle": "Chia sẻ \"{{name}}\"?",
"share.confirmBody": "{{people}} sẽ làm việc trong đó bằng tài khoản của bạn và đọc được mọi thứ trong thư mục home của bạn.",
"share.confirm": "Chia sẻ",
"share.saved_one": "Đã chia sẻ với {{count}} người",
"share.saved_other": "Đã chia sẻ với {{count}} người",
"share.disabled": "(đã vô hiệu hoá)",
"share.savedNone": "Không còn chia sẻ với ai",
"shared.ownAccount": "Workspace này được chia sẻ với bạn: session bạn mở ở đây chạy bằng tài khoản của chính bạn — tài khoản pool hoặc AI Proxy của bạn — và tính tiền cho bạn.",
"share.sessionTitle": "Chia sẻ session",
"share.sessionWhat": "Người được chia sẻ thấy session này và các thay đổi của workspace, vào tab đang mở, gõ, gửi tin nhắn cho nó (bằng tài khoản của họ, tính tiền cho họ) và commit — không gì khác của workspace. Chỉ bạn chia sẻ được. Trên server này họ làm việc bằng TÀI KHOẢN CỦA BẠN trong terminal của nó, nên từ đó họ vẫn với tới được phần còn lại trong home của bạn.",
"sessionMenu.share": "Chia sẻ…",
"shared.sessionOnly": "Chỉ một session của workspace này được chia sẻ với bạn: mở nó từ danh sách để làm việc.",
"tabs.billedTo": "Tính tiền cho {{name}}",
"tabs.here": "Cũng đang mở: {{names}}",
"shared.badge": "Được chia sẻ với bạn",
"panel.changes": "Thay đổi",
"panel.analyst": "Analyst",
"panel.preview": "Xem trang",
@@ -751,7 +776,7 @@
"shelves.deleted": "Đã xoá shelf",
"shelves.restored": "Đã khôi phục thay đổi đã cất",
"shelves.restoredConflicts": "Đã khôi phục với {{count}} file xung đột: hãy giải quyết trong danh sách. Shelf được giữ đến khi bạn xoá.",
"shelves.restoreDirty": "Hãy commit, bỏ hoặc cất thay đổi hiện tại trước khi khôi phục một shelf",
"shelves.restoreDirty": "Hãy commit, bỏ hoặc bỏ stage thay đổi trên các file của shelf này (và mọi thứ đã stage) trước khi khôi phục",
"shelves.shelveTicked": "Cất ({{count}})",
"shelves.shelveHint": "Cất các file đã tick vào một shelf và dọn chúng khỏi thư mục làm việc",
"shelves.shelveApply": "Cất {{count}} file",
+49 -6
View File
@@ -1426,17 +1426,60 @@
}
},
"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",
"description": "Dùng tài khoản Claude chung của công ty từ máy này. Chỉ phần tốn tiền công ty mới được ghi nhận.",
"ruleSent": "Session bạn mở bằng \"Tài khoản công ty\" sẽ gửi hội thoại và token đã dùng lên hub — mỗi giờ và khi session kết thúc. Các chuỗi giống bí mật (key, token, mật khẩu) được xoá trước khi gửi.",
"ruleNotSent": "Session dùng tài khoản riêng hoặc AI Proxy riêng của bạn không gửi gì cả — kể cả tên thư mục. Hub không đọc file của bạn và không điều khiển máy này.",
"ruleKey": "Key của tài khoản công ty nằm ở hub; máy này chỉ giữ một device token (90 ngày, thu hồi được).",
"ruleOffice": "Hub nằm trong mạng công ty: ở ngoài, session tài khoản công ty không mở được; tài khoản riêng vẫn dùng bình thường.",
"enrolledAs": "Đã đăng ký với {{hub}} bằng {{user}}",
"validUntil": "hiệu lực đến {{time}}",
"expired": "đã hết hạn",
"expiredHint": "Đăng nhập hub của máy này đã hết hạn — đăng ký lại.",
"sendNow": "Gửi ngay",
"logout": "Đăng xuất",
"logoutTitle": "Đăng xuất máy này khỏi hub?",
"logoutHint": "Device token bị thu hồi trên hub. Session tài khoản công ty không dùng được ở máy này cho đến khi đăng ký lại.",
"sentTitle": "Đã gửi lên hub",
"sentLine": "{{bytes}} · {{time}}",
"notSentYet": "chưa gửi",
"ended": "đã kết thúc",
"nothingSent": "Chưa có session tài khoản công ty nào.",
"sentToast": "Đã gửi {{bytes}} của {{sessions}} session",
"enrolledToast": "Máy này đã đăng ký với hub",
"hubAddress": "Địa chỉ hub",
"username": "Tên đăng nhập hub",
"password": "Mật khẩu hub",
"enroll": "Đăng ký máy này",
"cliHint": "Hoặc trong terminal: agenthub remote login <địa chỉ hub>",
"hubTitle": "Máy của bạn",
"hubDescription": "Các máy chạy AgentHub local bạn đã đăng ký với hub này, và các session tài khoản công ty của chúng.",
"noDevices": "Chưa có máy nào đăng ký.",
"revokedAt": "đã thu hồi {{time}}",
"lastSeen": "thấy lần cuối {{time}} từ {{ip}}",
"revoke": "Thu hồi",
"revokeTitle": "Thu hồi {{name}}?",
"revokeHint": "Device token ngừng hoạt động ngay; máy đó phải đăng ký lại.",
"revoked": "Đã thu hồi {{name}}",
"yourPassword": "Mật khẩu của bạn",
"sessions": "Session tài khoản công ty",
"unmonitored": "không mở từ AgentHub",
"sessionLine": "{{requests}} request · {{input}} vào · {{output}} ra · {{account}}",
"purged": "đã xoá hội thoại (quá hạn lưu)",
"whoRead": "Ai đã xem session của bạn",
"nobodyRead": "Chưa ai xem session của bạn."
}
}
+2 -1
View File
@@ -409,7 +409,8 @@
"activeLabel": "Đang chạy",
"activeValue": "{{sessions}} session · {{panes}} pane",
"nowTitle": "Đang chạy",
"poolTheirs7d": "của họ 7 ngày {{cost}}"
"poolTheirs7d": "của họ 7 ngày {{cost}}",
"devicesTitle": "Máy cá nhân (AgentHub local)"
},
"activity": "Hoạt động",
"spend": "Chi phí hôm nay",
+100
View File
@@ -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) =>
+169 -1
View File
@@ -940,6 +940,15 @@ export type PoolProfile = {
subscriptionType: string | null;
active: boolean;
};
/** A user a workspace is shared with (`/api/lanes/:id/shares`) — names only. */
export type LaneShare = {
id: number;
username: string;
name: string;
sharedAt: string;
/** Disabled since it was shared: kept on the list, granting nothing. */
disabled?: boolean;
};
/** A login profile of a tool other than Claude (`/api/tool-profiles`) — never a token. */
export type ToolProfileIdentity = {
/** codex: `chatgpt` | `apikey`; omp: `credentials`. */
@@ -3601,7 +3610,11 @@ export const api = {
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
artifacts: {
list: (
params: { scope?: "mine" | "hub" | "all" | "unowned"; kind?: ArtifactKind; q?: string } = {}
params: {
scope?: "mine" | "hub" | "withme" | "all" | "unowned";
kind?: ArtifactKind;
q?: string;
} = {}
) => {
const qs = new URLSearchParams();
if (params.scope) qs.set("scope", params.scope);
@@ -3611,6 +3624,17 @@ export const api = {
return request<{ items: Artifact[] }>(`/artifacts${s ? `?${s}` : ""}`);
},
settings: () => request<ArtifactSettings>("/artifacts/settings"),
setExportsEnabled: (exportsEnabled: boolean) =>
request<{ installsEnabled: boolean; exportsEnabled: boolean }>("/artifacts/settings", {
method: "PUT",
body: JSON.stringify({ exportsEnabled }),
}),
/** A page's viewable copy, taken now from the file it was published from (if still there). */
copy: (id: number) =>
request<{ item: Artifact }>(`/artifacts/${id}/copy`, { method: "POST", body: "{}" }),
/** Server mode: who the caller may share with — names only. */
people: () =>
request<{ people: { id: number; username: string; name: string }[] }>("/artifacts/people"),
setInstallsEnabled: (installsEnabled: boolean) =>
request<{ installsEnabled: boolean }>("/artifacts/settings", {
method: "PUT",
@@ -3697,6 +3721,8 @@ export const api = {
title?: string;
description?: string;
shared?: boolean;
/** Server mode: the user ids it is shared with by name (replaces the list). */
sharedWith?: number[];
/** plugin only: `name@marketplace`. */
source?: string;
/** plugin only: the marketplace source. */
@@ -3846,6 +3872,55 @@ export const api = {
*
* No response here ever carries an API key — a saved profile reports only
* `hasKey`. */
/** A LOCAL AgentHub's link to the company hub (server/routes/remote-client.js). */
remoteClient: {
/** GET /api/remote-client - enrollment and what was sent; never the token. */
status: () => request<RemoteClientStatus>("/remote-client"),
/** POST /api/remote-client/login - the password goes to the hub once, not kept. */
login: (body: { hub: string; username: string; password: string; deviceName?: string }) =>
request<RemoteClientStatus>("/remote-client/login", {
method: "POST",
body: JSON.stringify(body),
}),
/** POST /api/remote-client/logout - revoke on the hub, forget the token here. */
logout: () =>
request<{ ok: true; wasEnrolled: boolean; revokedOnHub?: boolean }>("/remote-client/logout", {
method: "POST",
}),
/** POST /api/remote-client/upload - send the Company-account sessions' new lines now. */
upload: () =>
request<{
sessions: number;
bytes: number;
errors: { sessionId: string; message: string }[];
}>("/remote-client/upload", { method: "POST" }),
},
/** The hub's remote-client views (server mode, server/hub/). */
remoteHub: {
/** GET /api/remote/devices - your machines; staff may name `user`. */
devices: (user?: number) =>
request<{ devices: RemoteDevice[] }>(`/remote/devices${user ? `?user=${user}` : ""}`),
/** POST /api/remote/devices/:id/revoke - your own password again. */
revoke: (id: number, password: string) =>
request<{ ok: true; device: RemoteDevice }>(`/remote/devices/${id}/revoke`, {
method: "POST",
body: JSON.stringify({ password }),
}),
/** GET /api/remote/sessions - Company-account sessions (device › folder). */
sessions: (user?: number) =>
request<{ sessions: RemoteSessionRow[] }>(`/remote/sessions${user ? `?user=${user}` : ""}`),
/** GET /api/remote/audit - who opened your sessions. */
audit: () =>
request<{
entries: {
id: number;
at: string;
sessionId: string;
reader: string | null;
readerDisplay: string | null;
}[];
}>("/remote/audit"),
},
aiProxies: {
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
@@ -5029,6 +5104,31 @@ export const api = {
}),
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
* uploaded file's AgentHub copy is deleted. */
/** GET /api/lanes/:id/shares — the users a workspace is shared with (server mode). */
shares: (id: number) =>
request<{ shares: LaneShare[]; canManage: boolean }>(`/workspaces/${id}/shares`),
/** PUT /api/lanes/:id/shares — the WHOLE list (owner or admin; `ENOTOWNER` else). */
setShares: (id: number, userIds: number[]) =>
request<{ shares: LaneShare[] }>(`/workspaces/${id}/shares`, {
method: "PUT",
body: JSON.stringify({ userIds }),
}),
/** GET /api/lanes/:id/presence — who has which pane open (server mode). */
presence: (id: number) =>
request<{ panes: Record<string, { id: number; name: string }[]> }>(
`/workspaces/${id}/presence`
),
/** GET /api/lanes/:id/sessions/:sid/shares — the users one session is shared with. */
sessionShares: (id: number, sid: string) =>
request<{ shares: LaneShare[]; canManage: boolean }>(
`/workspaces/${id}/sessions/${encodeURIComponent(sid)}/shares`
),
/** PUT /api/lanes/:id/sessions/:sid/shares — the WHOLE list (owner or admin). */
setSessionShares: (id: number, sid: string, userIds: number[]) =>
request<{ shares: LaneShare[] }>(
`/workspaces/${id}/sessions/${encodeURIComponent(sid)}/shares`,
{ method: "PUT", body: JSON.stringify({ userIds }) }
),
removeSource: (id: number, sourceId: number) =>
request<LaneSourcesView>(`/workspaces/${id}/sources/${sourceId}`, { method: "DELETE" }),
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
@@ -5222,6 +5322,10 @@ export interface Artifact {
size: number | null;
shared: boolean;
sharedAt: string | null;
/** Server mode: the people it is shared with by name (only its owner or an admin gets the list). */
sharedWith?: { id: number; name: string }[];
/** Server mode: shared with the caller by name. */
sharedWithMe?: boolean;
installs: number;
/** False once its owner removed it from their library while shared: Hub only. */
inLibrary: boolean;
@@ -5302,6 +5406,8 @@ export interface ArtifactSettings {
serverMode: boolean;
canShare: boolean;
installsEnabled: boolean;
/** Claude panes are asked to save a viewable copy of a Doc / Slides / Design they publish. */
exportsEnabled?: boolean;
canManage: boolean;
}
@@ -5614,6 +5720,9 @@ export interface AiProxy {
/** Identifier, derived from the label. Stable: a session's run row stores it. */
name: string;
label: string;
/** The remote client's Company account (`agenthub.company`) — the hub's gateway,
* added by the client on an enrolled machine; never a saved profile. */
company?: boolean;
/** True only for the read-only `native` profile (the vendor's own API). */
builtin: boolean;
/** "shared": the host's (an admin's, in server mode), usable by everyone;
@@ -6421,6 +6530,9 @@ export interface RunHandle {
startedAt: string | null;
/** Initial prompt preview (first 500 chars), null when not provided. */
promptPreview: string | null;
/** Server mode: a user the workspace is shared with opened it — they pay
* for it. Null/absent = the workspace's owner. */
startedBy?: { id: number; name: string } | null;
tool: LaneTool;
}
@@ -6805,3 +6917,59 @@ export interface PetList {
featured: FeaturedPet[];
sheet: { width: number; height: number; cellWidth: number; cellHeight: number };
}
/** An enrolled machine on the hub (server/hub/devices.js) — never its token. */
export interface RemoteDevice {
id: number;
userId: number;
name: string;
createdAt: string;
expiresAt: string;
lastSeenAt: string | null;
lastIp: string | null;
revokedAt: string | null;
active: boolean;
}
/** A Company-account session a device registered (server/hub/remote-sessions.js). */
export interface RemoteSessionRow {
sessionId: string;
deviceId: number;
deviceName: string;
userId: number;
cwd: string;
tool: string;
account: string | null;
registeredAt: string;
endedAt: string | null;
lastRequestAt: string | null;
requests: number;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
lastUploadAt: string | null;
purgedAt: string | null;
unmonitored: number;
title: string | null;
}
/** GET /api/remote-client — this local machine's link to the hub. */
export interface RemoteClientStatus {
enrolled: boolean;
expired?: boolean;
hub?: string;
user?: { id: number; username: string; role: string } | null;
device?: { id: number; name: string; expiresAt: string } | null;
enrolledAt?: string | null;
retainDays?: number | null;
uploads?: {
sessionId: string;
bytes: number;
lastSentAt: string | null;
ended: number;
title: string | null;
cwd: string | null;
startedAt: string | null;
}[];
}
+150
View File
@@ -0,0 +1,150 @@
/**
* @file artifactFolders — the Artifacts page's user-made folders ("Slides",
* "Client A", …): their names and order, and which folder each artifact sits
* in. One JSON value under `agenthub.artifacts.folders`, an account preference
* key (`lib/userPrefs.ts`, `server/routes/me.js`), so the folders are kept per
* ACCOUNT on the server and follow it to any browser. A viewer's arrangement,
* not an artifact fact: nothing about an artifact changes, other users never
* see these folders, and deleting a folder only takes its artifacts out of it.
* Same store shape as `workspaceGroups.ts`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useSyncExternalStore } from "react";
import { PREFS_EVENT } from "./workspaceGroups";
export const ARTIFACT_FOLDERS_KEY = "agenthub.artifacts.folders";
export const MAX_FOLDER_NAME = 60;
export interface ArtifactFolder {
id: string;
name: string;
}
export interface ArtifactFolders {
/** In display order. */
folders: ArtifactFolder[];
/** Artifact id → folder id. An artifact absent here is in no folder. */
of: Record<string, string>;
}
const EMPTY: ArtifactFolders = { folders: [], of: {} };
const listeners = new Set<() => void>();
let cache: { raw: string | null; value: ArtifactFolders } = { raw: null, value: EMPTY };
function readRaw(): string | null {
try {
return localStorage.getItem(ARTIFACT_FOLDERS_KEY);
} catch {
return null;
}
}
function parse(raw: string | null): ArtifactFolders {
if (!raw) return EMPTY;
try {
const v = JSON.parse(raw) as Partial<ArtifactFolders>;
const folders = (Array.isArray(v.folders) ? v.folders : []).filter(
(f): f is ArtifactFolder =>
!!f && typeof f.id === "string" && !!f.id && typeof f.name === "string"
);
const ids = new Set(folders.map((f) => f.id));
const of: Record<string, string> = {};
if (v.of && typeof v.of === "object") {
for (const [item, fid] of Object.entries(v.of)) {
if (typeof fid === "string" && ids.has(fid)) of[item] = fid;
}
}
return { folders, of };
} catch {
return EMPTY;
}
}
export function readArtifactFolders(): ArtifactFolders {
const raw = readRaw();
if (raw === cache.raw) return cache.value;
cache = { raw, value: parse(raw) };
return cache.value;
}
function write(next: ArtifactFolders): void {
try {
localStorage.setItem(ARTIFACT_FOLDERS_KEY, JSON.stringify(next));
} catch {
/* disabled storage: the folders live until the page closes */
cache = { raw: JSON.stringify(next), value: next };
}
listeners.forEach((l) => l());
}
function update(fn: (cur: ArtifactFolders) => ArtifactFolders): void {
write(fn(readArtifactFolders()));
}
const cleanName = (name: string) => name.trim().slice(0, MAX_FOLDER_NAME);
/** A new folder at the end, holding `itemIds` (moved out of their old folder). Returns its id, or null for a blank name. */
export function createFolder(name: string, itemIds: number[] = []): string | null {
const clean = cleanName(name);
if (!clean) return null;
const id = `f${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
update((cur) => {
const of = { ...cur.of };
for (const item of itemIds) of[item] = id;
return { folders: [...cur.folders, { id, name: clean }], of };
});
return id;
}
/** Rename; a blank name keeps the old one. */
export function renameFolder(id: string, name: string): void {
const clean = cleanName(name);
if (!clean) return;
update((cur) => ({
...cur,
folders: cur.folders.map((f) => (f.id === id ? { ...f, name: clean } : f)),
}));
}
/** Delete the folder; its artifacts are only taken out of it. */
export function deleteFolder(id: string): void {
update((cur) => ({
folders: cur.folders.filter((f) => f.id !== id),
of: Object.fromEntries(Object.entries(cur.of).filter(([, fid]) => fid !== id)),
}));
}
/** Put an artifact in a folder, or (null) take it out of every folder. */
export function moveToFolder(itemId: number, folderId: string | null): void {
update((cur) => {
const of = { ...cur.of };
if (folderId && cur.folders.some((f) => f.id === folderId)) of[itemId] = folderId;
else delete of[itemId];
return { ...cur, of };
});
}
/** The folder an artifact is in, or null. */
export function folderOf(value: ArtifactFolders, itemId: number): ArtifactFolder | null {
const fid = value.of[itemId];
return (fid && value.folders.find((f) => f.id === fid)) || null;
}
function subscribe(l: () => void) {
listeners.add(l);
const onStorage = (e: StorageEvent) => {
if (e.key === ARTIFACT_FOLDERS_KEY || e.key === null) l();
};
window.addEventListener("storage", onStorage);
window.addEventListener(PREFS_EVENT, l);
return () => {
listeners.delete(l);
window.removeEventListener("storage", onStorage);
window.removeEventListener(PREFS_EVENT, l);
};
}
export function useArtifactFolders(): ArtifactFolders {
return useSyncExternalStore(subscribe, readArtifactFolders);
}
+79
View File
@@ -0,0 +1,79 @@
/**
* @file hiddenProfilesStorage.ts
* @description Claude login profiles this browser leaves out of Settings →
* AI tools → Profiles, kept in localStorage with the same read/validate/
* fallback convention as laneOrderStorage.ts. A viewer preference only: the
* profile files, the API and the database are untouched, and another browser
* still lists every profile. The status bar's Usage panel reads the same list
* (`HIDDEN_PROFILES_EVENT` tells it of a change in this tab), and when the
* live login is hidden both show the first visible profile in its place
* (`standIn`).
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const STORAGE_KEY = "agenthub.cc.hiddenProfiles";
/** Fired on `window` after this tab changes the list. */
export const HIDDEN_PROFILES_EVENT = "agenthub:hidden-profiles";
/** A profile's stable key: its account (survives a rename), else its file name. */
export function profileKey(p: { accountUuid: string | null; name?: string }): string {
return p.accountUuid || p.name || "";
}
export function readHiddenProfiles(): string[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((k): k is string => typeof k === "string" && k.length > 0);
} catch {
return [];
}
}
export function writeHiddenProfiles(keys: string[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(keys));
} catch {
/* ignore quota / disabled storage */
}
try {
window.dispatchEvent(new Event(HIDDEN_PROFILES_EVENT));
} catch {
/* no window (tests outside jsdom) */
}
}
/** True when `key` names a change to the hidden list (a `storage` event's key). */
export const isHiddenProfilesKey = (key: string | null) => key === STORAGE_KEY || key === null;
/**
* The entry shown in place of a hidden live login: the first one in `list`
* that is not hidden, or null when the live one is visible (or none is left).
*/
export function standIn<T extends { accountUuid?: string | null; name?: string }>(
list: T[],
isActive: (x: T) => boolean,
hidden: string[]
): T | null {
const live = list.find(isActive);
if (
!live ||
!hidden.includes(profileKey({ accountUuid: live.accountUuid ?? null, name: live.name }))
)
return null;
return (
list.find(
(x) =>
!isActive(x) &&
!hidden.includes(profileKey({ accountUuid: x.accountUuid ?? null, name: x.name }))
) ?? null
);
}
/** `keys` with `key` added when absent, removed when present. */
export function toggleHidden(keys: string[], key: string): string[] {
return keys.includes(key) ? keys.filter((k) => k !== key) : [...keys, key];
}
+2 -6
View File
@@ -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" },
+4 -2
View File
@@ -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 {
+12 -1
View File
@@ -1240,6 +1240,8 @@ export interface ToolQuota {
active?: boolean;
/** The saved profile's label, when the account has one. */
label?: string | null;
/** Claude: the Anthropic account the row is about (hides a profile hidden in Settings). */
accountUuid?: string | null;
/** The signed-in account's email, when the tool's login names one. */
account?: string | null;
/** The subscription's name as the tool reports it (`pro`, `max`, `plus`, `team`…). */
@@ -1573,7 +1575,10 @@ export type AlertRuleType =
| "status_duration"
| "token_threshold"
| "cost_threshold"
| "budget";
| "budget"
/** Server mode: a user's machine used the company account for a session it
* never registered with the hub (no config). */
| "remote_unmonitored";
/**
* Rule-type-specific settings for an {@link AlertRule}. Which fields apply
@@ -1937,6 +1942,10 @@ export interface WSMessage {
| "remote_source.status"
| "remote_data.updated"
| "lane_update"
/** A workspace was shared with / taken back from the users named (`userIds`). */
| "lane_access"
/** Who has a pane open changed: `{laneId, runId, users}` (pane-presence.js). */
| "pane_presence"
/** One output line from a lane's profile hook, while it runs. */
| "lane_hook_output"
/** A lane's stack finished coming up (or failed to), with fresh facts. */
@@ -2701,6 +2710,8 @@ export interface Lane {
owner_display_name?: string | null;
/** When the owner's avatar was last set (`users.avatar_at`); null = none. */
owner_avatar_at?: string | null;
/** Server mode: `"session"` when only sessions of it are shared with this viewer. */
access?: "session";
branch: string | null;
kind: "managed" | "adopted";
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
+1
View File
@@ -47,6 +47,7 @@ export const PREF_KEYS = [
"agenthub.markdown.view",
"agenthub.workspace.groups",
"agenthub.changes.innerRepos",
"agenthub.artifacts.folders",
] as const;
const SYNCED = new Set<string>(PREF_KEYS);
const PUSH_DELAY_MS = 400;
+607 -25
View File
@@ -53,19 +53,23 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
ChevronRight,
FileText,
Folder,
FolderPlus,
Globe,
LayoutGrid,
List,
MoreHorizontal,
Pencil,
Puzzle,
Search,
Sparkles,
Trash2,
X,
} from "lucide-react";
import {
@@ -83,6 +87,8 @@ import { eventBus } from "../lib/eventBus";
import { toast } from "../lib/toast";
import { confirmDialog } from "../lib/confirm";
import { useDialog } from "../hooks/useDialog";
import { Checkbox } from "../components/Checkbox";
import { isSubmitEnter } from "../lib/keys";
import { copyText } from "../lib/clipboard";
import { timeAgo } from "../lib/format";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
@@ -95,6 +101,17 @@ import {
splitSkillMd,
} from "../components/artifacts/ArtifactAddPanels";
import { MarkdownPreview, isMarkdown } from "../components/MarkdownPreview";
import { HtmlFrame } from "../components/HtmlFrame";
import { PdfView } from "../components/PdfView";
import {
MAX_FOLDER_NAME,
createFolder,
deleteFolder,
folderOf,
moveToFolder,
renameFolder,
useArtifactFolders,
} from "../lib/artifactFolders";
// The storefront's shelf order: what installs first, what is read after.
const SHELVES: ArtifactKind[] = ["skill", "plugin", "page", "file"];
@@ -110,22 +127,41 @@ const fmtDate = (iso: string) =>
new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i;
// What the sandboxed preview can show: the server's typed list minus PDF.
// What the preview can show: the sandboxed types, plus PDF drawn by pdf.js.
const PREVIEWABLE =
/\.(html?|md|markdown|txt|log|csv|tsv|json|ya?ml|sh|py|js|ts|png|jpe?g|gif|webp|svg|mp4|webm|mp3|wav)$/i;
/\.(html?|md|markdown|txt|log|csv|tsv|json|ya?ml|sh|py|js|ts|png|jpe?g|gif|webp|svg|mp4|webm|mp3|wav|pdf)$/i;
const PDF = /\.pdf$/i;
/** What a row can do, worked out once for its card, menu and detail sheet. */
/** What a dragged artifact carries: its id, for a folder chip to take. */
const DRAG_TYPE = "application/x-agenthub-artifact";
const dragProps = (a: Artifact) => ({
draggable: true,
onDragStart: (e: React.DragEvent) => {
e.dataTransfer.setData(DRAG_TYPE, String(a.id));
e.dataTransfer.setData("text/plain", a.title);
e.dataTransfer.effectAllowed = "move";
},
});
/** Shared at all: with everyone (the Hub) or with chosen people. */
const isShared = (a: Artifact) => a.shared || (a.sharedWith?.length ?? 0) > 0;
function capabilities(a: Artifact) {
const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? "");
return {
previewPath,
canPreview:
a.files.length > 0 &&
(a.kind === "skill" || (a.kind === "file" && PREVIEWABLE.test(previewPath))),
(a.kind === "skill" ||
((a.kind === "file" || a.kind === "page") && PREVIEWABLE.test(previewPath))),
// A page with no copy yet that was published from a file: one may still
// be taken (`POST /:id/copy`) when someone asks to view it.
mayCopy: a.kind === "page" && a.files.length === 0 && !!a.source,
canDownload: a.kind === "file" && a.files.length > 0,
installable: a.kind === "skill" || a.kind === "plugin",
// Its owner removed it from their library; it lives on in the Hub only.
hubOnly: a.mine && a.shared && !a.inLibrary,
hubOnly: a.mine && isShared(a) && !a.inLibrary,
};
}
@@ -150,14 +186,25 @@ const footAction =
export function Artifacts() {
const { t } = useTranslation("artifacts");
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
const [tab, setTab] = useState<"all" | "mine" | "hub" | "unowned">("all");
const [tab, setTab] = useState<"all" | "mine" | "hub" | "withme" | "unowned">("all");
const { isAdmin } = usePermissions();
// Server mode: rows nobody owns — every one for an admin, those offered to you otherwise.
const [unownedCount, setUnownedCount] = useState(0);
const [users, setUsers] = useState<ServerUserUsage[]>([]);
const [kind, setKind] = useState<ArtifactKind | "">("");
const [q, setQ] = useState("");
const [items, setItems] = useState<Artifact[] | null>(null);
const [loaded, setItems] = useState<Artifact[] | null>(null);
// Folders are this account's own arrangement (`lib/artifactFolders.ts`).
const folders = useArtifactFolders();
const [folder, setFolder] = useState<string>("");
const [sharing, setSharing] = useState<Artifact | null>(null);
const [moving, setMoving] = useState<Artifact | null>(null);
const activeFolder = folders.folders.some((f) => f.id === folder) ? folder : "";
const items = useMemo(
() =>
loaded && activeFolder ? loaded.filter((a) => folders.of[a.id] === activeFolder) : loaded,
[loaded, activeFolder, folders]
);
const [adding, setAdding] = useState<ArtifactKind | null>(null);
const [addMenu, setAddMenu] = useState(false);
const addMenuRef = useRef<HTMLDivElement>(null);
@@ -261,12 +308,16 @@ export function Artifacts() {
const server = !!settings?.serverMode;
// Share opens the dialog: everyone (the Hub), chosen people, or nobody. A
// Hub-only row (its owner already removed it from their library) is
// deleted by unsharing it, so its one action is that, behind a confirm.
const share = async (a: Artifact) => {
// A Hub-only row (its owner already removed it from their library) is
// deleted by unsharing it, so that asks first.
if (!capabilities(a).hubOnly) {
setDetailId(null);
setSharing(a);
return;
}
if (
a.shared &&
!a.inLibrary &&
!(await confirmDialog({
title: t("hubRemoveTitle", { title: a.title }),
message: t("hubRemoveBody"),
@@ -275,8 +326,8 @@ export function Artifacts() {
)
return;
try {
await api.artifacts.update(a.id, { shared: !a.shared });
toast.success(t(a.shared ? "unshareDone" : "shareDone"));
await api.artifacts.update(a.id, { shared: false, sharedWith: [] });
toast.success(t("unshareDone"));
load();
} catch (err) {
toast.error(err);
@@ -495,6 +546,10 @@ export function Artifacts() {
const actionsFor = (a: Artifact): CardActions => ({
onOpen: () => setDetailId(a.id),
onShare: () => void share(a),
onMove: () => {
setDetailId(null);
setMoving(a);
},
onReshare: () => void reshare(a),
onRename: () => {
setDetailId(null);
@@ -506,7 +561,20 @@ export function Artifacts() {
onUninstall: () => void uninstall(a),
onPreview: (path) => {
setDetailId(null);
setPreview({ item: a, path });
if (path || !capabilities(a).mayCopy) {
setPreview({ item: a, path });
return;
}
// An older page: take its copy now, then show it.
api.artifacts
.copy(a.id)
.then(({ item }) => {
const c = capabilities(item);
if (c.canPreview) setPreview({ item, path: c.previewPath });
else toast.error(t("page.noCopy"));
load();
})
.catch((err) => toast.error(err));
},
});
@@ -609,20 +677,21 @@ export function Artifacts() {
// shared (only an admin is shown those) gets a section of its own, so
// "Shared by others" never holds a private row.
const halves =
server && items && tab !== "unowned"
server && items && tab !== "unowned" && tab !== "withme"
? [
{
key: `${tab}.others`,
rows: items.filter((a) => !a.mine && (a.shared || tab === "mine")),
rows: items.filter((a) => !a.mine && (a.shared || a.sharedWithMe || tab === "mine")),
},
{
key: "unshared",
rows: tab === "mine" ? [] : items.filter((a) => !a.mine && !a.shared),
rows:
tab === "mine" ? [] : items.filter((a) => !a.mine && !a.shared && !a.sharedWithMe),
},
{ key: `${tab}.own`, rows: items.filter((a) => a.mine) },
].filter((h) => h.rows.length > 0 || h.key !== "unshared")
: null;
const detail = detailId === null ? null : (items?.find((a) => a.id === detailId) ?? null);
const detail = detailId === null ? null : (loaded?.find((a) => a.id === detailId) ?? null);
return (
<div className="animate-fade-in space-y-5" data-testid="artifacts-page">
@@ -682,7 +751,7 @@ export function Artifacts() {
className="-mb-1 flex gap-1 overflow-x-auto border-b border-border"
data-testid="artifacts-scope"
>
{(["all", "mine", "hub"] as const).map((v) => (
{(["all", "mine", "hub", "withme"] as const).map((v) => (
<button
key={v}
type="button"
@@ -773,12 +842,31 @@ export function Artifacts() {
)}
</div>
<FolderBar
folders={folders.folders}
active={activeFolder}
counts={(id) => (loaded ?? []).filter((a) => folders.of[a.id] === id).length}
onPick={setFolder}
onDropItem={(id, fid) => {
moveToFolder(id, fid);
const f = fid ? folders.folders.find((x) => x.id === fid) : null;
toast.success(f ? t("folders.moved", { name: f.name }) : t("folders.takenOut"));
}}
/>
{items === null ? (
<SkeletonGrid label={t("loading")} />
) : items.length === 0 && tab === "unowned" ? (
<p className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-fg-muted">
{t("unowned.empty")}
</p>
) : items.length === 0 && (tab === "withme" || activeFolder) ? (
<p
className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-fg-muted"
data-testid="artifacts-empty-filtered"
>
{t(activeFolder ? "folders.empty" : "withme.empty")}
</p>
) : items.length === 0 ? (
<EmptyState
hub={tab === "hub"}
@@ -846,6 +934,24 @@ export function Artifacts() {
)}
{adding && <AddDialog kind={adding} onClose={() => setAdding(null)} onAdded={load} />}
{preview && <PreviewDialog {...preview} onClose={() => setPreview(null)} />}
{sharing && (
<ShareDialog
item={sharing}
onClose={() => setSharing(null)}
onSaved={() => {
setSharing(null);
load();
}}
/>
)}
{moving && (
<MoveDialog
item={moving}
current={folderOf(folders, moving.id)?.id ?? null}
folders={folders.folders}
onClose={() => setMoving(null)}
/>
)}
{renaming && (
<RenameDialog
item={renaming}
@@ -1131,6 +1237,8 @@ function EmptyState({
type CardActions = {
onOpen: () => void;
onShare: () => void;
/** Put it in one of the viewer's own folders. */
onMove: () => void;
/** A skill of your own: its newest version, shared (installers get Update). */
onReshare: () => void;
onRename: () => void;
@@ -1191,6 +1299,20 @@ function PrimaryAction({
{t("install")}
</button>
);
// A page with a copy is viewed here, by anyone it is shared with; the
// claude.ai link (⋯ → Open) needs the publishing account.
if (a.kind === "page" && (c.canPreview || c.mayCopy))
return (
<button
type="button"
className={smallPrimary}
onClick={() => onPreview(c.canPreview ? c.previewPath : "")}
title={t("page.viewHint")}
data-testid="artifact-view"
>
{t("page.view")}
</button>
);
if (a.kind === "page" && a.url)
return (
<a href={a.url} target="_blank" rel="noopener noreferrer" className={smallPrimary}>
@@ -1251,11 +1373,25 @@ function Badges({ item: a, server }: { item: Artifact; server: boolean }) {
{t("hubOnly")}
</span>
)}
{server && !a.shared && (
{server && !isShared(a) && !a.sharedWithMe && (
<span className="badge shrink-0" title={t("privateHint")}>
{t("private")}
</span>
)}
{server && !a.shared && (a.sharedWith?.length ?? 0) > 0 && (
<span
className="badge shrink-0 border-accent/30 bg-accent-muted text-accent"
title={a.sharedWith!.map((p) => p.name).join(", ")}
data-testid={`shared-people-${a.id}`}
>
{t("sharedWithCount", { count: a.sharedWith!.length })}
</span>
)}
{server && !a.shared && a.sharedWithMe && (
<span className="badge shrink-0 border-accent/30 bg-accent-muted text-accent">
{t("sharedWithMe")}
</span>
)}
{a.auto && (
<span className="badge shrink-0" title={t("autoHint")}>
{t("auto")}
@@ -1316,6 +1452,7 @@ function ArtifactMenu({
menu,
onOpen,
onShare,
onMove,
onReshare,
onRename,
onDelete,
@@ -1369,12 +1506,13 @@ function ArtifactMenu({
{t("uninstall")}
</MenuItem>
)}
<MenuItem onSelect={pick(onMove)}>{t("folders.move")}</MenuItem>
{a.canChange && (
<>
<MenuSeparator />
{server && (
<MenuItem danger={hubOnly} onSelect={pick(onShare)}>
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
{t(hubOnly ? "hubRemove" : isShared(a) ? "sharing" : "share")}
</MenuItem>
)}
{server && a.kind === "skill" && (
@@ -1419,6 +1557,7 @@ function ArtifactRow({ inPack, ...props }: ItemProps & { inPack?: boolean }) {
className="flex items-center gap-3 px-3 py-2.5 transition-colors hover:bg-surface-2/60"
data-testid={`artifact-${a.id}`}
onContextMenu={(e) => menu.open(e, null)}
{...dragProps(a)}
>
<KindIcon kind={a.kind} />
<button
@@ -1473,6 +1612,7 @@ function ArtifactCard({ as = "li", ...props }: ItemProps & { as?: "li" | "div" }
className="card flex flex-col gap-2.5 p-4 transition-colors hover:border-border-light"
data-testid={`artifact-${a.id}`}
onContextMenu={(e: React.MouseEvent) => menu.open(e, null)}
{...dragProps(a)}
>
<div className="flex items-start gap-3">
<KindIcon kind={a.kind} />
@@ -1567,9 +1707,18 @@ function DetailDialog({
{server && a.owner && !a.mine ? t("by", { name: a.owner }) : t(`kindOne.${a.kind}`)}
{a.source ? ` · ${a.source}` : ""}
</p>
{a.shared && (
<span className="badge mt-2 border-accent/30 bg-accent-muted text-accent">
{t(hubOnly ? "hubOnly" : "shared")}
{(isShared(a) || a.sharedWithMe) && (
<span
className="badge mt-2 border-accent/30 bg-accent-muted text-accent"
title={a.sharedWith?.map((p) => p.name).join(", ") || undefined}
>
{hubOnly
? t("hubOnly")
: a.shared
? t("shared")
: a.sharedWithMe
? t("sharedWithMe")
: t("sharedWithCount", { count: a.sharedWith?.length ?? 0 })}
</span>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
@@ -1695,7 +1844,7 @@ function DetailDialog({
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-4">
{server && (
<button type="button" className={secondary} onClick={onShare}>
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
{t(hubOnly ? "hubRemove" : isShared(a) ? "sharing" : "share")}
</button>
)}
{server && a.kind === "skill" && (
@@ -2107,6 +2256,422 @@ function RenameDialog({
);
}
/** Who an artifact is shared with: everyone (the Hub), chosen people, or nobody. */
function ShareDialog({
item,
onClose,
onSaved,
}: {
item: Artifact;
onClose: () => void;
onSaved: () => void;
}) {
const { t } = useTranslation("artifacts");
const initialPeople = (item.sharedWith ?? []).map((p) => p.id);
const [mode, setMode] = useState<"nobody" | "everyone" | "people">(
item.shared ? "everyone" : "people"
);
const [picked, setPicked] = useState<Set<number>>(new Set(initialPeople));
const [people, setPeople] = useState<{ id: number; username: string; name: string }[] | null>(
null
);
const [filter, setFilter] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
api.artifacts
.people()
.then((r) => setPeople(r.people))
.catch((err) => {
setPeople([]);
toast.error(err);
});
}, []);
const shown = (people ?? []).filter((p) => {
const f = filter.trim().toLowerCase();
return !f || p.name.toLowerCase().includes(f) || p.username.toLowerCase().includes(f);
});
const toggle = (id: number, on: boolean) =>
setPicked((cur) => {
const next = new Set(cur);
if (on) next.add(id);
else next.delete(id);
return next;
});
const target =
mode === "everyone"
? { shared: true, sharedWith: [] as number[] }
: mode === "people"
? { shared: false, sharedWith: [...picked] }
: { shared: false, sharedWith: [] as number[] };
const blocked = busy || (mode === "people" && picked.size === 0);
const save = async () => {
if (blocked) return;
// Out of its owner's library, a row that nobody can see any more is
// deleted: that asks first.
const goneForGood =
item.mine && !item.inLibrary && !target.shared && target.sharedWith.length === 0;
if (
goneForGood &&
!(await confirmDialog({
title: t("hubRemoveTitle", { title: item.title }),
message: t("hubRemoveBody"),
confirmLabel: t("hubRemove"),
}))
)
return;
setBusy(true);
try {
await api.artifacts.update(item.id, target);
toast.success(
target.shared
? t("shareDone")
: target.sharedWith.length
? t("sharePeopleDone", { count: target.sharedWith.length })
: t("unshareDone")
);
onSaved();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
};
const radio = (v: typeof mode, label: string, hint: string) => (
<label className="flex cursor-pointer items-start gap-2 rounded-md p-2 hover:bg-surface-2">
<input
type="radio"
name="share-mode"
className="mt-1"
checked={mode === v}
onChange={() => setMode(v)}
/>
<span>
<span className="block text-sm text-fg-primary">{label}</span>
<span className="block text-[11px] text-fg-muted">{hint}</span>
</span>
</label>
);
return (
<Modal title={t("shareTitle", { title: item.title })} onClose={onClose}>
<div className="space-y-3" data-testid="share-dialog">
<div role="radiogroup" aria-label={t("shareTitle", { title: item.title })}>
{radio("people", t("shareMode.people"), t("shareMode.peopleHint"))}
{radio("everyone", t("shareMode.everyone"), t("shareMode.everyoneHint"))}
{(item.shared || initialPeople.length > 0) &&
radio("nobody", t("shareMode.nobody"), t("shareMode.nobodyHint"))}
</div>
{mode === "people" && (
<div className="space-y-2">
<input
className="input w-full text-sm"
placeholder={t("sharePeople.search")}
aria-label={t("sharePeople.search")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{people === null ? (
<p className="text-xs text-fg-muted">{t("loading")}</p>
) : shown.length === 0 ? (
<p className="text-xs text-fg-muted">{t("sharePeople.none")}</p>
) : (
<ul
className="max-h-64 space-y-0.5 overflow-y-auto rounded-lg border border-border p-1"
data-testid="share-people"
>
{shown.map((p) => (
<li key={p.id} className="rounded-md px-2 py-1 hover:bg-surface-2">
<Checkbox
checked={picked.has(p.id)}
onChange={(v) => toggle(p.id, v)}
label={
<span className="text-sm text-fg-primary">
{p.name}
{p.name !== p.username && (
<span className="ml-1 text-xs text-fg-muted">@{p.username}</span>
)}
</span>
}
/>
</li>
))}
</ul>
)}
<p className="text-[11px] text-fg-muted">
{t("sharePeople.picked", { count: picked.size })}
</p>
</div>
)}
<div className="flex justify-end gap-2 pt-1">
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
{t("form.cancel")}
</button>
<button
type="button"
className="btn-primary text-sm"
disabled={blocked}
onClick={() => void save()}
>
{t("shareSave")}
</button>
</div>
</div>
</Modal>
);
}
/** Put one artifact in one of the viewer's own folders (or a new one), or take it out. */
function MoveDialog({
item,
current,
folders,
onClose,
}: {
item: Artifact;
current: string | null;
folders: { id: string; name: string }[];
onClose: () => void;
}) {
const { t } = useTranslation("artifacts");
const [name, setName] = useState("");
const pick = (id: string | null) => {
moveToFolder(item.id, id);
onClose();
};
const create = () => {
const id = createFolder(name, [item.id]);
if (id) onClose();
};
return (
<Modal title={t("folders.moveTitle", { title: item.title })} onClose={onClose}>
<div className="space-y-3" data-testid="move-dialog">
<p className="text-[11px] text-fg-muted">{t("folders.private")}</p>
{folders.length > 0 && (
<ul className="space-y-0.5">
{folders.map((f) => (
<li key={f.id}>
<button
type="button"
aria-pressed={current === f.id}
onClick={() => pick(f.id)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-surface-2 ${
current === f.id ? "text-accent" : "text-fg-primary"
}`}
>
<Folder className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{f.name}</span>
</button>
</li>
))}
</ul>
)}
<div className="flex gap-2">
<input
className="input min-w-0 flex-1 text-sm"
placeholder={t("folders.newName")}
aria-label={t("folders.newName")}
maxLength={MAX_FOLDER_NAME}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
create();
}
}}
/>
<button
type="button"
className="btn-secondary text-sm"
disabled={!name.trim()}
onClick={create}
>
<FolderPlus className="h-3.5 w-3.5" />
{t("folders.createHere")}
</button>
</div>
<div className="flex justify-between gap-2 pt-1">
{current ? (
<button type="button" className="btn-ghost text-sm" onClick={() => pick(null)}>
{t("folders.takeOut")}
</button>
) : (
<span />
)}
<button type="button" className="btn-ghost text-sm" onClick={onClose}>
{t("form.cancel")}
</button>
</div>
</div>
</Modal>
);
}
/** The viewer's folders as filter chips, with new / rename / delete. */
function FolderBar({
folders,
active,
counts,
onPick,
onDropItem,
}: {
folders: { id: string; name: string }[];
active: string;
counts: (id: string) => number;
onPick: (id: string) => void;
/** An artifact dropped on a chip: that folder, or (null, "All folders") none. */
onDropItem: (itemId: number, folderId: string | null) => void;
}) {
const { t } = useTranslation("artifacts");
// The chip an artifact is dragged over ("" = All folders), lit as a target.
const [over, setOver] = useState<string | null>(null);
const dropProps = (target: string) => ({
onDragOver: (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(DRAG_TYPE)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (over !== target) setOver(target);
},
onDragLeave: () => setOver((cur) => (cur === target ? null : cur)),
onDrop: (e: React.DragEvent) => {
setOver(null);
const id = Number(e.dataTransfer.getData(DRAG_TYPE));
if (!Number.isInteger(id) || id <= 0) return;
e.preventDefault();
onDropItem(id, target || null);
},
});
const lit = (target: string) => (over === target ? " ring-2 ring-accent bg-accent-muted" : "");
// `new`: naming a new folder; a folder id: renaming that one.
const [editing, setEditing] = useState<"new" | string | null>(null);
const [name, setName] = useState("");
const begin = (what: "new" | string, start = "") => {
setEditing(what);
setName(start);
};
const commit = () => {
if (editing === "new") {
const id = createFolder(name);
if (id) onPick(id);
} else if (editing) renameFolder(editing, name);
setEditing(null);
};
const remove = async (f: { id: string; name: string }) => {
const ok = await confirmDialog({
title: t("folders.deleteTitle", { name: f.name }),
message: t("folders.deleteBody"),
confirmLabel: t("folders.delete"),
});
if (!ok) return;
deleteFolder(f.id);
if (active === f.id) onPick("");
};
const chip = (on: boolean) =>
`inline-flex h-7 items-center gap-1.5 rounded-full border px-3 text-xs whitespace-nowrap transition-colors ${
on
? "border-accent/40 bg-accent-muted text-fg-primary"
: "border-border text-fg-secondary hover:border-border-light hover:bg-surface-2 hover:text-fg-primary"
}`;
const count = (n: number, on: boolean) => (
<span
className={`min-w-[1.25rem] rounded-full px-1.5 text-center text-[10px] leading-4 tabular-nums ${
on ? "bg-accent/20 text-accent" : "bg-surface-3 text-fg-muted"
}`}
>
{n}
</span>
);
const iconBtn =
"inline-flex h-7 w-7 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary";
return (
<div className="flex flex-wrap items-center gap-1.5" data-testid="artifact-folders">
<button
type="button"
aria-pressed={!active}
className={chip(!active) + lit("")}
onClick={() => onPick("")}
title={folders.length ? t("folders.dropOutHint") : undefined}
{...dropProps("")}
>
<Folder className="h-3.5 w-3.5" aria-hidden />
{t("folders.all")}
</button>
{folders.map((f) => {
const on = active === f.id;
return editing === f.id ? null : (
<span key={f.id} className="inline-flex items-center">
<button
type="button"
aria-pressed={on}
className={chip(on) + lit(f.id)}
onClick={() => onPick(f.id)}
onDoubleClick={() => begin(f.id, f.name)}
title={t("folders.dropHint")}
data-testid={`artifact-folder-${f.id}`}
{...dropProps(f.id)}
>
<span className="max-w-[12rem] truncate">{f.name}</span>
{count(counts(f.id), on)}
</button>
{on && !editing && (
<>
<button
type="button"
className={`${iconBtn} ml-0.5`}
onClick={() => begin(f.id, f.name)}
title={t("folders.rename")}
aria-label={t("folders.rename")}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={`${iconBtn} hover:!text-status-danger`}
onClick={() => void remove(f)}
title={t("folders.delete")}
aria-label={t("folders.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</span>
);
})}
{editing ? (
<input
className="input h-7 w-44 rounded-full px-3 text-xs"
autoFocus
maxLength={MAX_FOLDER_NAME}
aria-label={t(editing === "new" ? "folders.newName" : "folders.rename")}
placeholder={t("folders.newName")}
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
setEditing(null);
}
}}
/>
) : (
<button
type="button"
className="inline-flex h-7 items-center gap-1 rounded-full border border-dashed border-border px-3 text-xs text-fg-muted transition-colors hover:border-accent/50 hover:text-fg-primary"
onClick={() => begin("new")}
data-testid="artifact-folder-new"
>
<FolderPlus className="h-3.5 w-3.5" />
{t("folders.new")}
</button>
)}
</div>
);
}
// Text the dashboard renders itself: Markdown through `MarkdownPreview`, the
// rest as code. A sandboxed iframe would draw a text/plain body with the
// browser's own colours — light text on our white frame in a dark theme.
@@ -2287,6 +2852,23 @@ function PreviewDialog({
<video src={url} controls className="max-h-full max-w-full" />
)}
</div>
) : PDF.test(current) ? (
<PdfView url={url} title={current} />
) : html && item.kind === "page" && !/\.svg$/i.test(current) ? (
// A page's copy is an app: it runs, in the chat artifacts'
// sandbox (scripts, never the dashboard's origin), with its
// own files read from the copy.
<HtmlFrame
path={current}
title={item.title}
className="h-full w-full"
testId="artifact-page-frame"
fetchFile={(rel) =>
fetch(api.artifacts.rawUrl(item.id, rel), { credentials: "same-origin" }).then(
(r) => (r.ok ? r.blob() : Promise.reject(new Error(String(r.status))))
)
}
/>
) : IMAGE.test(current) && !html ? (
<img src={url} alt={current} className="h-full w-full object-contain p-4" />
) : (
+8 -7
View File
@@ -74,7 +74,7 @@ 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";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
@@ -254,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;
@@ -309,17 +309,18 @@ 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. */}
<OmpSharingSettings />
{/* Members' test runs on this box: server mode + S3, an admin's. */}
<MemberPolicySettings />
{/* The company hub: enroll this machine (local) / your machines (hub). */}
<CompanyHubSettings />
</div>
),
},
+58 -39
View File
@@ -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"
+160 -1
View File
@@ -28,6 +28,10 @@ import { api, type Artifact } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
vi.mock("../../lib/confirm", () => ({ confirmDialog: vi.fn() }));
// pdf.js draws on a canvas jsdom does not have; the viewer is its own unit.
vi.mock("../../components/PdfView", () => ({
PdfView: ({ title }: { title: string }) => <div data-testid="pdf-view">{title}</div>,
}));
vi.mock("../../lib/api", () => ({
api: {
lanes: { list: vi.fn() },
@@ -35,6 +39,8 @@ vi.mock("../../lib/api", () => ({
settings: vi.fn(),
list: vi.fn(),
update: vi.fn(),
people: vi.fn(),
copy: vi.fn(),
remove: vi.fn(),
install: vi.fn(),
updateInstalled: vi.fn(),
@@ -386,6 +392,157 @@ describe("Artifacts", () => {
expect(screen.queryByTestId("artifact-installs")).toBeNull();
});
it("shares with the people picked, never with everyone unless asked", async () => {
vi.mocked(api.artifacts.settings).mockResolvedValue({
serverMode: true,
canShare: true,
installsEnabled: true,
canManage: false,
});
vi.mocked(api.artifacts.list).mockResolvedValue({ items: [item({ id: 4, title: "deck" })] });
vi.mocked(api.artifacts.people).mockResolvedValue({
people: [
{ id: 7, username: "ann", name: "Ann" },
{ id: 8, username: "bob", name: "Bob" },
],
});
vi.mocked(api.artifacts.update).mockClear();
vi.mocked(api.artifacts.update).mockResolvedValue({ item: item({ id: 4 }) } as never);
const user = userEvent.setup();
open();
const card = await screen.findByTestId("artifact-4");
await menuAction(user, "Share", card);
const dialog = await screen.findByTestId("share-dialog");
const save = within(dialog).getByRole("button", { name: "Save" });
expect(save).toBeDisabled(); // nobody picked yet
await within(dialog).findByText("Ann");
await user.type(within(dialog).getByLabelText("Find a person"), "an");
expect(within(dialog).queryByText("Bob")).toBeNull();
await user.click(within(dialog).getByRole("checkbox", { name: /Ann/ }));
await user.click(save);
await waitFor(() =>
expect(api.artifacts.update).toHaveBeenCalledWith(4, { shared: false, sharedWith: [7] })
);
});
it("shows who a row is shared with and lists Shared with you as its own tab", async () => {
vi.mocked(api.artifacts.settings).mockResolvedValue({
serverMode: true,
canShare: true,
installsEnabled: true,
canManage: false,
});
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({ id: 5, title: "mine", sharedWith: [{ id: 7, name: "Ann" }] }),
item({ id: 6, title: "for-me", mine: false, canChange: false, sharedWithMe: true }),
],
});
const user = userEvent.setup();
open();
const badge = await screen.findByTestId("shared-people-5");
expect(badge).toHaveTextContent("Shared with 1 person");
expect(badge).toHaveAttribute("title", "Ann");
// A row shared with me by name is "Shared by others", not private.
expect(within(screen.getByTestId("artifact-6")).queryByText("Private")).toBeNull();
await user.click(screen.getByRole("button", { name: "Shared with you" }));
await waitFor(() =>
expect(api.artifacts.list).toHaveBeenLastCalledWith(
expect.objectContaining({ scope: "withme" })
)
);
});
it("views a page's copy here: an app runs in the sandbox, a PDF is drawn, an older page is copied first", async () => {
const page = (over: Partial<Artifact>) =>
item({ kind: "page", url: "https://claude.ai/artifact/p", ...over });
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
page({ id: 11, title: "app", files: [{ path: "app.html", size: 9 }] }),
page({ id: 12, title: "deck", files: [{ path: "deck-1.pdf", size: 9 }] }),
page({ id: 13, title: "old", files: [], source: "/w/old.html" }),
],
});
// The copy's own files come through the raw route.
const realFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => new Response("<p>app</p>")) as typeof fetch;
vi.mocked(api.artifacts.copy).mockResolvedValue({
item: page({ id: 13, title: "old", files: [{ path: "old.html", size: 3 }] }),
});
const user = userEvent.setup();
open();
await user.click(within(await screen.findByTestId("artifact-11")).getByTestId("artifact-view"));
expect(await screen.findByTestId("artifact-page-frame")).toBeInTheDocument();
expect(screen.queryByTestId("artifact-preview")).toBeNull();
await user.keyboard("{Escape}");
await user.click(within(screen.getByTestId("artifact-12")).getByTestId("artifact-view"));
expect(await screen.findByTestId("pdf-view")).toBeInTheDocument();
await user.keyboard("{Escape}");
await user.click(within(screen.getByTestId("artifact-13")).getByTestId("artifact-view"));
await waitFor(() => expect(api.artifacts.copy).toHaveBeenCalledWith(13));
expect(await screen.findByTestId("artifact-page-frame")).toBeInTheDocument();
globalThis.fetch = realFetch;
});
it("keeps folders per account: make one, move an artifact in, filter by it", async () => {
localStorage.removeItem("agenthub.artifacts.folders");
const user = userEvent.setup();
open();
await screen.findByText("deploy");
await user.click(screen.getByTestId("artifact-folder-new"));
await user.type(screen.getByLabelText("Folder name"), "Slides{Enter}");
// The new folder is picked at once, and empty.
expect(await screen.findByTestId("artifacts-empty-filtered")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "All folders" }));
await menuAction(user, "Move to folder…", screen.getByTestId("artifact-2"));
const dialog = await screen.findByTestId("move-dialog");
await user.click(within(dialog).getByRole("button", { name: "Slides" }));
const stored = JSON.parse(localStorage.getItem("agenthub.artifacts.folders")!);
expect(Object.values(stored.of)).toHaveLength(1);
expect(stored.of["2"]).toBe(stored.folders[0].id);
await user.click(screen.getByRole("button", { name: /^Slides/ }));
expect(await screen.findByTestId("artifact-2")).toBeInTheDocument();
expect(screen.queryByTestId("artifact-1")).toBeNull();
localStorage.removeItem("agenthub.artifacts.folders");
});
it("drags an artifact onto a folder to put it there, and onto All folders to take it out", async () => {
localStorage.setItem(
"agenthub.artifacts.folders",
JSON.stringify({ folders: [{ id: "f1", name: "Slides" }], of: {} })
);
open();
const row = await screen.findByTestId("artifact-2");
expect(row).toHaveAttribute("draggable", "true");
const data = new Map<string, string>();
const dataTransfer = {
setData: (k: string, v: string) => data.set(k, v),
getData: (k: string) => data.get(k) ?? "",
get types() {
return [...data.keys()];
},
effectAllowed: "",
dropEffect: "",
};
const stored = () => JSON.parse(localStorage.getItem("agenthub.artifacts.folders")!);
fireEvent.dragStart(row, { dataTransfer });
const chip = screen.getByTestId("artifact-folder-f1");
fireEvent.dragOver(chip, { dataTransfer });
expect(chip.className).toMatch(/ring-2/);
fireEvent.drop(chip, { dataTransfer });
expect(stored().of["2"]).toBe("f1");
expect(chip.className).not.toMatch(/ring-2/);
// Something that is not an artifact (a file from the desktop) is ignored.
fireEvent.drop(chip, {
dataTransfer: { ...dataTransfer, getData: () => "", types: ["Files"] },
});
expect(stored().of["2"]).toBe("f1");
fireEvent.dragStart(row, { dataTransfer });
fireEvent.drop(screen.getByRole("button", { name: "All folders" }), { dataTransfer });
expect(stored().of["2"]).toBeUndefined();
localStorage.removeItem("agenthub.artifacts.folders");
});
it("keeps a shared row in the Hub when its owner deletes it, and removes it from there on ask", async () => {
vi.mocked(api.artifacts.settings).mockResolvedValue({
serverMode: true,
@@ -404,7 +561,9 @@ describe("Artifacts", () => {
expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await menuAction(user, "Remove from Hub", card);
await waitFor(() => expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false }));
await waitFor(() =>
expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false, sharedWith: [] })
);
const calls = vi.mocked(confirmDialog).mock.calls;
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
});
@@ -91,6 +91,13 @@ vi.mock("../../lib/api", async (importOriginal) => {
api: {
aiTools: { list: vi.fn(async () => view), iconUrl: () => "" },
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
// AI tools → Company hub (local: not enrolled; hub: nothing yet).
remoteClient: { status: vi.fn(async () => ({ enrolled: false })) },
remoteHub: {
audit: vi.fn(async () => ({ entries: [] })),
devices: vi.fn(async () => ({ devices: [] })),
sessions: vi.fn(async () => ({ sessions: [] })),
},
// AI tools → Profiles.
toolProfiles: { list: vi.fn(async () => ({ tools: [] })) },
ccConfig: {
@@ -21,6 +21,7 @@ import type { AuthState, ServerUserUsage } from "../../lib/api";
let authState: AuthState = { mode: "local" };
vi.mock("../../components/AuthGate", () => ({
useAuth: () => ({ state: authState, logout: async () => {} }),
usePermissions: () => ({ isAdmin: true, local: false }),
}));
const usage = { cost: 0, tokens: 0 };
@@ -56,6 +57,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
api: {
users,
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
remoteHub: {
devices: vi.fn(async () => ({ devices: [] })),
sessions: vi.fn(async () => ({ sessions: [] })),
},
accountPool: {
list: vi.fn(async () => ({ accounts: [], codex: [], blockPercent: 90 })),
profiles: vi.fn(async () => ({ profiles: [] })),
@@ -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",
+13 -1
View File
@@ -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.69 (rebuild client to match)
UI build v2.8.78 (rebuild client to match)
</span>
</p>
</div>
+67 -8
View File
@@ -1366,8 +1366,10 @@ cannot collide on `index.lock`.
`shelf-drop` need a workspace at the repo root (`400 ESUBDIR`), and
`shelve`/`unshelve` are `409 EMERGING` during a merge, cherry-pick, revert
or rebase. `shelve` on a clean tree is `400 ENOCHANGES`; its `paths` go
through the same `git status` check as `discard`. `unshelve` refuses a
dirty tree (`400 EDIRTY`) so a restore never mixes with new work; a clean
through the same `git status` check as `discard`. `unshelve` refuses
anything staged and any change in one of the shelf's own files
(`400 EDIRTY`, those files in `paths`) so a restore never mixes with new
work — changes in other files (the ones a partial `shelve` left) stay put; a clean
apply drops the entry (`kept: false`), a conflicted one KEEPS it and
answers `conflicts: [...]`, `kept: true` — the files are resolved with
`resolve` (`ours` = the branch, `theirs` = the shelf; there is no merge to
@@ -2096,6 +2098,58 @@ POST /api/locks/:name/release
- **409** `{ "error": { "code": "ENOTHOLDER", "message": "...", "currentHolder": "lane1" } }` — `holder` doesn't match the current owner; a release never trusts its caller, same rule every other destructive path in this project follows.
- **404** `{ "error": { "code": "ENOLOCK", "message": "no such lock: build" } }` — nothing by that name is held.
#### Sharing a workspace (server mode)
```http
GET /api/lanes/:id/shares → { shares: [{id, username, name, sharedAt}], canManage }
PUT /api/lanes/:id/shares {"userIds": [2, 5]} → { shares }
```
The users a workspace is shared with (`lane_shares`, names only). `PUT` takes the
WHOLE list — at most 200 enabled users, never the owner (`400 EBADUSER`) — from the
owner or an admin only (`403 ENOTOWNER`), same-origin, each change audited. Local
mode: `GET` answers an empty list, `PUT` `400 ELOCALMODE`. A user it is shared with
sees and works in it like its owner, but `PATCH {title}`, `reset`/`remove`/`purge`,
`DELETE` and `PUT …/shares` answer them `403 ENOTOWNER`. A session they start or
resume (`POST /api/lanes/:id/start`, `POST /api/run`) runs on THEIR OWN account —
a pool setup-token (Claude) or API key (Codex, Cursor) they may use, or an AI
Proxy of theirs — else `409 ENOCOLLABACCOUNT`; resuming a non-Claude session the other side pays
for, or a collaborator's `resumeLast`, is `409 ECOLLABRESUME`; their spend quota (`429 EQUOTA`)
decides, not the owner's. Every pane they open (a Terminal tab too) records them
in `dashboard_runs.started_by`: its pane token acts as them, and an AI pane's
session hours are billed to them (`session_payers`; a Terminal tab's stay the
owner's). A user disabled since stays on the
list (`disabled: true`) and may be kept or removed; only a new one must be enabled. Users taken off
get the WebSocket message `lane_access` `{laneId, userIds}` (sent to them only). See
`docs/SERVER-MODE.md` → Sharing a workspace. A collaborator's pane token
answers only that workspace's reads, the workspace list (filtered to it), its
stage / up / down / hook / feature / mcp-sync writes and `/api/health`
(`403 EPANESCOPE` — never a start or resume).
```http
GET /api/lanes/:id/presence → { panes: { "<run id>": [{id, name}] } }
```
Who has which pane open (server mode; local mode `{panes: {}}`), in memory;
changes come as the WebSocket message `pane_presence` `{laneId, runId, users}`
to the workspace's owner and full collaborators. A run handle
(`GET /api/run`) carries `startedBy: {id, name}` when a collaborator opened
it — they pay for it. The first keystroke of a terminal attach into a shared
workspace's pane by anyone but its owner is audited (`type into <run id>`).
```http
GET /api/lanes/:id/sessions/:sid/shares → { shares, canManage }
PUT /api/lanes/:id/sessions/:sid/shares {"userIds": [3]} → { shares }
```
One session shared on its own (`session_shares`), same rules as the workspace
list (owner or admin, enabled users, audited `share session <id>`, `404
ENOSESSION` for a session of another workspace). The user gets the workspace
in `GET /api/lanes` with `access: "session"`, only that session in its session
lists, the pane holding it, a resume of it (`start {resumeSessionId}` — no
fork, no new session, no shell), and the workspace's git/Files reads and git
writes; any other call on the workspace answers `403 ESESSIONSHARE`.
#### Workspace context: instructions, folders, context files, memory
```http
@@ -2959,7 +3013,7 @@ curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2
GET /api/pricing/quota[?force=1]
```
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's as `GET /api/oauth/profile` reports it NOW (`organization.organization_type` / `rate_limit_tier` / `seat_tier`, remembered per account so saved profiles and pool accounts show it too), else read from the stored rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; the stored fields are written at sign-in, so they keep `pro` after an upgrade to Max), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
How much of each native tool's plan quota is spent: `{ tools: [{ id: "claude"|"codex"|"antigravity"|"cursor", active, label?, accountUuid?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt }` (`server/lib/quota.js`). **Claude** asks Anthropic's `GET /api/oauth/usage` with the access token the CLI holds right now — never refreshed, so an expired token answers `error: "ELIVEEXPIRED"` until Claude Code refreshes it — cached 5 min (`force=1`, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is `active: true`; every OTHER saved auth profile follows as `active: false` with its `label`, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's **Check quota** does that) — and, once expired, showing the last figure this server read (`asOf`) or `error: "EPROFILEEXPIRED"`. Each Claude token's owner is also asked (`GET /api/oauth/profile`): the live row carries the label and email of the account its token belongs to (`.claude.json` can name another), and a saved profile whose token belongs to a different account answers `error: "EWRONGACCOUNT"` with no windows, never that other account's usage. Every Claude row carries its Anthropic `accountUuid` (null when unknown). **Codex** reads the newest `rate_limits` from its rollout journals, no network (`asOf` = when the journal recorded it; a window past its reset reads 0). **Antigravity** runs the catalog-detected `agy` as `agy --print /usage --output-format json` — the CLI's own `/usage` command, no model call (`num_turns` 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and `agy --version` is ≥ 1.1.11 (an older agy may bill `/usage` as a model request); cached like Claude, a failed run shows the last figure (`asOf`) or `error: "EUPSTREAM"`. One window per pool bucket (`5h`, `wk`), `group` naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; `AGENTHUB_AGY_USAGE=0` never runs it (no entry). **Cursor** reads the Cursor CLI's (`cursor-agent`) own login, `$XDG_CONFIG_HOME/cursor/auth.json` (default `~/.config`), and sends it ONLY to cursor.com's undocumented `GET /api/usage-summary` as the `WorkosCursorSessionToken=<user id>::<token>` cookie (user id from `~/.cursor/cli-config.json` `authInfo`), while its JWT is valid and never refreshed — expired or a 401 is `error: "ETOKENEXPIRED"`; cached like Claude; no file (macOS keeps it in the keychain), no entry. One `mo` window: `individualUsage.plan.totalPercentUsed`, resetting at `billingCycleEnd` (an Enterprise seat without `plan` shows its plan name and no window). `plan` is the subscription's name — Claude's as `GET /api/oauth/profile` reports it NOW (`organization.organization_type` / `rate_limit_tier` / `seat_tier`, remembered per account so saved profiles and pool accounts show it too), else read from the stored rate-limit tier and seat ("Max 5x", "Team Standard", "Team Premium", else `subscriptionType` title-cased; the stored fields are written at sign-in, so they keep `pro` after an upgrade to Max), Codex's `chatgpt_plan_type` id-token claim, Cursor's `membershipType` — shown as a badge beside the account. `account` is the signed-in email and `expiresAt` when the stored access token runs out — Claude's `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: `5h`, `wk`, `mo`, else `<n>d`/`<n>h` (the hover spells `wk`/`mo` out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. **Server mode with S3** (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (`~/.claude`, `~/.codex`, `~/.config/cursor` + `~/.cursor`) — never the host's — and only from files that account owns (open + `fstat` owner check, a FIFO refused). No saved Claude profiles and no Antigravity row for them (agy's `/usage` needs the self-updating binary, so it stays the host's); an account that is not provisioned gets `tools: []`. An admin gets the host's, as before.
#### Analytics
```http
@@ -3514,8 +3568,8 @@ A snapshot freezes a session as it is at that moment into one immutable `session
```http
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed`, `updateAvailable` and `versionAt` (when its content last changed — a new snapshot; a rename or share toggle moves only `updatedAt`) (default scope `all`)
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, exportsEnabled, canManage}
PUT /api/artifacts/settings {installsEnabled?: boolean, exportsEnabled?: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub (installs: server mode only)
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
GET /api/artifacts/skills?laneId= {items: [{name, from: "user"|"workspace"}]}
POST /api/artifacts/page {url, title?, description?}
@@ -3527,11 +3581,13 @@ POST /api/artifacts/skill/new {name, description, content, titl
POST /api/artifacts/plugin/inspect {market} → {market, name, description, plugins: [{name, key, description, version, category}]}
POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL | marketplace.json URL, title?, description?}
GET /api/artifacts/:id {item}
PATCH /api/artifacts/:id {title?, description?, shared?, source?, market?, skill?: {name, description, content}} (source/market: plugin only; skill: a skill's SKILL.md frontmatter name/description and body — written as a new snapshot, other files kept, row `source`/`description` follow)
GET /api/artifacts/people → {people: [{id, username, name}]} — server mode: every enabled account but the caller, names only (who an artifact can be shared with)
PATCH /api/artifacts/:id {title?, description?, shared?, sharedWith?: [userId], source?, market?, skill?: {name, description, content}} (sharedWith: server mode, replaces who it is shared with by name — up to 100 enabled users, the owner dropped; a row out of its owner's library shared with nobody is deleted, `removed`) (source/market: plugin only; skill: a skill's SKILL.md frontmatter name/description and body — written as a new snapshot, other files kept, row `source`/`description` follow)
DELETE /api/artifacts/:id {ok, kept} — kept: a shared row of yours left your library but stays in the Hub
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?}
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
POST /api/artifacts/:id/copy → {item} — a page captured before pages kept a copy: snapshot the HTML file it was published from, if still in its workspace (once per row per server run; any viewer, who gets only the copy)
POST /api/artifacts/:id/reshare → {item, changed} — Share again (skill, owner): the owner's folder on this server becomes the new version and the row is shared; 409 ENOSOURCE when the server has no copy
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, state?, paths?}], unrecorded} — state (skills): latest | outdated | removed | unknown, that install's Claude skill copies against the newest version now
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
@@ -3554,7 +3610,10 @@ What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/ar
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` AND `~/.agents/skills/<name>/` (the folder Codex, Gemini/Antigravity, OpenCode and Cursor read) of every account the caller's sessions run as — under S3 their member home (as them through `write-as`), plus the host's home for an admin, whose host-folder workspaces run as the host. The answer is `{installed, paths, replaced}` (`installed` = the paths joined). A name taken in any of them is `409 EEXISTS` unless `replace: true`, which moves each old one to `~/.claude/agenthub-replaced-skills/<name>[-agents]-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. A list row's `installed` is true when that skill (`~/.claude/skills/<name>`) or plugin (a key of `~/.claude/plugins/installed_plugins.json`) is already in EVERY one of those accounts — the page then shows **Installed** instead of Install; it is a pure read, and an account that cannot be resolved or read counts as not installed. Every install is also a row of `artifact_installs` (who, when, the folders written, whether it replaced); `GET …/installs` lists them newest first — the owner, staff and local mode see every install, anyone else only their own, and `paths` goes only to the installer and an admin (a plugin has none: it is `claude plugin install --scope user`). `unrecorded` is what `installs` counted before that table existed. The card's "N installs" opens this list.
- **a skill follows its source** — a skill row taken from a folder (its owner's `~/.claude/skills/<name>`, or the workspace's `.claude/skills/<name>`; the host's in local mode) is brought up to that folder whenever the Hub is listed (`syncSkillSources`, at most every 15 s; only a folder whose file names, sizes or mtimes changed is read again, through the same checks as a pick — no symlink, no secret, ≤ 5 MB — a member's files as theirs). A change is a NEW snapshot directory, then the old one dropped, and an `artifact_changed` broadcast. `artifacts.version` is a snapshot's content hash and `source_hash` the folder's when last taken: an upload or a Hub edit records the folder as it is then, so only a LATER change to the folder replaces it — the newer change wins. A list row's `updateAvailable` is true when the skill is `installed` and the viewer's `~/.claude/skills/<name>` (any account they run as) differs from `version`; the page then shows **Update**. `install` with `update: true` (a skill, `ENOTINSTALLED` when it is not installed) replaces those copies like `replace: true` — the old ones moved aside — and is not counted as another install. Share, unshare, a shared row's delete and every install or uninstall by someone else are written to `audit_log`.
- **uninstall** — takes an installed skill or plugin out of the same accounts again (`EBADKIND` otherwise; allowed while installs are off). A skill's folders that exist (both skills folders of each account) are MOVED to `~/.claude/agenthub-replaced-skills/<name>[-agents]-uninstalled-<time>/`, never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data <key>` as the caller (its data dir stays). Nothing of it there is `409 ENOTINSTALLED`. The answer is `{uninstalled, paths}` (`paths` = the skill folders moved; empty for a plugin). The `installs` count and `artifact_installs` history are left as they were. The page offers **Uninstall** only in the card's ⋯ menu (and its right-click menu); there is no Reinstall — a newer version is uninstalled and installed again.
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared}`) reaches everyone for a shared row, else its owner and staff.
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared, recipients}`) reaches everyone for a shared row, else its owner, the people it is shared with by name (`recipients`) and staff.
- **Shared with chosen people** (`artifact_shares`, one row per person): `shared = 1` stays "everyone" (the Hub); a row may instead be shared with named users only. They see it (`canSee`), may install it, and find it under `?scope=withme` (**Shared with you**); `sharedWith` (the list) is shown only to the owner or an admin, `sharedWithMe` tells a recipient. Share again never widens a row shared by name to everyone. Changes are audited (`shared with ann, bob`).
- **A page's own copy** (a claude.ai page is private to the account that published it): a publish from an HTML file inside the session's workspace snapshots that file plus the supporting files the tool named (`files`), under the pinned-file guards (read as the folder's account, no secret names, 25 MB in all), and a republish of the same link replaces it. A Claude Docs doc or a typed artifact (Slides, Design…) has no such file: `scripts/artifact-export-hint.js` (a synchronous PostToolUse hook, AgentHub panes only, off when the pane carries `AGENTHUB_ARTIFACT_EXPORT=0` — the admin's `exportsEnabled` switch) asks Claude to save `artifacts-export/<artifact id>.(md|pdf|html)` in the workspace root, and the Stop capture attaches that file to the page's row (no second card) and git-excludes the folder. The page then shows **View**: an HTML copy runs in `HtmlFrame`'s sandbox (scripts, never the dashboard's origin), Markdown in `MarkdownPreview`, a PDF drawn by pdf.js (`client/src/components/PdfView.tsx`, legacy build, loaded only when a PDF is opened). The claude.ai link stays under ⋯ → Open.
- **Folders** are the viewer's own arrangement, not an artifact fact: the account preference `agenthub.artifacts.folders` (`/api/me/prefs`, 64 KB; `client/src/lib/artifactFolders.ts`), never seen by anyone else; deleting a folder only takes its artifacts out of it.
### Query Explorer
@@ -3651,7 +3710,7 @@ POST /api/updates/check
POST /api/updates/apply
```
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, plugin_update_progress, repo_root, … }` (`plugin_update_progress` is `{ step, index, total, started_at }` while a plugin-update chain runs — `marketplace_update` → `plugin_update` → `bootstrap`, from `plugin-update-progress.json` in the runtime dir — else `null`) (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only`, rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, plugin_update_progress, repo_root, … }` (`plugin_update_progress` is `{ step, index, total, started_at }` while a plugin-update chain runs — `marketplace_update` → `plugin_update` → `bootstrap`, from `plugin-update-progress.json` in the runtime dir — else `null`) (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only` (first putting back `package-lock.json` / `client/package-lock.json` when they are the ONLY tracked changes — an earlier update's `npm install` rewrote them; any other change still makes the merge refuse), rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
### Import history
+5 -3
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -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
+1 -1
View File
@@ -82,7 +82,7 @@ checkout unnecessary.
| Component | Where it comes from |
|---|---|
| The eight event hooks | inline `hooks` in `.claude-plugin/plugin.json`, each running `scripts/hook-handler.js` |
| The eight event hooks | inline `hooks` in `.claude-plugin/plugin.json`, each running `scripts/hook-handler.js` — plus one synchronous `PostToolUse` entry (matcher `Artifact\|mcp__claude_ai_.*__batch`) running `scripts/artifact-export-hint.js`, which asks Claude for a viewable copy of a Doc / Slides / Design it just published (see docs/API.md § Artifacts hub) |
| The dashboard server | started detached by the bootstrap, from the plugin cache |
| The `agenthub` CLI | a launcher written to `~/.local/bin/agenthub` |
| The dashboard UI (`/run` and every other client route) | built into the runtime dir by the bootstrap, so it works the moment `claude` starts |
+167 -6
View File
@@ -152,7 +152,8 @@ Workspaces made before server mode, or by the host account, have none.
- A session belongs to the workspace that owns it: `sessions.lane_id`, or the
workspace whose pane holds it. Its agents, events, tokens, cost, alerts,
snapshots and workflow runs belong to that session.
- A **developer** sees only rows of their own workspaces, everywhere: lists,
- A **developer** sees only rows of their own workspaces (and of workspaces
shared with them — see [Sharing a workspace](#sharing-a-workspace)), everywhere: lists,
counts, charts, cost, the Sessions/Events/Agents tables, exports, live
WebSocket updates and terminals. A request naming someone else's row (in the
path, the query or the body) gets `404 ENOTFOUND`, the same answer as a row
@@ -235,6 +236,117 @@ hides them for developers. The command and skill lists show a developer only the
own workspaces' project items: `?cwd=` outside one of them answers
`403 EFOLDER`, and with no `cwd` they list no project items at all.
### Sharing a workspace
A workspace's owner (or an admin) shares it with other users from its `⋯`
menu → **Share…** (`WorkspaceShareModal`, `GET/PUT /api/lanes/:id/shares`,
table `lane_shares`). There is one level: **collaborate**. A user it is shared
with sees the workspace, its sessions, Changes, history and Files like its
owner, joins its open terminals, types into them, commits and pushes, edits its
instructions and context — every row and live update, through the same
`scope.js` predicates (`canSeeLane`, `sessionWhere`, `laneWhere`,
`broadcastAudience`'s `viewers`). Only the owner or an admin renames, resets,
removes, deletes or shares it (`403 ENOTOWNER`).
**Whoever starts or resumes a session pays for it.** A session a collaborator
starts or resumes runs on THEIR OWN account and never on the owner's login
(`spawnRun` `startedBy`): a pool **setup-token** (Claude) or **API key**
(Codex, Cursor) their pool group, pin or choice allows (`forCollaborator` —
never a pool *login*, which would be written into the owner's home), or an AI
Proxy profile of theirs (resolved as them; the gateway relays it). With none,
the start is refused (`409 ENOCOLLABACCOUNT`); omp and every other tool, which
can only run on the folder account's own login, are refused too. Their own
spend quota decides (`EQUOTA`), their pane's hooks read their own flag
(`quota-u<id>.json` in the owner's handoff dir under S3, `u<id>.json` on the
host), and a quota sweep ends or blocks only the panes THEY pay for
(`runPayer`). Money is per hour (`session_payers`, `server/lib/session-payers.js`):
a session nobody else ran has no row and is billed exactly as before; a session
a collaborator started is theirs from its first hour; a resume changes hands
from the NEXT hour (the hour it happens in stays with the previous payer), in
either direction. Only a Claude session books its spend by the hour, so only
it can change hands: a Codex/Cursor/other session is billed whole at its start
hour, and resuming one the other side pays for is refused (`409
ECOLLABRESUME`), as is a collaborator's `resumeLast` (it names no session).
A Claude session's sub-agent usage is likewise booked at the session's start
hour, so after a hand-over it stays with the first payer — an accepted
rounding. A Terminal tab stays the owner's money: a `claude` typed there runs
on the folder account's own login. The Users page, spend quotas, Today (`/api/pricing/cost?mine=1`)
and a developer's budgets all count by payer; the workspace owner's agent
instructions still go into every pane of the workspace. `MAX_AI_SESSIONS` counts
per Linux account, so a collaborator's sessions count against the owner's
account limit.
**Under S3 a collaborator works as the OWNER's Linux account** — the folder
decides the account. They can read everything in the owner's home (the owner's
other workspaces, SSH keys, AI logins, the pane tokens of the owner's panes)
and their commits carry the owner's git identity; only AgentHub's `audit_log`
records who did what. The Share dialog says so. Share only with people you
would hand your keyboard to. Two consequences follow and are accepted, not
bugs: a collaborator can read the pane token of a pane the OWNER started (and
so act as the owner through the CLI from it), and `ENOCOLLABACCOUNT` is a
dashboard rule, not a barrier — in a terminal they can run `claude` as the
owner's account on the owner's login, and either side can edit the other's
quota flag file in that home (the flag is fail-open anyway; the start check is
server-side). A collaborator's personal Codex proxy writes no key into the
owner's home, only its endpoint. Every pane the collaborator opens records them
as its starter (`dashboard_runs.started_by`), so its own pane token acts as
THEM, and only while the workspace is still shared with them
(`userFromPaneToken`).
A user already on the list who was disabled since stays listed (marked
disabled, granting nothing) and can be taken off; only a NEW addition must be
enabled. Every share and unshare is audited (`share workspace <id>` /
`unshare workspace <id>`). Taking it back sends the users taken off
`lane_access` (their list re-reads) and closes their open terminals on it at
the next attach re-check. Deleting the workspace, or the user, drops the rows;
giving the workspace to a user it was shared with drops theirs.
**A collaborator's pane token acts as them inside that workspace only.** The
owner's account can read it (it sits in the owner's home), so it answers only
reads of `/api/lanes/<that id>/…` (and `/api/workspaces/<id>/…`), the
workspace list (which then holds that workspace only), `/api/health`, and its
`stage`, `up`, `down`, `hook/<name>`, `features/activate` and `mcp/sync`
writes (`paneLane`, `403 EPANESCOPE` for everything else) — never a start,
resume or any other call that could bill them. The CLI's workspace commands
work from their pane; the MCP server and the statusline's spend line do not.
A pane the owner or an admin opens still acts as the owner.
**Who is here, and who pays.** A pane's tab shows the other people who have
it open (`pane_presence`, `GET /api/lanes/:id/presence`, in memory — never
the viewer themself), and its tooltip says "Billed to <name>" for a pane a
collaborator opened (`startedBy` on the run). The first keystroke of each
terminal attach into a SHARED workspace's pane (whole or one session of it)
by anyone but its owner is written to the audit log as `type into <run id>` —
who typed where, never what (mouse, focus and the terminal's own answers to
tmux do not count); the Chat composer types through the same attach, so its
prompts are covered too. A terminal that stops answering pings (a sleeping
laptop) is ended at the next re-check, and its presence with it.
#### Sharing one session
The owner (or an admin) can also share ONE session: right-click it in the
sidebar → **Share…** (`GET/PUT /api/lanes/:id/sessions/:sid/shares`, table
`session_shares`, audited as `share session <id>`). The user sees the
workspace in their list (`access: "session"`) with only that session in it,
attaches to the pane holding it, sends it messages (a resume, on their own
account and billed to them, as above) and uses the workspace's Changes, git
and Files reads and git writes. Everything else of the workspace — another
session, a new session, a fork, a Terminal tab, `/api/run`, its instructions,
context and settings — answers `403 ESESSIONSHARE` (`sessionShareAllows` in
`scope.js`, checked in `scopeGuard`). Their run lists show only runs that held
that session, and a pane switching to another session closes their terminal
at the next attach re-check. Taking it back, deleting the workspace or the
user drops the rows. A resume names a free slot (`slot: "auto"`, never pane
0) and a session of THAT workspace; the session's name and archive state stay
the owner's (`PATCH /api/sessions/<id>` is `ESESSIONSHARE`), and the
workspace's extra folders (`?source=`), its files list for Artifacts and its
stage history stay out of reach. **These limits hold for the dashboard's API
only.** Under S3 the pane runs as the owner's Linux account: inside it the
user can `/resume` any session of the folder, run shell commands and read the
owner's home — a session share hands over as much as a workspace share
there. The Share dialog says so; share only with people you would hand your
keyboard to.
### AI Proxy profiles
Each user may keep **personal** AI Proxy profiles beside the **shared** ones:
@@ -534,6 +646,48 @@ How a new session picks its account (`server/lib/claude-pool.js`):
> launch file instead of tmux `-e`), but no other account's process or file.
> The AI Proxy gateway does not cover pool tokens.
## Users' own machines (Company account)
A user with their own computer can run **AgentHub local** there and still use
the Claude account pool. The pool's logins never leave this server: their
sessions go through the server's gateway, which adds the account. Only those
sessions are reported back here — their conversation and token usage, once an
hour and when the session ends. A session on the user's own login sends
nothing, and the server never reads or runs anything on their machine.
Design and limits: `docs/plans/remote-clients/README.md`.
On the user's machine (company LAN only, plain HTTP):
```bash
agenthub remote login http://10.1.13.7:4820 --user mia --name mia-laptop # hub password, asked hidden
agenthub remote status # hub, device, what was sent and when
agenthub remote upload # send now instead of waiting for the hour
agenthub remote logout # the token stops working on the server at once
```
or Settings → AI tools → **Company hub**. The machine then gets **Company
account** in the AI Proxy menu of a new Claude session; that choice is kept on
a resume. The device token lasts 90 days; after it expires or is revoked, log
in again and resume any session that was open (a running pane keeps the token
it started with).
On the server: Settings → AI tools → Company hub lists the user's own
machines (Revoke, their password again) and who opened their sessions; an
admin sees and revokes any user's machines on the **Users** page. Their
sessions are listed device › folder, with what the gateway counted; one used
with the device token outside AgentHub is flagged **unmonitored**, and an
alert rule of type **Company account outside AgentHub** (Settings → Alerts)
fires for it once it has stayed unregistered for a minute.
| Variable (server) | Default | Effect |
|---|---|---|
| `AGENTHUB_REMOTE_GATE` | `warn` | `block` refuses a session AgentHub did not register (403) |
| `AGENTHUB_REMOTE_RETAIN_DAYS` | `30` | conversations older than this are deleted; token rows stay |
| `AGENTHUB_DEVICE_TOKEN_DAYS` | `90` | device token lifetime |
| `AGENTHUB_REMOTE_UPSTREAM` | `https://api.anthropic.com` | where the gateway relays |
| `AGENTHUB_REMOTE_UNMONITORED_ALERT_MS` | `60000` | how long a session may stay unregistered before the alert fires |
| `AGENTHUB_REMOTE_FLUSH_MS` | `120000` | an upload round that stopped half-way is imported this long after its last chunk |
## Codex account pool
The same for Codex (`server/lib/codex-pool.js`), with two kinds of account:
@@ -650,15 +804,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
@@ -1054,7 +1213,9 @@ a pane really is. Each case below is refused or bounded, with a test in
running host server uses that server's own binary (`/proc/<pid>/exe`, found
from its socket in `/proc`), so installing a newer tmux under a running
server no longer mixes versions: a 3.8 client asking a 3.6 server kept it at
100% CPU.
100% CPU. That binary is cached for 10 s; once its server has exited (the
last pane stopped) the call falls back to PATH's `tmux` instead of failing
with `ENOENT`.
- **A member's tmux across a tmux upgrade.** Every tmux call AS a member goes
through `/opt/agenthub/libexec/tmux-member` (root's, in sudoers): it runs
the call with the binary of the member's running server (`/proc/<pid>/exe`,
+2 -2
View File
@@ -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; a shared artifact appears in the **Hub** for every signed-in user. 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)
@@ -22,7 +22,7 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
- **Nothing shared runs on the dashboard's origin.** HTML/SVG previews are served under the `/git/raw` headers (`sandbox` CSP + `nosniff`) and shown in a sandboxed iframe; a type that sandbox cannot show (Office files, zip, PDF — the sandbox blocks both a download and the PDF viewer) gets Download only, never a blank Preview. A `page` is only a link out, accepted only as `https://claude.ai/…`.
- **Installing is the viewer's explicit click, into the viewer's own account only.** For a skill, the hub writes the snapshot's files into `~/.claude/skills/<name>/` of the VIEWER (S3: as their member, through `write-as`). It never overwrites an existing skill without `conflict: replace`, and the old one goes to the trash first. For a plugin, the hub runs `claude plugin marketplace add <source>` + `claude plugin install <name>@<market> --scope user` as the viewer (the `applyPluginBaseline` path). The marketplace source passes an allowlist (`owner/repo` or a `validateRemoteUrl` URL), and no argv comes from the request beyond those two validated values.
- **Before installing, the dialog lists every file of the skill** (a skill may carry scripts that the viewer's agent will run). Every share, unshare and install is written to `audit_log`.
- **Visibility follows ownership:** a developer sees their own artifacts plus shared ones; admin/analyst see per `scope.js`, like sessions. Unshare removes an artifact from the Hub but does not uninstall copies others already installed.
- **Visibility follows ownership:** a developer sees their own artifacts plus those shared with everyone or with them by name; admin/analyst see per `scope.js`, like sessions. Unshare removes an artifact from the Hub but does not uninstall copies others already installed.
## Data
+186 -140
View File
@@ -1,175 +1,221 @@
# Remote clients — work runs on each user's machine, the hub only manages
# Remote clients — work runs on each user's machine, the hub only records company spend
Status: **PLAN, not built.** Decided 2026-10-09. Branch `server-s3` (one Linux
user per account on the hub) stays as it is until this plan is proven or dropped.
Status: **B1–B3 built 2026-11-04 for the Claude pool** (§ Built); B4 and
Codex not started. First decided 2026-10-09, re-scoped 2026-11-04 (this
version). Server mode with S3 stays and runs side by side: a user with a
weak machine keeps working on the hub, a user with their own machine runs
AgentHub local and enrolls it.
## Why
Everyone logging into one machine (server mode, S3) does not scale past ~10
concurrent users: every pane, build and test runs on the hub. Here the compute
moves to each user's machine; the hub keeps identity, the shared accounts, and
every workspace/session/conversation/token record.
the record of what the COMPANY's accounts were used for.
## Decisions (user, 2026-10-09)
## Decisions (user, 2026-11-04 — replaces the 10-09 list)
1. Each user installs **AgentHub local** on their own machine.
2. The **full conversation** is uploaded to the hub — only for sessions started
through AgentHub.
3. Shared accounts (the Claude pool, shared AI Proxies) are usable **only through
AgentHub**. The company has at least as many seats as people; a seat may be
used by more than one person.
4. Use without AgentHub is **detected and warned about**, and **blocked when an
admin turns blocking on**.
5. The hub is reached over the company LAN at `http://10.1.13.7:4820` — **no
HTTPS**.
6. An admin sees, in one click from the Users page, which machines a user has,
which workspaces they used, and what each session did.
7. `server-s3` is kept until this architecture is settled.
1. Each user may install **AgentHub local** on their own machine and enroll it
with the hub once. **S3 stays** alongside it.
2. **The account decides, not the workspace.** Only a session that runs on a
company account — the hub's pool or a shared AI Proxy, i.e. one that goes
through the hub's gateway — is reported. A session on the user's OWN login
or own proxy sends **nothing**: no conversation, no tokens, no heartbeat,
not even the workspace's name. Neither does anything run outside AgentHub.
3. What a reported session sends: its **conversation and token usage**, never
the repository. The hub **never reads the client's source tree, never runs
anything on the client, never drives it** (no terminal, no start/stop, no
file access). The client pushes; the hub never connects to a client.
4. Cadence: **one registration** when a company-account session starts
(session id + device, no content — what the gateway checks), then
conversation and tokens **once an hour** and once more when the session
ends. No per-minute heartbeat (user, 11-04). A queue on disk carries what
an offline machine could not send; it is sent back in the office.
The conversation is uploaded **whole** (tool results and code included —
user, 11-04: no stripping); only strings that look like keys, tokens or
passwords are redacted before sending.
The hub keeps a conversation **30 days**; token and cost rows are kept.
5. The company account's credential **never leaves the hub**. A client holds a
device token only; its Claude calls go through the hub's gateway, which adds
the real credential. Revoking the device token ends access at once.
6. Use of a device token outside AgentHub is **detected**; `remote_gate`
decides `warn` (default: forward, mark the session unmonitored, alert) or
`block` (403).
7. The hub is reached over the company LAN only (`10.1.13.7`); a device token
**expires after 90 days** (`agenthub remote login` again). At home a
company-account session simply cannot reach the hub — AgentHub local says
so plainly — while the user's own login works as usual. Working from home
later means a VPN, never exposing the hub. **No HTTPS** (user, 11-04): plain HTTP
on the company LAN, accepting that someone already inside the network
could sniff conversations, hub passwords and device tokens. Adding it
later is a reverse proxy in front of the hub (docs/SERVER-MODE.md § Put
HTTPS in front of it), no code change.
8. Company vs own is decided by the **account picked at session start**: an
enrolled machine's account menu gains "Company account", whose pane talks
to the hub's gateway; the default login and the user's own AI Proxies are
their own. It is recorded on the run (`dashboard_runs.proxy`), shown as a
"Company" badge, and a resume keeps it. A company account is usable only
through the gateway, so neither side can be mislabelled.
9. Each user can see, on their local AgentHub, what was sent for each
session and when, and on the hub who (admin/analyst) opened their
sessions (`audit_log`).
10. On the hub a client's sessions are shown grouped **device › folder** — no
lane rows are created for them.
## Shape
```
user machine — AgentHub LOCAL hub 10.1.13.7 — AgentHub SERVER mode
┌───────────────────────────────────┐ ┌──────────────────────────────────┐
│ pane: claude │─hooks +▶│ /api/remote/ingest (device token)│
│ ANTHROPIC_BASE_URL = hub gateway│ transcr.│ → sessions/events/tokens, owned │
│ ANTHROPIC_AUTH_TOKEN = ahdev_… │ │ by the device's user │
│ │─/v1/… ─▶│ /api/proxy-gw → pool token added │
└───────────────────────────────────┘ └──────────────────────────────────┘
user machine — AgentHub LOCAL hub 10.1.13.7 — SERVER mode
┌──────────────────────────────────────┐ ┌──────────────────────────────────┐
│ pane on a COMPANY account: │─/v1/… ─▶│ gateway: device token + session │
│ ANTHROPIC_BASE_URL = hub gateway │ │ check → pool credential added │
│ ANTHROPIC_AUTH_TOKEN = ahdev_… │ │ → Anthropic; usage counted now │
│ │─start ─▶│ session registered (id only) │
│ │─hourly▶│ conversation + tokens │
│ pane on the user's OWN login │ (none) │ │
└──────────────────────────────────────┘ └──────────────────────────────────┘
```
No pool token or proxy key ever reaches a user's machine; it holds one device
token. Plain HTTP means anyone sniffing the LAN can read that token and the
conversations — accepted for an internal network. Moving to HTTPS later is a
reverse proxy in front of the hub (Caddy `tls internal`), no code change.
## What "conversation, not source" can and cannot mean
## What the hub can see (per tool, checked 2026-10-09)
The hub stores no file of the repository. But a conversation is made of what
the agent read and wrote: a transcript's tool results hold the files Claude
`Read`, its `Edit`/`Write` inputs hold code, and every request through the
gateway carries the whole context (code included) on its way to Anthropic.
So "conversation only" still contains source excerpts. Decided (user,
11-04): they are NOT stripped — the transcript goes whole, only secret-shaped
strings are redacted.
Uploads cover every session the machine's AgentHub started, in ANY of its
workspaces — company repo or the user's own project alike. A session run
outside AgentHub on the user's own login is invisible; only pool use is gated.
## Gateway checks (verified 2026-10-09, Claude Code 2.1.295)
| Tool | Conversation | Tokens / cost | Pool / gate | Reader today |
|---|---|---|---|---|
| Claude Code | yes | yes | yes (R3) | hooks + transcript |
| Codex | yes | yes | not yet (Codex pool = later) | `codex-usage.js` rollouts |
| Antigravity | no — needs a renderer for `transcript_full.jsonl` | yes | no (own login) | `antigravity-usage.js` |
| Oh My Pi, other catalog tools | no | no | no | none: only the pane run (workspace, time) |
| DeepSeek CLI / not in catalog | no | no | no | not launchable as a tool; a shell pane records nothing |
R2 therefore uploads Claude transcripts, Codex rollouts and agy conversation
files (`remote-sync.js` today mirrors only the first two). Any tool beyond that
needs its own reader first — one per tool, on demand.
## Gating
Verified 2026-10-09 against Claude Code 2.1.295 with a capture server: every
`/v1/messages` call carries `x-claude-code-session-id: <id>` (same id in
`metadata.user_id`), and `--session-id <uuid>` sets it — `spawnRun` already
starts Claude panes that way.
The gateway checks each request:
Every `/v1/messages` call carries `x-claude-code-session-id: <id>`, and
`--session-id <uuid>` sets it — `spawnRun` already starts Claude panes that
way. The gateway checks each request:
- bearer = a device token that is not revoked, of a user who is not disabled —
else **always 401**;
- `x-claude-code-session-id` = a session the hub received uploads for, from
THAT device, within `REMOTE_LIVE_SEC` (300 s; 60 s grace for a new session).
- `x-claude-code-session-id` = a session that SAME device registered at its
start. The registration, not the hourly upload, is what this check reads.
A request failing the second check is a session AgentHub is not uploading
(token copied out of AgentHub, or uploads switched off). Setting
`remote_gate` decides: `warn` (default) forwards it and records the session
as **unmonitored** with its token count from the response's `usage`, and
fires an alert (existing `fireAlert`); `block` refuses it with 403. Ceiling:
someone who also fakes the upload stream passes — detection, not proof.
Relay spike (2026-10-09, Claude Code 2.1.295, script kept in
`~/.cache/agenthub-spike/relay.js`): a client with NO login and
`ANTHROPIC_AUTH_TOKEN=<fake>` worked through a relay that swapped in a team
subscription's OAuth token (+ `anthropic-beta: oauth-2025-04-20`). Checked:
answers, `--resume`, a local Bash tool call, prompt cache reads on every turn
(18–26k tokens), `anthropic-ratelimit-unified-*` headers passed back. Three
findings for R3:
- Through a base URL Claude Code writes **5-minute** cache entries; set
`ENABLE_PROMPT_CACHING_1H=1` in the pane (verified: 1h entries) to keep the
subscription's 1-hour TTL.
- The cache is per ACCOUNT (a fresh client read 18k cached tokens on its first
call), so the gateway must keep a session on ONE pool account — switching
accounts mid-session re-writes the whole cache.
- A repo's `.claude/settings*.json` `env` overrides the pane's
`ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` (seen in this repo) — give the
gateway through `--settings=<file>`, the layer that outranks project settings,
as AI Proxy panes already do.
Not checked yet (interactive features that call claude.ai with the user's own
login, likely unavailable): `/usage` quota view, claude.ai MCP connectors,
Remote Control / web sessions, org plugins from claude.ai.
Relay spike (2026-10-09, script `~/.cache/agenthub-spike/relay.js`): a client
with no login and a fake `ANTHROPIC_AUTH_TOKEN` worked through a relay that
swapped in a team subscription's OAuth token (+ `anthropic-beta:
oauth-2025-04-20`): answers, `--resume`, local tools, prompt-cache reads,
rate-limit headers. Rules it gave:
- set `ENABLE_PROMPT_CACHING_1H=1` in the pane (a base URL drops to 5-minute
cache entries otherwise);
- keep a session on ONE pool account (the cache is per account);
- give the gateway through `--settings=<file>` (a repo's `.claude/settings*.json`
`env` overrides the pane env), as AI Proxy panes already do.
Not checked: `/usage`, claude.ai MCP connectors, Remote Control — likely lost
through a relay.
What is and is not guaranteed:
- **Guaranteed:** no hub account (or a disabled user / revoked device) → no
issued credential, because the credential never leaves the hub. This holds
only while the ADMIN alone holds each shared seat's login (setup-tokens
made by the admin). A seat whose login (email/SSO) an employee knows can
always be used directly at claude.ai or in a plain `claude` — AgentHub cannot
stop that.
- **Not guaranteed:** that the AgentHub APP was used. The device token sits on
a machine the user controls; `block` stops a plain `claude` using it, unless
the user also fakes the upload stream. Hard enforcement needs managed,
locked-down machines.
- **Closes the visibility gap (R3b):** the gateway sees every request body, so
it can record the conversation of every pool request ITSELF — new messages
per request by session id, plus the streamed answer and its `usage` — no
matter which client sent it. Then nothing that uses an issued credential is
invisible to the admin, AgentHub app or not.
- **Plain HTTP:** a LAN sniffer can take a device token and act as that user.
If that matters, HTTPS (reverse proxy + an internal CA every machine trusts,
`NODE_EXTRA_CA_CERTS` for Claude Code).
- **Guaranteed:** no hub account / disabled user / revoked device → no company
credential, because it never leaves the hub — as long as the admin alone
holds each shared seat's login.
- **Not guaranteed:** that the AgentHub app was used. A device token copied out
and used with a plain `claude` is caught by the registration check, unless
the user also fakes a registration. Hard enforcement needs managed machines.
- Quota and budget on company accounts stay **real-time** despite the hourly
upload: the gateway counts `usage` from each response it relays.
## What already exists (2026-11-04)
| Need | In the code | Missing |
|---|---|---|
| Hooks from another machine | `POST /api/hooks/ingest-batch` + `push-token.js` (`push:<source>`) | one hub-wide token, no user/device; transcript dropped |
| Credential swap | `proxy-gateway.js` (pane token → real key, SSE streamed) | loopback + pane token only |
| Shared accounts | pools, pool groups, pins, own choice, `refreshLogins` | a path for a remote caller |
| Spend per user | `pool-usage.js`, `user-quota.js`, `session-account.js`, Administration user detail | ownership of a session with no lane |
| Transcript import | `importFromDirectory`, `codex-usage.js` | an upload path |
## Phases
Each one ships alone and changes nothing for a local-only install.
Each ships alone and changes nothing for a local-only install.
**R0 — user activity view (hub, useful today).** Users page row → a user
page: their devices (once R1 exists), workspaces, and sessions by day with
tokens/cost; a session opens the existing session page (conversation, tool
calls). Backend: an `owner=<user id>` filter for staff on `GET /api/sessions`
and `GET /api/lanes`, reusing `sessionWhere`/`laneWhere` for that user — no
new query logic. Admin reads are audited as they already are.
Verify: server test for the filter (developer cannot use it); client snapshot.
**B1 — devices and ownership.** Table `devices` (id, user_id, name,
token_hash, created_at, last_seen_at, revoked_at); `agenthub remote login
http://10.1.13.7:4820` (hub password, throttled like `/api/auth/login`) stores
an `ahdev_` token 0600 in the runtime dir; an admin lists and revokes devices
on the user's page (step-up + `audit_log`). Column `sessions.owner_id`,
written from the device's user; `sessionWhere` honours it, so scoping needs no
lane. Verify: mint/hash/revoke tests; revoked → 401; a developer sees only
their own device sessions.
**R1 — devices (hub + CLI).** Table `devices` (id, user_id, name, token_hash,
created_at, last_seen_at, revoked_at). `agenthub remote login
http://10.1.13.7:4820` signs in with the user's hub password (throttled like
`/api/auth/login`), receives an `ahdev_` token once, stores it 0600 in the
runtime dir. The user page lists devices; an admin revokes one (re-auth +
`audit_log`).
Verify: mint/hash/revoke tests; a revoked token gets 401.
**B2 — gateway for the Claude pool over LAN.** `proxy-gateway.js` gains the
device-token path: heartbeat check, `pickAccount` + session-sticky account,
`usage` counted per response. The local `spawnRun`, on an enrolled machine and
only when the user picks a company account, points the pane at the hub through
`--settings=` and sets `ENABLE_PROMPT_CACHING_1H=1`. Spike first on one real
pool account. Verify: a pane answers "pong"; a curl with a device token and an
unknown session id is logged (`warn`) or 403 (`block`).
**R2 — upload (both sides).** An enrolled local AgentHub uploads each of ITS
sessions (`dashboard_runs`): hook events as they arrive, the session files of
the table above (Claude transcript, Codex rollout, agy conversation)
incrementally (byte offset, retry queue on disk, never blocking a hook). The
hub stores transcripts under `<data dir>/remote/<user id>/<device id>/projects/`
and imports them with the SAME `importFromDirectory` `remote-sync.js` uses, so
tokens and cost match local sessions. Sessions are owned by the device's user
(`source = device:<id>`); workspaces are mirrored as records (title, folder,
tool) — shown, not driven. The existing `ingest-batch` stays as it is.
Verify: two local instances → one hub; R0's page shows both users' work.
**B3 — registration and hourly upload.** For company-account sessions only:
one registration at start; conversation (Claude transcript, Codex rollout) and
tokens hourly and at session end, incremental by byte offset, disk queue,
never blocking a hook. The hub imports with `importFromDirectory` so cost
matches local sessions; shown device › folder on the user's page.
**R3 — remote gateway.** The local `spawnRun` points an enrolled machine's
Claude pane at the hub (`ANTHROPIC_BASE_URL`, device token as
`ANTHROPIC_AUTH_TOKEN`, through tmux `-e`). `proxy-gateway.js` (from
origin/main since 84e2a9f0) gains the remote path and checks above; for the pool it picks an
account with `pickAccount` and sends its OAuth token. **Spike first** on ONE
real pool account: which headers a setup-token needs through a relay.
Verify: a pane gets "pong"; a curl with the device token and a session id
nobody uploads is logged (`warn`) or 403 (`block`).
**B4 — shared skills/plugins.** No AgentHub code: company marketplace (git) +
a `managed-settings.json` template for user machines.
**R4 — shared skills/plugins.** No AgentHub code: the company marketplace (git)
plus a `managed-settings.json` template (`extraKnownMarketplaces`,
`enabledPlugins`) and an install script for user machines.
Codex comes after B2 works for Claude (ChatGPT-login auth differs from an API
key; its session-id header is unchecked).
Codex comes after R3 works for Claude: ChatGPT-account auth differs from an API
key, and its session-id header still has to be checked.
## Built (2026-11-04)
Hub side — `server/hub/`, loaded only in server mode (`server/index.js`
requires it inside `isServerMode()`; `server/__tests__/hub-boundary.test.js`
fails if anything outside the folder requires it):
| File | Does |
|---|---|
| `devices.js` | `ahdev_<32 bytes base64url>` tokens, only the SHA-256 kept; 90 days (`AGENTHUB_DEVICE_TOKEN_DAYS`); revoke |
| `remote-sessions.js` | registration (UUID, absolute cwd, `claude` only); one session = one device — another user's device gets `409 EFOREIGN`, the SAME user's new device (enrolled again) takes it over (`moveSession` moves its copy) |
| `remote-gateway.js` | `/api/remote/gw/v1/*`: device token → registered session (waits `AGENTHUB_REMOTE_REGISTER_GRACE_MS`, 5 s, for one in flight) → `claude-pool.forRemote` (sticky per session) → upstream `AGENTHUB_REMOTE_UPSTREAM`; usage read from a decompressed COPY of the answer (Anthropic sends it gzip/br) |
| `remote-ingest.js` | upload chunks at the hub's own offset (`409 EOFFSET` + `expected`), copy under `<data>/remote/u<uid>/d<did>/<sid>/`, `importFromDirectory` once per round (chunks carry `more: true`, the round's closing request imports; a round cut short is imported after `AGENTHUB_REMOTE_FLUSH_MS`, 2 min), then `sessions.source = device:<id>`, `owner_id`; `purgeOld` after `AGENTHUB_REMOTE_RETAIN_DAYS` (30) keeps token rows |
| `index.js` | `/api/remote/device/*` (device token: login, me, logout, sessions, end, files, upload), `/api/remote/*` (signed in: devices, revoke with password + audit, sessions, "who opened mine"), hourly purge |
Shared: `sessions.owner_id` (scope.js, session-account.js, users.js honour
it); `claude-pool.forRemote` and live remote sessions in the pool ranking;
`auth.checkLogin`.
Client side (local mode): `server/lib/remote-client.js` +
`/api/remote-client` + `agenthub remote login|status|upload|logout`, Settings →
AI tools → Company hub. The reserved AI Proxy name `agenthub.company` ("Company
account", Claude only) writes `<data>/proxy-settings/agenthub.company.json` with the
gateway URL, the device token and `ENABLE_PROMPT_CACHING_1H=1`. A company
session is registered at spawn, again on a new session id (SessionStart /
UserPromptSubmit hooks: `/clear`, a fork), uploaded hourly
(`DASHBOARD_REMOTE_UPLOAD_MS`) and soon after SessionEnd; every JSON line goes
through `privacy.redactValue` first.
Measured (2026-11-04): local mode loads none of `server/hub/` (216 server
modules vs 214 before: `remote-client.js` + its route); startup and RSS
unchanged within noise. E2E on one real pool account: answers, sticky account,
`/clear`, resume, hub totals = transcript totals, no device token or key in the
hub copy, revoke → 401, hub down → `503 EHUBDOWN` at start.
Known limits:
- Claude Code reads `ANTHROPIC_AUTH_TOKEN` once: a pane open across a
re-login (expired or revoked token) keeps failing with 401 until it is
resumed.
- An unmonitored session is flagged on the hub (`unmonitored`, a warning in
the log) and, once it has stayed unregistered for
`AGENTHUB_REMOTE_UNMONITORED_ALERT_MS` (60 s), fires every enabled
**Company account outside AgentHub** alert rule (`remote_unmonitored`). A
registration that was only late clears the flag first.
- `AGENTHUB_REMOTE_GATE=block` is covered by unit tests only.
- A shared AI Proxy as a company account, and Codex, are not built.
## Open questions
- Should an admin be able to mark a workspace "company account required"?
(Today's answer: no — a user's own login in any workspace is their business.)
## Open risks
- A hub outage stops every pool session; a machine can still use its own login.
- The hub stores every conversation of an AgentHub session; tell users.
- A hub outage stops every company-account session; the machine can still use
its own login.
- Every byte of a company-account session's context passes through the hub in
transit; tell users.
+279
View File
@@ -0,0 +1,279 @@
# Workspace & session sharing — plan
Status: **phases 1–4 built 2026-11-06** (workspace sharing; collaborators run on their own account, billed per payer by hour — `session_payers`; one session can be shared — `session_shares`; pane presence + typing audit; R1 decided as option **(a)** — accept and warn hard).
An independent review of phase 1 found and got fixed: a collaborator's Terminal
tab acted as the owner through its pane token (now `dashboard_runs.started_by`),
`POST /api/run {shell:true}` bypassed `ECOLLABSTART`, a disabled collaborator
locked the share list, and the home's prompt box let a collaborator type into a
Start that always failed. Accepted under (a): the owner's own panes' tokens are
readable by a collaborator, and `ECOLLABSTART` is advisory under S3.
Server mode only. In local mode there are no users, so there is nothing to share
and every piece below is a no-op (`isServerMode()`).
## What the user asked for
Members of the same AgentHub install work together on one workspace or one
session. Decisions taken with the repo owner (2026-11-06):
| Question | Decision |
| --- | --- |
| What may a person it is shared with do? | **Always collaborate** — the same as the owner inside that workspace/session: type into panes, send prompts, start sessions, commit, push. No view-only level. |
| Unit of sharing | **Both** — a whole workspace (every session, present and future) or a single session. |
| Who pays for a session a collaborator starts? | **The person who opens it** — its tokens, cost and spend quota are theirs, not the workspace owner's. |
| Whose AI subscription does a collaborator's run use? | **Their own, or none**: only a pool account of kind `token` that the collaborator may use (passed by env). With a `login`-kind pool or no pool the run is **refused** (`ENOCOLLABACCOUNT`) — a collaborator never spends the owner's subscription. |
| Spend quota | **Only the payer's** (the person who started or resumed the run). The owner's quota is not involved. |
| A collaborator resumes someone else's session | **The resumer pays from then on** — billing is per RUN, not per session (below). |
| Session-only share and git | **Allowed** — commit/push in that session's tree, like everything else. |
| Sharing with an analyst | **Allowed** — a shared workspace counts as their own for writes. |
## The one fact everything else follows from (S3)
Under S3 a pane runs as the Linux account of its **folder**
(`accountForPath`, `server/lib/run-as.js`), and only the folder's account may
own a workspace there (`assertOwnerFits`, `EOWNERFOLDER`). Sharing does **not**
change that: a collaborator's work in Alice's workspace runs as `ah-alice`.
What that means, and the UI must say it once, plainly, in the Share dialog:
- **Git identity, SSH key, HTTPS store, commit signing are the owner's.** A
collaborator's commit is authored by the owner (`git config --global` of the
owner's account). AgentHub's `audit_log` records who clicked; git does not.
- **Files written are the owner's.** Everything a collaborator's session
creates belongs to `ah-alice`.
- **The owner's `~/.claude` applies** — their skills, memory, MCP servers,
settings — to the collaborator's sessions too.
- **The owner's account can read the collaborator's pane token** (it is in a
launch file in the owner's home, like every pane secret). So the collaborator's
token must be scoped to the shared workspace only (below), never their whole
account.
There is no way around these without moving the folder to a shared account —
out of scope. Sharing is therefore something you do with people you would
also hand your keyboard to; the dialog says so.
## Data model (migration-safe, `server/db.js`)
```sql
CREATE TABLE IF NOT EXISTS lane_shares (
lane_id INTEGER NOT NULL REFERENCES lanes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
granted_by INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
PRIMARY KEY (lane_id, user_id)
);
CREATE TABLE IF NOT EXISTS session_shares (
session_id TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
granted_by INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
PRIMARY KEY (session_id, user_id)
);
-- who started a dashboard run (a collaborator's run is billed to them)
ALTER TABLE dashboard_runs ADD COLUMN started_by INTEGER; -- guarded like every ALTER in db.js
```
No `role`/`level` column: there is one level (collaborate). Adding view-only
later is one column.
## Who may do what
| Action | Owner | Workspace collaborator | Session collaborator | Admin |
| --- | --- | --- | --- | --- |
| See the workspace, its sessions, Changes, history | ✓ | ✓ | that session + the workspace's Changes panel | ✓ |
| Attach to / type into a live pane | ✓ | ✓ every pane | that session's pane | ✓ |
| Send a message to an ended session (resume) | ✓ | ✓ | that session | ✓ |
| Start a NEW session | ✓ | ✓ (billed to them) | ✗ | ✓ |
| Git writes (commit, push, shelve, rewrite…) | ✓ | ✓ | ✓ (the session's tree) | ✓ |
| Workspace instructions, context files, extra folders | ✓ | ✓ | ✗ | ✓ |
| Rename / Remove / Reset the workspace | ✓ | ✗ | ✗ | ✓ |
| Share / unshare | ✓ | ✗ | ✗ | ✓ |
| `agenthub stage`, lane runtime `up/down/hook` | ✓ | ✓ | ✗ | ✓ |
Owner-only actions get an `assertLaneOwner(req, laneId)` (owner or admin);
everything else becomes "owner **or** collaborator".
## Server changes
1. **`server/lib/scope.js`** — the one place that decides visibility:
- `laneWhere` / `canSeeLane`: `owner_id = ? OR id IN (SELECT lane_id FROM lane_shares WHERE user_id = ?)`.
- `ownedSessionWhere`: add `OR col IN (SELECT session_id FROM session_shares WHERE user_id = ?)` and sessions of shared lanes (through the same lane predicate).
- `PATH_RULES` / `namedIds` need no new rule (they call `canSeeLane`/`canSeeSession`); a new route under a new prefix (`/api/shares`) gets its own rule.
- Keep `memberId` as is: an analyst still reads everything and writes only what they own **or collaborate on**.
2. **Session ownership & billing ("whoever opens it pays")**
- `spawnRun` takes `startedBy = req.user.id`; writes `dashboard_runs.started_by`.
- `bindRunSession` (`server/routes/hooks.js`) stamps `sessions.owner_id = started_by` when it differs from the lane owner. `sessions.owner_id` already exists (remote clients) and `scope.js`, `session-account.js`, `users.js` honour it.
- `server/routes/users.js` ownership CTE: `sessions.owner_id` must WIN over the lane owner (today it is a UNION — a session could count twice). Same in `user-quota.js` spend (`hourlyTokenRows` + `calculateCost`, never a second formula).
- Spend quota: `assertSpawnAllowed(startedBy)` instead of the lane owner, and `hook-handler.js`'s `UserPromptSubmit` check reads the flag of the RUN's payer (the quota file is per Linux account today → per account + payer user id, passed to the pane as `AGENTHUB_PAYER_ID`).
- **Billing is per run, not per session.** A session can be paid by several
people over time: whoever starts or resumes a run pays for everything that
run does, including what anyone types into its live pane (keystrokes cannot
be attributed; the run's starter can). New table
`session_payers(session_id, user_id, from_at)` — one row per run start whose
payer differs from the previous one. `hourlyTokenRows` books a session's
tokens per UTC hour already; each hour goes to the payer whose `from_at` is
the latest ≤ that hour (a run started mid-hour takes the hour from the next
one on — documented rounding, never a second cost formula). With no row the
session's owner pays, as today. `sessions.owner_id` keeps meaning "who
opened it" (visibility); `session_payers` only moves money.
3. **AI login of a collaborator's run — their own or refused.**
- The collaborator's pool pick (`pickAccount(startedBy)`, their group, pin
and own choice) must be of kind `token`: it goes in as
`CLAUDE_CODE_OAUTH_TOKEN` through tmux `-e` / the launch file, overriding
the owner's home login for that pane only.
- A `login`-kind pick, or no pool account for them: `spawnRun` refuses with
`409 ENOCOLLABACCOUNT` before anything starts, for a new session AND for a
resume. The home's Start button says why. The owner's login is never used
for a collaborator's run.
- Codex / other tools: same rule with their own env-only kinds (Codex API key
`AGENTHUB_POOL_KEY`, Cursor `CURSOR_API_KEY`); a ChatGPT login (written into
the home as `auth.json`) is refused like a Claude `login`.
4. **Pane token.** A collaborator's session gets a pane token of the
collaborator, **scoped to that lane**: `dashboard_runs.pane_token_hash` plus a
`scope_lane_id`, and `userFromPaneToken` refuses anything outside that lane.
`peerIsPaneAccount` keeps checking the folder's account (`ah-alice`) — the
token is accepted from that uid because the pane runs as it.
5. **Live co-working.** tmux already allows several attaches with a shared
size (`pty-attach.js`). Add:
- presence: who is attached to which pane (in-memory, broadcast to the lane's audience);
- an audit line per attach-with-input and per prompt sent from the chat composer (`audit_log`, actor = the real user). Keystrokes are not logged.
6. **WebSocket** — `broadcastAudience` (`server/websocket.js`): every lane- or
session-scoped message also reaches the lane's/session's collaborators. A new
type `shares_changed` (owner + affected user).
7. **Routes** (`server/routes/shares.js`, same-origin, audited, server mode only):
- `GET /api/lanes/:id/shares`, `PUT /api/lanes/:id/shares {userIds}` (owner/admin)
- `GET /api/sessions/:id/shares`, `PUT /api/sessions/:id/shares {userIds}` (owner/admin)
- `GET /api/me/shared` — what is shared with me
- only enabled users; never the owner themself; a disabled user's shares stay but grant nothing.
8. **Revoke.** Removing a share closes that user's live attaches on the lane
(their websocket attach ends), revokes their pane tokens' lane scope, and
leaves their running sessions running (they run as the owner; the owner can
stop them). Deleting a lane cascades its shares.
## Client changes
- **Share dialog** from the workspace's `⋯` menu and the session's right-click
menu: user picker (enabled users, not me), the current list with Remove, and
the S3 warning above. `confirmDialog` on Share, `toast` on the result.
- **Sidebar**: a "Shared with me" group (workspaces shared with me, and loose
shared sessions under their workspace name, marked). A share icon on rows I
shared.
- **Presence**: small avatars on a pane's tab of who else is attached.
- **Recents / session list**: the opener's avatar on a session a collaborator
started; the cost column is the opener's.
- Owner-only controls hidden for collaborators (the server refuses anyway —
`ENOTOWNER`, 403).
## Docs to update when built
`docs/SERVER-MODE.md` (new § Sharing + scope table), `docs/LANES.md`,
`docs/API.md`, `CLAUDE.md` (the scope.js rule and the S3 rule gain "or a
collaborator"), `README.md` feature list.
## Phases
1. **S1 — Workspace sharing, visibility + live panes.** Tables, scope.js,
broadcastAudience, Share dialog, sidebar group, attach/type, git writes.
Collaborators can join live panes but cannot start sessions yet.
2. **S2 — Collaborator runs on their own account, billed per run.**
`started_by`, `sessions.owner_id` stamping, `session_payers`, users.js /
quota precedence (and the double count fixed), pane token scope, pool
`token` env, `ENOCOLLABACCOUNT`.
3. **S3 — Session-only sharing.** `session_shares`, its scope rules and UI.
4. **S4 — Presence + audit of prompts.**
Each phase ships on its own; tests per phase: `scope.test.js` (every predicate
with and without a share), `server-mode-review-fixes.test.js` style 404s for a
non-collaborator, users/quota attribution, websocket audience, and the client
dialog/sidebar tests.
## Self-review against the code (2026-11-06)
Checked every claim above against `server/lib/scope.js`, `auth.js`
(`userFromPaneToken`), `hook-auth.js` (`peerIsPaneAccount`), `pty-run.js`
(`spawnRun`), `user-quota.js`, `lane-sources.js` (`launchExtras`),
`websocket.js` (pty attach) and `routes/users.js`.
**R1 — CRITICAL: sharing a workspace hands out a shell as the owner's whole
account, not just that workspace.** A collaborator who types into a pane (or
whose prompt makes Claude run Bash) runs as `ah-alice`, whose home holds ALL of
Alice's workspaces, `~/.ssh` keys, `~/.config/agenthub/` HTTPS store, Claude and
Codex logins (`.credentials.json`, `auth.json`) and the env of her live panes.
So Bob can read Alice's other projects, copy her SSH key and her Claude
subscription token, and — via `$AGENTHUB_PANE_TOKEN` in any of her panes — call
the CLI/MCP **as Alice** (`userFromPaneToken` returns the lane owner, and is
accepted from Alice's uid, which Bob's typing runs as). This also defeats
decision 1 ("never the owner's subscription"): Bob can simply use her login.
The section "The one fact everything else follows from" understated this.
Options:
- **(a) Accept it, warn hard.** Share = "give this person your account". Simple,
honest, no isolation.
- **(b) Team workspaces in their own Linux account.** A shared workspace lives
under a dedicated account (`ah-team-<id>`, provisioned by `agenthub-provision`
like a member, its own home, git identity, logins = the pool's `token` kinds
only). Every member of the team works there; nobody's personal home is
exposed. Real isolation; more work (provisioning, git identity per team,
`assertOwnerFits` learns team folders, moving an existing workspace = clone).
- **(c) Chat-only collaborators.** No terminal for collaborators — but Claude's
own Bash tool still runs as Alice, so this isolates nothing. Not proposed.
**R2 — Typing into the owner's live pane spends the owner's subscription.**
Unavoidable while the pane runs on her login; contradicts the spirit of
decision 1. Under (b) it disappears (team panes use pool `token` accounts).
**R3 — Pane tokens.** `userFromPaneToken` maps a token to the LANE OWNER. Needs
`dashboard_runs.started_by` → the token's user. And every pane token of a shared
lane (the owner's too, see R1) must be limited to that lane, or one leaked token
is a whole account.
**R4 — Session-only sharing does not fit the current checks.** `canSeeRun` and
every `/api/lanes/:id/...` route decide by LANE (`canSeeLane`). Granting a
session collaborator `canSeeLane` would show them the whole workspace. Needs a
run/session-level predicate and an allowlist of lane routes usable with
`?session=<shared id>` only. Recommend: drop session-only sharing from the first
release, or limit it to that session's pane + chat (no Changes panel).
**R5 — Per-account limits are shared.** `MAX_AI_SESSIONS` (default 4) and
`member-guard.js` count per Linux account, so collaborators' sessions eat the
owner's 4 (no issue under (b): the team account has its own limit).
**R6 — Spend-quota flag is one file per account** (`quota.json` in the member's
handoff dir, `flagEnv`). Payer-based quota needs one file per payer
(`quota-u<id>.json`) and `AGENTHUB_QUOTA_FILE` pointing at the payer's.
**R7 — `launchExtras` puts the lane OWNER's personal instructions** first in
every pane; a collaborator's session should get the starter's.
**R8 — AI Proxy.** `resolveProxy(tool, proxy, laneOwner)` resolves by owner: a
collaborator could spend the owner's proxy key. Resolve by the starter — and a
collaborator's OWN proxy profile is a valid "own account" besides a `token` pool
account (decision 1 should allow it).
**R9 — Other `owner_id = ?` call sites** to extend or keep owner-only, checked
one by one: `scope.js` (`laneWhere`, `canSeeLane`, `visibleLaneIds`,
`insideOwnWorkspace`), `auth.js` (demote/disable ends a user's host panes —
must also end runs they `started_by` elsewhere), `pool-usage.js`,
`member-tmux.js`, `me.js` counts, `users.js`.
**R10 — Simplification found:** the pty attach socket already re-checks its user
periodically ("still owning the pane's workspace", `websocket.js`), so revoking
a share closes live attaches by just changing the predicate — no new kill path.
## Answered questions (2026-11-06)
1. A collaborator's run uses **their own `token`-kind pool account, or is
refused** (`ENOCOLLABACCOUNT`) — never the owner's subscription.
2. Spend quota: **only the payer's**.
3. Resume by someone else: **the resumer pays from then on** (per-run billing,
`session_payers`).
4. Session-only share: **commit/push allowed**.
5. Analysts: **sharing gives write** in that workspace.
## Still to settle while building
- The per-hour rounding of `session_payers` (a run started at 10:40 by Bob in a
session Alice paid at 10:05): the hour goes to the earlier payer and Bob pays
from 11:00, or token rows get a finer key. Phase 2 measures how often it
matters before adding a finer key.
- Typing into a live pane started by someone else costs the run's starter. The
pane's header shows "billed to <name>" so nobody is surprised.
+1143 -48
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.69",
"version": "2.8.78",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.69",
"version": "2.8.78",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.69",
"version": "2.8.78",
"bin": {
"agenthub": "bin/agenthub.js"
},
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* @file artifact-export-hint.js — a synchronous PostToolUse hook (matcher
* `Artifact` and the claude.ai connectors' `batch`) that asks Claude, right
* after it publishes a Claude Docs doc or a typed artifact (Slides, Design,
* Dashboard…), to also save a copy in the workspace at
* `artifacts-export/<artifact id>.<pdf|html|md>`. Such a page lives only in
* the publishing claude.ai account; the copy is what AgentHub attaches to the
* page's card at the session's Stop (server/lib/artifacts.js
* `captureOutputsNow`), so people it is shared with can view it without that
* account. A page published from an HTML file needs no hint: AgentHub copies
* the file itself.
*
* Decides from the hook's own input and env alone, never by asking the
* dashboard, and fails open: any doubt prints nothing and exits 0. Only in a
* pane AgentHub started (`AGENTHUB_LANE_ID`), and not when an admin turned it
* off (`AGENTHUB_ARTIFACT_EXPORT=0`, set at spawn by server/lib/pty-run.js).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const MAX_INPUT = 4 * 1024 * 1024;
const DOCS_BATCH_RE = /^mcp__[A-Za-z0-9_-]*claude_ai_[A-Za-z0-9_-]*__batch$/i;
const ARTIFACT_ID_RE = /https:\/\/claude\.ai\/(?:code\/)?artifact\/([A-Za-z0-9_-]+)/;
/** The hint for one PostToolUse payload, or null when there is nothing to ask. */
function hintFor(data, env = process.env) {
if (!data || typeof data !== "object") return null;
if (!env.AGENTHUB_LANE_ID || env.AGENTHUB_ARTIFACT_EXPORT === "0") return null;
const tool = String(data.tool_name || "");
const input = data.tool_input && typeof data.tool_input === "object" ? data.tool_input : {};
let kind = null;
if (DOCS_BATCH_RE.test(tool)) {
let container = input.container;
if (typeof container === "string") {
try {
container = JSON.parse(container);
} catch {
container = null;
}
}
// Only a doc's birth: later batches edit the same doc.
if (container && typeof container.create === "object" && container.create) kind = "doc";
} else if (tool === "Artifact") {
if (input.action && input.action !== "publish") return null;
const file = typeof input.file_path === "string" ? input.file_path : "";
// A page from an HTML file is copied by AgentHub itself.
if (input.type_url || (file && !/\.html?$/i.test(file))) kind = "typed";
}
if (!kind) return null;
const m = ARTIFACT_ID_RE.exec(JSON.stringify(data.tool_response ?? ""));
if (!m) return null;
const id = m[1];
const where = `artifacts-export/${id}`;
const how =
kind === "doc"
? `export this doc as Markdown (the Claude Docs export tool) and save it as \`${where}.md\``
: `save it as \`${where}.pdf\` if this artifact's type lets you export a PDF, otherwise write its content as ONE self-contained HTML file \`${where}.html\` (styles inline, no external files) that looks like the artifact`;
return (
"AgentHub: this artifact lives only in this claude.ai account, so teammates it is shared " +
`with in AgentHub cannot open it. Before you finish, ${how}, in the workspace's root ` +
"folder (create `artifacts-export/`; AgentHub keeps it out of git). Do it again after " +
"you change the artifact a lot. Do not mention this to the user unless they ask."
);
}
function main() {
let raw = "";
let done = false;
const finish = () => {
if (done) return;
done = true;
try {
const text = hintFor(JSON.parse(raw));
if (text) {
process.stdout.write(
JSON.stringify({
hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: text },
})
);
}
} catch {
/* fail open: say nothing */
}
process.exit(0);
};
process.stdin.setEncoding("utf8");
process.stdin.on("data", (c) => {
raw += c;
if (raw.length > MAX_INPUT) {
raw = "";
finish();
}
});
process.stdin.on("end", finish);
process.stdin.on("error", finish);
setTimeout(finish, 2000).unref();
}
if (require.main === module) main();
module.exports = { hintFor };
+27 -5
View File
@@ -150,12 +150,27 @@ function makeHookEntry(hookType) {
return entry;
}
// The one hook that answers Claude: after a Claude Docs doc or a typed
// artifact (Slides, Design…) is published, it asks for a copy AgentHub can
// show to the people it is shared with. Synchronous (its answer is read), but
// only for these tools, so no other tool call waits on it.
const EXPORT_HINT = path.join(__dirname, "artifact-export-hint.js");
const EXPORT_HINT_MATCHER = "Artifact|mcp__claude_ai_.*__batch";
function makeExportHintEntry() {
return {
matcher: EXPORT_HINT_MATCHER,
hooks: [{ type: "command", command: `node "${EXPORT_HINT}"`, timeout: 5 }],
};
}
const OUR_SCRIPTS = ["hook-handler.js", "artifact-export-hint.js"];
const isOurCommand = (c) => typeof c === "string" && OUR_SCRIPTS.some((n) => c.includes(n));
function isOurEntry(entry) {
// Matches old format (entry.command) and new format (entry.hooks[].command)
if (entry.command && entry.command.includes("hook-handler.js")) return true;
if (Array.isArray(entry.hooks)) {
return entry.hooks.some((h) => h.command && h.command.includes("hook-handler.js"));
}
if (isOurCommand(entry.command)) return true;
if (Array.isArray(entry.hooks)) return entry.hooks.some((h) => isOurCommand(h.command));
return false;
}
@@ -221,7 +236,9 @@ function installHooks(silent = false) {
for (const hookType of HOOK_TYPES) {
if (!settings.hooks[hookType]) settings.hooks[hookType] = [];
const existing = settings.hooks[hookType].findIndex(isOurEntry);
const existing = settings.hooks[hookType].findIndex(
(e) => isOurEntry(e) && !JSON.stringify(e).includes("artifact-export-hint.js")
);
const entry = makeHookEntry(hookType);
if (existing >= 0) {
@@ -232,6 +249,11 @@ function installHooks(silent = false) {
installed++;
}
}
// The export hint is a second PostToolUse entry of ours, kept once.
const post = settings.hooks.PostToolUse;
const hint = post.findIndex((e) => JSON.stringify(e).includes("artifact-export-hint.js"));
if (hint >= 0) post[hint] = makeExportHintEntry();
else post.push(makeExportHintEntry());
const dir = path.dirname(getSettingsPath());
fs.mkdirSync(dir, { recursive: true });
+1 -1
View File
@@ -564,7 +564,7 @@ Request body shape:
| `GET` | `/api/ai-tools/job` | The current or last job (in memory), or `null`. |
| `GET` | `/api/ai-tools/versions` | npm-managed check and newest npm version per npm-installed tool, cached 24 h. The AI Tools modal no longer calls it: Update shows for every updater. |
| `GET` | `/api/ai-tools/:id/icon` | The server-cached favicon (`server/lib/ai-tool-icons.js`), raster only; `204` when there is none (a normal state — no console error per letter avatar), `404` for an unknown id. |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree unless `changes: "carry"` — `409 ECARRYCONFLICT` with `files` when git would overwrite one — or `changes: "shelve"`; answers the target branch's `shelves`), `branch-create` (never switches unless `switch: true`) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root workspace, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`; both also finish/abort an open cherry-pick or revert); `cherry-pick`/`revert` (`{sha}`: a non-merge commit, same guards and conflict flow as `merge`, `409 EEMPTY` for a no-op), `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) and `amend` (`{message?}`) — the last two only on an UNPUSHED HEAD (`409 EPUSHED`) that is still the request's `sha` (`409 ESTALE`), from a repo-root workspace; a terminal-started rebase blocks all history actions (`EMERGING`); `EMERGING` blocks pull/switch/merge/hunk/history actions mid-operation. `shelve` (`{note?, paths?}`: `stash push --include-untracked` under an `agenthub-shelf:` message), `unshelve` (`{sha}`: clean tree only, `apply --index`, dropped only when clean, a conflict keeps it), `shelf-drop` (`{sha, confirm:true}`) — repo root only, never mid-operation, only entries AgentHub made (`GET /api/lanes/:id/git/shelves` lists them); `409 EAGENTRUNNING` until `confirm:true` while the workspace's agent is working. No rebase/hard reset by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
| `POST` | `/api/lanes/:id/git/:action` | Same-origin guarded git write, serialized under the workspace lock: `fetch`, `pull` (always `--ff-only`), `push` (`-u origin HEAD`), `stage`/`unstage`/`discard` (per file; a path must be one git reports changed, and `discard` needs `confirm:true`), `stage-hunk`/`discard-hunk` (one hunk by its `@@` line, re-read fresh — `409 EHUNKGONE` when it moved; `discard-hunk` needs `confirm:true`), `commit` (`add -A` + `-m`, or `only:"staged"`), `switch` (refuses a dirty tree unless `changes: "carry"` — `409 ECARRYCONFLICT` with `files` when git would overwrite one — or `changes: "shelve"`; answers the target branch's `shelves`), `branch-create` (never switches unless `switch: true`) `branch-delete` (`-d`, refuses protected/current/busy branches) and `publish` (`{url}`: `remote add origin` + `push -u origin HEAD`, only when the repo has no remote; URL allowlisted to https-without-userinfo/ssh/scp-like; remote rolled back on a failed push). `merge` (clean tree, repo-root workspace, a ref that exists; conflicts come back as `conflicts[]`), `resolve` (`side: ours|theirs|done`, conflicted paths only, `done` refuses leftover markers), `merge-commit`, `merge-abort` (`confirm:true`; both also finish/abort an open cherry-pick or revert); `cherry-pick`/`revert` (`{sha}`: a non-merge commit, same guards and conflict flow as `merge`, `409 EEMPTY` for a no-op), `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) and `amend` (`{message?}`) — the last two only on an UNPUSHED HEAD (`409 EPUSHED`) that is still the request's `sha` (`409 ESTALE`), from a repo-root workspace; a terminal-started rebase blocks all history actions (`EMERGING`); `EMERGING` blocks pull/switch/merge/hunk/history actions mid-operation. `shelve` (`{note?, paths?}`: `stash push --include-untracked` under an `agenthub-shelf:` message), `unshelve` (`{sha}`: nothing staged and none of its own files changed — `400 EDIRTY` with `paths` —, other files' changes stay; `apply --index`, dropped only when clean, a conflict keeps it), `shelf-drop` (`{sha, confirm:true}`) — repo root only, never mid-operation, only entries AgentHub made (`GET /api/lanes/:id/git/shelves` lists them); `409 EAGENTRUNNING` until `confirm:true` while the workspace's agent is working. No rebase/hard reset by design. A remote wanting a login is `409 EGITAUTH` with `auth: {protocol, host, username, rejected}`; resend with `credentials: {protocol, host, username, password, remember}` (a repo remote's host only, else `400 EBADHOST`; saved only after git succeeds and `remember` is set). |
The route accepts `{ sourceRepo, title, base?, slug? }`. `sourceRepo` must be
an existing absolute git repository. The response workspace starts as
@@ -0,0 +1,66 @@
/**
* @file Tests for scripts/artifact-export-hint.js: when Claude is asked to save
* a viewable copy of a published Doc / typed artifact, and when it is not.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const path = require("node:path");
const { hintFor } = require("../../scripts/artifact-export-hint");
const PANE = { AGENTHUB_LANE_ID: "3" };
const docBirth = {
tool_name: "mcp__claude_ai_Claude_Docs__batch",
tool_input: { container: { kind: "project", create: { name: "Plan" } } },
tool_response: [{ type: "text", text: '{"frame":{"url":"https://claude.ai/artifact/doc-9"}}' }],
};
const slides = {
tool_name: "Artifact",
tool_input: { type_url: "https://claude.ai/types/slides", title: "Deck" },
tool_response: "Created https://claude.ai/artifact/deck-1",
};
describe("artifact export hint", () => {
it("asks for a Markdown copy of a new doc and a PDF/HTML copy of a typed artifact", () => {
assert.match(hintFor(docBirth, PANE), /artifacts-export\/doc-9\.md/);
const s = hintFor(slides, PANE);
assert.match(s, /artifacts-export\/deck-1\.pdf/);
assert.match(s, /artifacts-export\/deck-1\.html/);
});
it("stays silent everywhere else", () => {
assert.equal(hintFor(slides, {}), null, "not an AgentHub pane");
assert.equal(hintFor(slides, { ...PANE, AGENTHUB_ARTIFACT_EXPORT: "0" }), null, "turned off");
const html = { ...slides, tool_input: { file_path: "/w/page.html" } };
assert.equal(hintFor(html, PANE), null, "an HTML page is copied by AgentHub itself");
const edit = {
...docBirth,
tool_input: { container: { kind: "project", id: "p1" }, batch: [] },
};
assert.equal(hintFor(edit, PANE), null, "a later batch edits the doc");
assert.equal(hintFor({ ...slides, tool_response: "no link" }, PANE), null);
assert.equal(hintFor({ ...slides, tool_name: "ArtifactData" }, PANE), null);
assert.equal(hintFor(null, PANE), null);
});
it("prints Claude Code's PostToolUse context, and nothing on bad input", () => {
const script = path.join(__dirname, "..", "..", "scripts", "artifact-export-hint.js");
const run = (input, env = PANE) =>
spawnSync(process.execPath, [script], {
input,
env: { PATH: process.env.PATH, ...env },
encoding: "utf8",
timeout: 10_000,
});
const ok = run(JSON.stringify(slides));
assert.equal(ok.status, 0);
const out = JSON.parse(ok.stdout);
assert.equal(out.hookSpecificOutput.hookEventName, "PostToolUse");
assert.match(out.hookSpecificOutput.additionalContext, /deck-1/);
const bad = run("{not json");
assert.equal(bad.status, 0);
assert.equal(bad.stdout, "");
});
});
+303
View File
@@ -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");
@@ -874,6 +876,101 @@ describe("sharing and visibility", () => {
assert.equal(seen.unrecorded, 1);
});
it("shares with chosen people only: they see and list it, nobody else does", () => {
const addUser = db.prepare(
"INSERT OR IGNORE INTO users (id, username, role, password_hash, disabled) VALUES (?, ?, 'developer', 'x', ?)"
);
addUser.run(701, "owner701", 0);
addUser.run(702, "ann702", 0);
addUser.run(703, "bob703", 0);
addUser.run(704, "gone704", 1);
const as = (id) => ({ method: "GET", user: { id, role: "developer", username: `u${id}` } });
const id = Number(
db
.prepare(
"INSERT INTO artifacts (kind, title, owner_id, url) VALUES ('page', 'for-ann', 701, 'https://claude.ai/ann')"
)
.run().lastInsertRowid
);
process.env.AGENTHUB_MODE = "server";
try {
// Only enabled, existing users; the owner is dropped from the list.
assert.throws(() => artifacts.update(as(701), id, { sharedWith: [704] }), {
code: "EBADFIELD",
});
assert.throws(() => artifacts.update(as(701), id, { sharedWith: [99999] }), {
code: "EBADFIELD",
});
assert.throws(() => artifacts.update(as(701), id, { sharedWith: "702" }), {
code: "EBADFIELD",
});
// Nobody but its owner may choose who sees it.
assert.throws(() => artifacts.update(as(702), id, { sharedWith: [702] }), {
code: "ENOTFOUND",
});
const v = artifacts.update(as(701), id, { sharedWith: [702, 701, 702] });
assert.equal(v.shared, false, "never to everyone");
assert.deepEqual(
v.sharedWith.map((p) => p.id),
[702]
);
assert.equal(artifacts.canSeeId(as(702), id), true);
assert.equal(artifacts.canSeeId(as(703), id), false);
const ann = artifacts.list(as(702), { scope: "withme" });
assert.deepEqual(
ann.map((a) => a.title),
["for-ann"]
);
assert.equal(ann[0].sharedWithMe, true);
assert.deepEqual(ann[0].sharedWith, [], "a recipient does not see who else got it");
assert.ok(!artifacts.list(as(702), { scope: "hub" }).some((a) => a.id === id));
assert.ok(!artifacts.list(as(703)).some((a) => a.id === id));
assert.deepEqual(artifacts.list(as(703), { scope: "withme" }), []);
assert.deepEqual(artifacts.shareRecipients(id), [702]);
// People to pick from: enabled accounts but the caller, names only.
const people = artifacts.people(as(701));
assert.ok(people.some((p) => p.id === 702 && p.name === "ann702"));
assert.ok(!people.some((p) => p.id === 701 || p.id === 704));
assert.deepEqual(Object.keys(people[0]).sort(), ["id", "name", "username"]);
// A recipient disabled since is not listed, so it never blocks an edit.
artifacts.update(as(701), id, { sharedWith: [702, 703] });
db.prepare("UPDATE users SET disabled = 1 WHERE id = 703").run();
const listed = artifacts.view(
db.prepare("SELECT * FROM artifacts WHERE id = ?").get(id),
as(701)
);
assert.deepEqual(
listed.sharedWith.map((p) => p.id),
[702]
);
artifacts.update(as(701), id, { sharedWith: [702] });
db.prepare("UPDATE users SET disabled = 0 WHERE id = 703").run();
// Out of its owner's library it stays with Ann; taking Ann off deletes it.
assert.equal(artifacts.remove(as(701), id).kept, true);
assert.equal(artifacts.canSeeId(as(702), id), true);
const gone = artifacts.update(as(701), id, { sharedWith: [] });
assert.equal(gone.removed, true);
assert.equal(
db.prepare("SELECT 1 FROM artifact_shares WHERE artifact_id = ?").get(id),
undefined
);
const audited = db
.prepare("SELECT path FROM audit_log WHERE path LIKE '%for-ann%' ORDER BY id")
.all()
.map((r) => r.path);
assert.ok(
audited.some((a) => /shared with ann702/.test(a)),
audited.join(" | ")
);
} finally {
delete process.env.AGENTHUB_MODE;
}
});
it("refuses a cross-origin write", async () => {
const r = await request(
"POST",
@@ -1063,3 +1160,209 @@ describe("unowned rows", () => {
assert.equal(artifacts.list(null, { scope: "unowned" }).length, 0, "local mode has no owners");
});
});
describe("a page's own copy, viewable without its claude.ai account", () => {
it("copies the HTML file a page was published from, with the files it names", async () => {
const dir = path.join(ws, "pub");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "deck.html"), '<link href="style.css"><h1>v1</h1>');
fs.writeFileSync(path.join(dir, "style.css"), "h1{color:red}");
fs.writeFileSync(path.join(dir, ".env"), "SECRET=1");
const row = artifacts.captureFromHook("s1", {
cwd: ws,
tool_input: {
action: "publish",
file_path: "pub/deck.html",
files: { "style.css": "pub/style.css", ".env": "pub/.env", "x.txt": "/etc/hostname" },
},
tool_response: "Published: https://claude.ai/artifact/copy-1",
});
await Promise.all(artifacts.__pageCopies());
const kept = db.prepare("SELECT * FROM artifacts WHERE id = ?").get(row.id);
const files = JSON.parse(kept.files);
assert.equal(files[0].path, "deck.html", "the page itself first");
// A secret-looking file and one outside the workspace are never copied.
assert.deepEqual(
files.map((f) => f.path),
["deck.html", "style.css"]
);
const shot = artifacts.snapshotFile(null, row.id, "deck.html");
assert.match(fs.readFileSync(shot.abs, "utf8"), /v1/);
// A republish of the same link takes the new version, same row.
fs.writeFileSync(path.join(dir, "deck.html"), "<h1>v2</h1>");
const again = artifacts.captureFromHook("s1", {
cwd: ws,
tool_input: { action: "publish", file_path: path.join(dir, "deck.html") },
tool_response: "https://claude.ai/artifact/copy-1",
});
assert.equal(again.id, row.id);
await Promise.all(artifacts.__pageCopies());
assert.match(fs.readFileSync(artifacts.snapshotFile(null, row.id).abs, "utf8"), /v2/);
assert.ok(
!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", kept.snapshot)),
"the old copy is dropped"
);
});
it("never copies a file outside the workspace or one that is not HTML", async () => {
const outside = artifacts.captureFromHook("s1", {
cwd: ws,
tool_input: { action: "publish", file_path: "/etc/hosts.html" },
tool_response: "https://claude.ai/artifact/copy-out",
});
const typed = artifacts.captureFromHook("s1", {
cwd: ws,
tool_input: { action: "publish", file_path: "main.js", type_url: "https://claude.ai/t" },
tool_response: "https://claude.ai/artifact/copy-typed",
});
await Promise.all(artifacts.__pageCopies());
for (const r of [outside, typed]) {
assert.equal(
db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(r.id).snapshot,
null
);
}
});
// 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(
db
.prepare(
"INSERT INTO artifacts (kind, title, lane_id, source, url) VALUES ('page', 'old', ?, ?, 'https://claude.ai/artifact/old-1')"
)
.run(lane.id, path.join(ws, "pub", "old.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),
["old.html"]
);
});
it("attaches an exported copy of a Doc or Slides to its page, not as a second card", async () => {
const page = artifacts.captureFromHook("s1", {
tool_input: { action: "publish", type_url: "https://claude.ai/types/slides" },
tool_response: "https://claude.ai/artifact/deck-77",
});
fs.mkdirSync(path.join(ws, "artifacts-export"), { recursive: true });
fs.writeFileSync(path.join(ws, "artifacts-export", "deck-77.pdf"), "%PDF-1.4 deck");
fs.writeFileSync(path.join(ws, "artifacts-export", "nobody-1.md"), "# not ours");
const before = db.prepare("SELECT COUNT(*) AS n FROM artifacts").get().n;
const done = await artifacts.captureOutputs("s1");
assert.ok(done.some((r) => r.id === page.id));
const kept = db.prepare("SELECT * FROM artifacts WHERE id = ?").get(page.id);
assert.deepEqual(
JSON.parse(kept.files).map((f) => f.path),
["deck-77.pdf"]
);
assert.ok(
!done.some((r) => /artifacts-export/.test(r.source || "")),
"an export never becomes a file card"
);
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM artifacts").get().n, before);
// Unchanged: not taken again.
assert.ok(!(await artifacts.captureOutputs("s1")).some((r) => r.id === page.id));
});
it("turns the copy hint off for new panes", async () => {
assert.equal(artifacts.exportsEnabled(), true);
const r = await request("PUT", "/api/artifacts/settings", { exportsEnabled: false });
assert.equal(r.status, 200, r.text);
assert.equal(r.body.exportsEnabled, false);
assert.equal(r.body.installsEnabled, true, "installs untouched");
assert.equal(
(await request("PUT", "/api/artifacts/settings", { exportsEnabled: "no" })).status,
400
);
artifacts.setExportsEnabled(true);
});
});
@@ -0,0 +1,66 @@
/**
* @file Regression: transcript lookups in claude-home.js find a file by
* `stat`, never `fs.existsSync`. existsSync is access(2), which the kernel runs
* with the capabilities cleared, so under S3 it reported a member's live
* transcript (behind their 0700 `~/.claude/projects`) as missing and the chat
* served the stale import-time snapshot — a whole turn shown in the terminal
* never reached the chat. Here `fs.existsSync` is stubbed to answer "no" for
* everything under the temp Claude home, as access(2) does for the unit.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const fs = require("node:fs");
const path = require("node:path");
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-access-blind-"));
const SAVED_HOME = process.env.CLAUDE_HOME;
process.env.CLAUDE_HOME = ROOT;
const ch = require("../lib/claude-home");
const SID = "11111111-2222-3333-4444-555555555555";
const CWD = "/work/demo-repo";
const PROJECT = path.join(ROOT, "projects", ch.encodeCwd(CWD));
const MAIN = path.join(PROJECT, `${SID}.jsonl`);
const SUB = path.join(PROJECT, SID, "subagents", "agent-abc.jsonl");
const NESTED = path.join(PROJECT, SID, "subagents", "workflows", "wf_1", "agent-xyz.jsonl");
const realExists = fs.existsSync;
describe("claude-home transcript lookups when access(2) is blind", () => {
before(() => {
for (const f of [MAIN, SUB, NESTED]) {
fs.mkdirSync(path.dirname(f), { recursive: true });
fs.writeFileSync(f, "{}\n");
}
fs.existsSync = (p) => (String(p).startsWith(ROOT) ? false : realExists(p));
});
after(() => {
fs.existsSync = realExists;
if (SAVED_HOME === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = SAVED_HOME;
fs.rmSync(ROOT, { recursive: true, force: true });
});
it("getTranscriptPath finds the live main transcript", () => {
assert.equal(ch.getTranscriptPath(SID, CWD), MAIN);
});
it("findTranscriptPath finds it by scanning", () => {
assert.equal(ch.findTranscriptPath(SID, CWD), MAIN);
});
it("subagent transcripts (flat and nested Workflow) are found", () => {
assert.equal(ch.findSubagentTranscriptPath(SID, "abc", null, CWD), SUB);
assert.equal(ch.findSubagentTranscriptPath(SID, "xyz", "wf_1", CWD), NESTED);
assert.equal(ch.findSubagentTranscriptPath(SID, "xyz", null, CWD), NESTED);
});
it("a missing transcript is still reported missing", () => {
assert.equal(ch.getTranscriptPath("no-such-session", CWD), null);
assert.equal(ch.exists(path.join(ROOT, "nope")), false);
});
});
+180
View File
@@ -0,0 +1,180 @@
/**
* @file collab-runs.test.js
* @description A session started by a user a workspace is shared with
* (sharing phase 2, pty-run.js spawnRun `startedBy`): it runs on a pool
* setup-token of THEIR OWN through tmux `-e` (never the owner's pick, never a
* login), is refused with `ENOCOLLABACCOUNT` when they have none, records
* `started_by`, makes them the payer of the new session from its first hour
* (session-payers.js), points its hooks at their own quota flag and is
* refused while they are over their quota. The owner's own start is unchanged.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "collab-runs-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "claude");
const { db } = require("../db");
const ccUsage = require("../lib/cc-usage");
const pool = require("../lib/claude-pool");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
const realFetchUsage = ccUsage.fetchUsage;
const realFetchOwner = ccUsage.fetchOwner;
let argv = null;
let alice;
let bob;
let lane;
const envOf = (name) => {
const hit = (argv || []).find((x) => String(x).startsWith(`${name}=`));
return hit ? String(hit).slice(name.length + 1) : undefined;
};
before(() => {
ccUsage.fetchUsage = async () => {
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
};
ccUsage.fetchOwner = async () => null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
if (args[0] === "new-session") argv = args;
return "";
});
const user = (name) =>
Number(
db
.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', 'developer')")
.run(name).lastInsertRowid
);
alice = user("alice");
bob = user("bob");
lane = Number(
db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)").run(TMP, alice)
.lastInsertRowid
);
db.prepare("INSERT INTO lane_shares (lane_id, user_id) VALUES (?, ?)").run(lane, bob);
process.env.AGENTHUB_MODE = "server";
});
after(() => {
delete process.env.AGENTHUB_MODE;
ccUsage.fetchUsage = realFetchUsage;
ccUsage.fetchOwner = realFetchOwner;
tmux.__setExecImpl(null);
fs.rmSync(TMP, { recursive: true, force: true });
});
beforeEach(() => {
fs.rmSync(pool.poolDir(), { recursive: true, force: true });
db.prepare("DELETE FROM dashboard_runs").run();
db.prepare("DELETE FROM session_payers").run();
db.prepare("DELETE FROM account_pins").run();
db.prepare("DELETE FROM user_quotas").run();
pool.__quotaCache.clear();
argv = null;
});
describe("a collaborator's own session", () => {
it("is refused without an account of their own, never run on the owner's", () => {
assert.throws(() => pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", startedBy: bob }), {
code: "ENOCOLLABACCOUNT",
});
assert.equal(argv, null, "no pane was started");
// The owner's own start is as before.
pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude" });
const row = db.prepare("SELECT started_by FROM dashboard_runs").get();
assert.equal(row.started_by, null);
});
it("runs on their own pool token, records them and makes them its payer", () => {
pool.addAccount("theirs", TOKEN("b"));
pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", startedBy: bob, slot: 1 });
assert.equal(envOf("CLAUDE_CODE_OAUTH_TOKEN"), TOKEN("b"));
assert.ok(argv.indexOf(`CLAUDE_CODE_OAUTH_TOKEN=${TOKEN("b")}`) < argv.indexOf("--"));
// Their own quota flag, never the owner's.
assert.match(envOf("AGENTHUB_QUOTA_FILE"), new RegExp(`u${bob}\\.json$`));
const run = db
.prepare("SELECT session_id, started_by, account FROM dashboard_runs WHERE id = ?")
.get(`agenthub-lane-${lane}-1`);
assert.equal(run.started_by, bob);
assert.equal(run.account, "theirs");
assert.deepEqual(
db.prepare("SELECT session_id, from_hour, user_id FROM session_payers").all(),
[{ session_id: run.session_id, from_hour: "0000", user_id: bob }]
);
});
it("starts nothing while they are over their own quota — and the owner still starts", () => {
pool.addAccount("theirs", TOKEN("b"));
const MODEL = "claude-sonnet-4-5-20250929";
const hour = new Date().toISOString().slice(0, 13);
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, updated_at) VALUES ('sBob', 'b', 'active', ?, ?, ?)"
).run(TMP, lane, new Date().toISOString());
db.prepare(
"INSERT INTO token_usage (session_id, model, output_tokens) VALUES ('sBob', ?, 10000000)"
).run(MODEL);
db.prepare(
"INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens) VALUES ('sBob', ?, ?, 10000000)"
).run(hour, MODEL);
db.prepare("INSERT INTO session_payers VALUES ('sBob', '0000', ?)").run(bob);
const q = require("../lib/user-quota");
for (const id of [alice, bob]) {
q.setUserQuota(id, { mode: "custom", limit_usd: 1, period: "5h", tz_offset: 0 });
}
assert.throws(() => pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", startedBy: bob }), {
code: "EQUOTA",
});
// Those hours are bob's money, not alice's.
pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", slot: 2 });
assert.ok(argv, "the owner's pane started");
});
it("never resumes a non-Claude session across payers, nor the latest one for a collaborator", () => {
pool.addAccount("theirs", TOKEN("b"));
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, tool) VALUES ('sCodexAAA', 'a', 'completed', ?, ?, 'codex')"
).run(TMP, lane);
assert.throws(
() =>
pty.spawnRun({
laneId: lane,
cwd: TMP,
tool: "codex",
resumeSessionId: "sCodexAAA",
startedBy: bob,
}),
{ code: "ECOLLABRESUME" }
);
// Nor the owner a session the collaborator pays for.
db.prepare("INSERT INTO session_payers VALUES ('sCodexBBB', '0000', ?)").run(bob);
assert.throws(
() => pty.spawnRun({ laneId: lane, cwd: TMP, tool: "codex", resumeSessionId: "sCodexBBB" }),
{ code: "ECOLLABRESUME" }
);
assert.throws(
() =>
pty.spawnRun({ laneId: lane, cwd: TMP, tool: "claude", resumeLast: true, startedBy: bob }),
{ code: "ECOLLABRESUME" }
);
});
it("keeps a Terminal tab's session the owner's money", () => {
const payers = require("../lib/session-payers");
db.prepare(
`INSERT INTO dashboard_runs (id, session_id, mode, cwd, status, lane_id, tool, started_by)
VALUES ('agenthub-lane-shell', 'sShell', 'interactive', ?, 'running', ?, 'shell', ?)`
).run(TMP, lane, bob);
payers.noteRun("agenthub-lane-shell");
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM session_payers").get().n, 0);
});
});
+34 -3
View File
@@ -247,17 +247,48 @@ describe("shelves", () => {
await assert.rejects(act("shelve", {}), { code: "EMERGING" });
});
it("restores staged changes staged, and refuses a dirty tree", async () => {
it("restores staged changes staged, and refuses one of its files changed", async () => {
write("a.txt", "staged\n");
g("add", "a.txt");
const { shelf } = await act("shelve", {});
write("sub/s.txt", "new work\n");
write("a.txt", "new work\n");
await assert.rejects(act("unshelve", { sha: shelf.sha }), { code: "EDIRTY" });
g("checkout", "--", "sub/s.txt");
g("checkout", "--", "a.txt");
await act("unshelve", { sha: shelf.sha });
assert.equal(g("diff", "--cached", "--name-only").trim(), "a.txt");
});
it("restores beside changes in other files, never over its own or a staged index", async () => {
// A partial Shelve (n): the unticked files stay behind and must not block
// the restore.
write("a.txt", "wip\n");
write("sub/new.txt", "new\n");
write("left.txt", "left behind\n");
const { shelf } = await act("shelve", { paths: ["a.txt", "sub/new.txt"] });
assert.equal(read("left.txt"), "left behind\n");
// Its own file changed since: refused, with the file named.
write("a.txt", "other\n");
await assert.rejects(act("unshelve", { sha: shelf.sha }), (e) => {
assert.equal(e.code, "EDIRTY");
assert.deepEqual(e.paths, ["a.txt"]);
return true;
});
g("checkout", "--", "a.txt");
// Anything staged: refused, nothing written.
write("b.txt", "staged\n");
g("add", "b.txt");
await assert.rejects(act("unshelve", { sha: shelf.sha }), { code: "EDIRTY" });
assert.equal(fs.existsSync(path.join(REPO, "sub/new.txt")), false);
g("rm", "-q", "--cached", "b.txt");
const r = await act("unshelve", { sha: shelf.sha });
assert.equal(r.kept, false);
assert.equal(read("a.txt"), "wip\n");
assert.equal(read("sub/new.txt"), "new\n");
assert.equal(read("left.txt"), "left behind\n");
assert.equal(read("b.txt"), "staged\n");
assert.equal((await listShelves(REPO)).length, 0);
});
it("keeps a conflicted shelf and lets `resolve` settle its files", async () => {
write("shared.txt", "mine\n");
const { shelf } = await act("shelve", {});
+69
View File
@@ -0,0 +1,69 @@
/**
* @file hub-boundary.test.js
* @description `server/hub/` (the remote-clients hub, server mode only) is
* loaded only behind `isServerMode()`: no file outside it may require it,
* except `server/index.js` on lines that test the mode themselves. And a
* local-mode app really leaves it out — no module under `server/hub/` is in
* the require cache after `createApp()` in local mode.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const fs = require("node:fs");
const os = require("node:os");
const ROOT = path.join(__dirname, "..", "..");
const HUB = path.join(ROOT, "server", "hub");
const SCAN = ["server", "bin", "scripts", "mcp", "statusline"];
const REQUIRE_HUB = /require\(\s*["'`][^"'`]*\bhub(?:\/[^"'`]*)?["'`]\s*\)/;
function jsFiles(dir) {
const out = [];
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (["node_modules", "__tests__", "dist"].includes(e.name) || full === HUB) continue;
out.push(...jsFiles(full));
} else if (/\.(c|m)?js$/.test(e.name)) {
out.push(full);
}
}
return out;
}
describe("server/hub boundary", () => {
it("nothing outside server/hub requires it, except index.js behind isServerMode()", () => {
const offenders = [];
for (const top of SCAN) {
const dir = path.join(ROOT, top);
if (!fs.existsSync(dir)) continue;
for (const file of jsFiles(dir)) {
const lines = fs.readFileSync(file, "utf8").split("\n");
lines.forEach((line, i) => {
if (!REQUIRE_HUB.test(line)) return;
const rel = path.relative(ROOT, file);
if (rel === path.join("server", "index.js") && /isServerMode\(\)/.test(line)) return;
offenders.push(`${rel}:${i + 1}: ${line.trim()}`);
});
}
}
assert.deepEqual(offenders, []);
});
it("a local-mode app loads no module of server/hub", () => {
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "hub-boundary-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, ".claude");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
delete process.env.AGENTHUB_MODE;
try {
require("../index").createApp();
const loaded = Object.keys(require.cache).filter((k) => k.startsWith(HUB + path.sep));
assert.deepEqual(loaded, []);
} finally {
fs.rmSync(TMP, { recursive: true, force: true });
}
});
});

Some files were not shown because too many files have changed in this diff Show More