Compare commits

..

14 Commits

Author SHA1 Message Date
nntrivi2001 8a6dc6a29e chore: v2.8.14 — Artifacts storefront and Uninstall, terminal chat for every tool, Discard all 2026-10-21 16:10:30 +07:00
nntrivi2001 c042519bc9 feat(artifacts): storefront layout and Uninstall for installed skills/plugins
- Artifacts page laid out like an app store: hero search, category chips,
  shelves by kind, app-icon cards with one primary pill and a detail sheet
- POST /api/artifacts/:id/uninstall: a skill's copies in ~/.claude/skills and
  ~/.agents/skills are moved to ~/.claude/agenthub-replaced-skills/
  (-uninstalled-<time>), never deleted; a plugin runs
  `claude plugin uninstall --scope user --keep-data` as the caller;
  ENOTINSTALLED when nothing is there; allowed while installs are off
- Uninstall on the card, the ⋯ menu and the detail sheet, after a confirm
2026-10-21 16:10:30 +07:00
nntrivi2001 383b9ae231 feat(chat): terminal chat parity for every tool; Desktop-like artifacts
- The chat over a terminal pane now covers Claude, Codex, omp, Cursor and
  Antigravity: Cursor's agent transcript and agy's step log
  (brain/<id>/.system_generated/logs/transcript_full.jsonl) are read as
  conversations; Cursor sessions are claimed by their workspace
- Replies appear as soon as they are written: the projects-tree watcher
  broadcasts `transcript_changed` (at most every 250 ms per session) and the
  chat re-reads the transcript while the terminal underneath draws
- Stop shows from the pane's own busy line (Claude/Codex `esc to interrupt`,
  agy `… esc to cancel`), or fresh output for omp/Cursor — no hooks needed
- Permission prompts read off the terminal become a card with the tool, its
  command and "Deny with a note"; Codex gets Permission mode / Model buttons
  that open its own numbered pickers, answered from the menu card
- Artifacts: cards for pages/documents/diagrams/tables/React components, a
  Preview | Code panel with versions, copy, download, save to the Artifacts
  Hub, full screen; sandboxed iframes without allow-same-origin
- Claude panes get CLAUDE_CODE_ENABLE_TODO_TOOLS=1 so the Progress panel has a
  plan; multi-question pane menus read their Next row and question tabs
- The realtime chat bridge (structured stdio, /api/chat-bridge, /ws-chat)
  from the unreleased ad9d475 is not kept
2026-10-21 15:44:17 +07:00
nntrivi2001 aee4fc190e feat: Discard all, claude-style image pastes, large local drops, per-account / list
- Changes panel: **Discard all** beside Commit discards every non-conflicted
  file in one `discard`, after a dialog naming how many untracked files go.
- Terminal: a `claude` pane gets dropped/pasted images as bare paths (shown as
  `[Image #n]`) and other files as `@` mentions (`claudePastes`); a local
  browser's copied `file://` paths and text-drag file lists paste by path.
- `POST /api/run/drop-file`: a loopback browser outside server mode may drop
  up to 2 GB, streamed to disk; remote stays 20 MB.
- cc-config skills/commands/plugins read the USER scope of the account the
  pane runs as (S3 member home by `cwd`), never another user's or the host's
  for a non-admin without an account; the run form passes its cwd to plugins.
2026-10-21 15:44:06 +07:00
nntrivi2001 0547488f13 chore: v2.8.13 — Artifacts reinstall/edit/installs, agent steps in chat, pick a local file to attach, Windows paths as links
- Chat: an Agent step shows its task, status and tool calls and opens its conversation in place; Windows paths (E:\…, C:/…) in replies open as files
- Prompt: attach a file on this computer by path (native picker), clearer too-large messages
- Artifacts: edit title/description and a skill's files (new snapshot), Reinstall, install history, Hub-only rows, skills installed into ~/.claude/skills and ~/.agents/skills
- Files: batch upload of small files in one extract
- AI Tools: npm install falls back to --prefix ~/.local when the global prefix is not writable
2026-10-20 16:44:55 +07:00
nntrivi2001 49f87c4da3 chore: v2.8.12 — clone queue and progress, session defaults, chat agents, drops as @ mentions
Whole tree from parallel sessions, plus the version bump:
- Clone: a queue with live progress (Add workspace and extra folders),
  clones run under nice/ionice so the machine stays responsive.
- Settings → AI tools → Session defaults: a new session's default
  permission mode / bypass per account.
- Chat lists the background subagents that have not reported back yet.
- A file pasted or dropped into a claude pane goes in as an @ mention.
- Changes panel: say why amend/undo is refused on a pushed commit.
- openapi.yaml regenerated (Artifacts, Host, resumeById).
2026-10-19 17:19:41 +07:00
nntrivi2001 1a6b550e89 feat(files): New → Clone repository clones into the open folder
The File Explorer's New menu (and its right-click menu) gains Clone
repository: it clones a URL into the folder it has open — of the user's own
home or of a workspace's own folder — through POST /api/lanes/clone with
that folder as `parent`, so the URL allowlist, the own-folder check and the
folder account's own git identity all apply, and opens a workspace on the
clone. ECREDENTIALS asks for a login, ESSHAUTH points to Settings → Git.
GET /api/homes now gives `me` its absolute `dir`. Keys typed in the dialog
never reach the explorer's shortcuts (Backspace / Delete).
2026-10-19 17:13:26 +07:00
nntrivi2001 f8d07dab61 feat(ai-tools): omp shared + OpenCode/omp sessions, tokens, subagents; Grok hooks; resume by id
- omp-usage.js / opencode-usage.js: poll each CLI's own session store into
  sessions (tool omp/opencode), token_usage per model and subagent agents;
  transcript routes read them (omp journals, OpenCode's opencode.db).
- Grok runs the Claude hooks in ~/.claude/settings.json: its hooks are now
  recognised (hookEventName + workspaceRoot), tagged tool=grok, and their
  transcript_path dropped instead of being parsed as a Claude transcript.
- Catalog `resumeById`: omp/Grok/Cursor `--resume <id>`, OpenCode
  `--session <id>`; session ids may carry `_`. The client offers Resume for
  any tool that resumes by id.
- Kimi Code and GitHub Copilot leave the catalog (their session stores could
  not be verified).
2026-10-19 16:49:59 +07:00
nntrivi2001 0434ffc750 fix(tmux): scrolling a pane no longer freezes typing
The wheel puts a pane into tmux's copy-mode, which took every typed key as
a scrollback command and painted a yellow [0/0] box in the corner. In
AgentHub's own panes (every other session on the server keeps tmux's
defaults, restored verbatim):

- the copy-mode position box is off (copy-mode-position-format "");
- the first keystroke after a mouse report leaves copy-mode
  (`copy-mode -q`, pty-attach) before it is written, once per scroll;
- copy-mode keys that open a tmux prompt (g, :, /, f, digits…) leave
  copy-mode and reach the program;
- the wheel scrolls 10 lines a notch instead of 5, and the first notch
  already scrolls instead of only entering copy-mode.
2026-10-19 11:59:22 +07:00
nntrivi2001 e2d8d314f6 feat(workspace): check the git identity before cloning extra folders
The workspace's "Or clone repositories" box now reads GET /api/me/git and
checks every URL as it is typed: an SSH link while the account has no SSH
key is never sent and points to Settings → Git (make a key, add its public
key to the host); an HTTPS host with no saved login is flagged with an
optional login row, since a public repo needs none. The identity is re-read
when the window regains focus, and a refused SSH login links to the same
settings. The server's ECREDENTIALS / ESSHAUTH answers still decide.
2026-10-19 11:54:49 +07:00
nntrivi2001 0c8782ea75 chore: v2.8.11 — switch a pool account's users from its Edit 2026-10-19 11:03:54 +07:00
nntrivi2001 c042adc51b ui(pool): pick an account's users from its own Edit
Each account row in the Account pool card has a pencil: tick the users
whose sessions run on it (one moves off any other account, unticking sends
one back to the balancer). PUT /api/accounts/:name/users saves the set in one
transaction, audits each change and switches a Claude login into each added
user's home at once. The card-level switch button and pinned list are gone.
2026-10-19 10:45:54 +07:00
nntrivi2001 776ad6466f feat(pool): an admin's switch moves a user's Claude login at once
PUT /api/accounts/pins (and agenthub accounts pin) now writes a pinned
Claude login into the user's home right away (claude-pool.switchHome):
every home's newer pair is copied back first, the member's own login is
never written over, and no pane is restarted — running sessions take the
new pair at their next credentials read. A live home stays on the login it
holds, so unpinning does not flip it back. The Users page calls it
"Switch a user's account" and says what happened.
2026-10-19 10:23:44 +07:00
nntrivi2001 9c98638ec0 feat(pool): pin a user to one Claude/Codex pool account
An admin pins a server-mode user to one pool account per tool
(account_pins; PUT /api/accounts/pins with the password step-up and audit,
the Users page's Pin a user, agenthub accounts pin|unpin). The workspace
owner's pin wins over the balancer whenever the pane may use the account;
a pinned login still waits while another pool login holds the home.
2026-10-19 10:05:19 +07:00
165 changed files with 14828 additions and 1505 deletions
+10 -9
View File
File diff suppressed because one or more lines are too long
+87 -6
View File
@@ -2855,8 +2855,8 @@ const COMMAND_GROUPS = [
],
[
"accounts",
"list|add|remove [name] [--tool codex]",
"Claude/Codex account pool: new sessions spread over these accounts",
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex]",
"Claude/Codex account pool: new sessions spread over these accounts; pin = one user always on one",
],
["repl", "", "Open the interactive shell (also: shell, i)"],
],
@@ -3814,7 +3814,7 @@ const SUBCOMMANDS = {
service: ["install", "uninstall", "status"],
server: ["setup", "off", "status"],
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
accounts: ["list", "add", "remove"],
accounts: ["list", "add", "remove", "pin", "unpin"],
pricing: ["set", "delete", "reset"],
webhooks: ["test"],
import: ["rescan", "path"],
@@ -4286,7 +4286,18 @@ async function cmdCodexAccounts(sub, name, flags) {
}
pool.resetQuota();
table(
["codex account", "kind", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota", "state"],
[
"codex account",
"kind",
"live",
"$ 5h",
"$ 7d",
"tok 5h",
"tok 7d",
"quota",
"state",
"pinned",
],
pool
.poolStatus()
.map((a) => [
@@ -4301,10 +4312,64 @@ async function cmdCodexAccounts(sub, name, flags) {
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
: "–",
a.full ? "full" : "ok",
pinnedUsers("codex", a.name),
])
);
}
/**
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex] —
* that user's new sessions run on that account (server/lib/account-pins.js).
*/
async function cmdAccountPin(sub, positional, tool) {
const pins = require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"));
const [first, second] = positional;
const username = sub === "pin" ? second : first;
if (!username || (sub === "pin" && !first)) {
throw new Error("usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex]");
}
const user = require(path.join(REPO_ROOT, "server", "db.js"))
.db.prepare("SELECT id FROM users WHERE username = ?")
.get(username);
if (!user) throw new Error(`no user ${username}`);
const pin = pins.setPin(user.id, tool, sub === "pin" ? first : null);
if (!require(path.join(REPO_ROOT, "server", "lib", "auth.js")).isServerMode()) {
console.log(c.dim(" Pins apply in server mode only."));
}
if (!pin.account) {
console.log(`${c.green("✔")} ${pin.username} unpinned — new ${tool} sessions use the balancer`);
return;
}
const switched = await pins.applyPin(user.id, tool);
console.log(`${c.green("✔")} ${pin.username}'s ${tool} sessions run on ${pin.account}`);
console.log(c.dim(` ${SWITCH_NOTE[switched] || SWITCH_NOTE["next-session"]}`));
}
/** What `applyPin` did, for the CLI. */
const SWITCH_NOTE = {
switched: "Switched now: running sessions take it at their next login check, new ones at once.",
kept: "Their home already holds it.",
"own-login":
"They signed in with their own login, which is left alone: no pool login is written over it.",
expired: "That login has expired: new sessions fall back to the balancer until it is re-added.",
"next-session": "New sessions use it; running ones keep their account until they end.",
};
/** The `pinned` column: who an admin pinned to `account`. */
function pinnedUsers(tool, account) {
try {
return (
require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"))
.listPins(tool)
.filter((p) => p.account === account)
.map((p) => p.username)
.join(",") || "–"
);
} catch {
return "–";
}
}
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
async function cmdAccounts(sub, positional, flags = {}) {
serverAuth(); // the same settings + database the server reads
@@ -4316,6 +4381,8 @@ async function cmdAccounts(sub, positional, flags = {}) {
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
throw new Error("--tool is claude (the default) or codex");
}
if (sub === "pin" || sub === "unpin")
return cmdAccountPin(sub, positional, flags.tool || "claude");
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
if (sub === "add") {
console.log(
@@ -4337,7 +4404,18 @@ async function cmdAccounts(sub, positional, flags = {}) {
await pool.refreshQuotas({ force: true });
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
table(
["account", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota 5h", "quota wk", "state"],
[
"account",
"live",
"$ 5h",
"$ 7d",
"tok 5h",
"tok 7d",
"quota 5h",
"quota wk",
"state",
"pinned",
],
pool
.poolStatus()
.map((a) => [
@@ -4356,6 +4434,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
: a.quota?.error
? `no quota (${a.quota.error})`
: "ok",
pinnedUsers("claude", a.name),
])
);
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
@@ -4363,7 +4442,9 @@ async function cmdAccounts(sub, positional, flags = {}) {
return;
}
console.error(
c.red("✖ Usage: agenthub accounts list | add <name> | remove <name> [--tool codex]")
c.red(
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex]"
)
);
process.exitCode = 1;
}
+4
View File
@@ -165,12 +165,15 @@ export function MenuItem({
danger,
disabled,
testId,
title,
children,
}: {
onSelect: () => void;
danger?: boolean;
disabled?: boolean;
testId?: string;
/** Hover text — e.g. why a disabled item cannot run. */
title?: string;
children: ReactNode;
}) {
return (
@@ -179,6 +182,7 @@ export function MenuItem({
role="menuitem"
data-testid={testId}
disabled={disabled}
title={title}
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
onClick={onSelect}
>
+5 -1
View File
@@ -34,6 +34,7 @@ import {
chooseRunOption,
interruptRun,
readRunMenu,
stepRunMenu,
submitToRun,
type PaneMenu,
} from "../../lib/panePaste";
@@ -318,7 +319,7 @@ export function PetChat({
const r = await startInFreeTabDetailed(lane.id, {
prompt,
...(tool !== lane.tool ? { tool } : {}),
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}),
});
remember({ laneId: r.laneId, runId: r.runId, tool });
} catch (err) {
@@ -505,6 +506,9 @@ export function PetChat({
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
else setError(t("sagi.chat.notSent"));
}}
onStep={(dir) => {
if (!stepRunMenu(chat.runId, dir)) setError(t("sagi.chat.notSent"));
}}
onCancel={() => {
if (interruptRun(chat.runId)) setMenu(null);
else setError(t("sagi.chat.notSent"));
@@ -14,6 +14,7 @@
* and tick the ones to add; or type `name@marketplace` by hand, which is
* also where a link the server cannot read (ssh, a private repo) ends up.
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
* - `SkillNameField`/`splitSkillMd` — also used by the page's skill editor.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -24,8 +25,8 @@ import { api, type MarketplaceInfo } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { toast } from "../../lib/toast";
const field = "w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
const label = "block text-[11px] font-medium text-fg-secondary";
const field = "input w-full";
const label = "block text-xs text-fg-secondary";
const hint = "text-[11px] text-fg-muted";
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
@@ -155,8 +156,19 @@ function Tabs<T extends string>({
);
}
/** SKILL.md split for editing: its frontmatter `name`/`description` and the body. */
export function splitSkillMd(text: string): { name: string; description: string; body: string } {
const clean = text.replace(/^\uFEFF/, "");
const fm = /^---\r?\n[\s\S]*?\r?\n---[^\n]*(\r?\n|$)/.exec(clean);
return {
name: frontmatterField(clean, "name"),
description: frontmatterField(clean, "description"),
body: (fm ? clean.slice(fm[0].length) : clean).replace(/^\s+/, ""),
};
}
/** The name field with the folder/command it becomes. */
function SkillNameField({
export function SkillNameField({
value,
onChange,
disabled,
@@ -175,7 +187,7 @@ function SkillNameField({
{t("skill.name")}
{required ? " *" : ""}
<input
className={`${field} mt-0.5`}
className={`${field} mt-1`}
value={value}
disabled={disabled}
placeholder="release-notes"
@@ -296,7 +308,7 @@ export function SkillAddPanel({
<label className={label}>
{t("skill.description")}
<textarea
className={`${field} mt-0.5`}
className={`${field} mt-1`}
rows={2}
value={description}
disabled={many}
@@ -310,7 +322,7 @@ export function SkillAddPanel({
<label className={label}>
{t("form.title")}
<input
className={`${field} mt-0.5`}
className={`${field} mt-1`}
value={title}
disabled={many}
onChange={(e) => setTitle(e.target.value)}
@@ -452,7 +464,10 @@ export function SkillAddPanel({
/>
)}
{meta}
<div className="flex justify-end">
<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="submit"
className="btn-primary text-sm"
@@ -489,7 +504,7 @@ export function SkillAddPanel({
<label className={label}>
{t("skill.description")} *
<textarea
className={`${field} mt-0.5`}
className={`${field} mt-1`}
rows={2}
value={description}
placeholder={t("skill.descriptionHint")}
@@ -499,7 +514,7 @@ export function SkillAddPanel({
<label className={label}>
{t("skill.content")} *
<textarea
className={`${field} mt-0.5 font-mono text-xs`}
className={`${field} mt-1 font-mono text-xs`}
rows={10}
value={content}
placeholder={t("skill.contentHint")}
@@ -509,12 +524,15 @@ export function SkillAddPanel({
<label className={label}>
{t("form.title")}
<input
className={`${field} mt-0.5`}
className={`${field} mt-1`}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<div className="flex justify-end">
<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="submit"
className="btn-primary text-sm"
@@ -534,7 +552,7 @@ export function SkillAddPanel({
{mode === "existing" && (
<div className="space-y-2">
<select
className={field}
className="select w-full"
aria-label={t("form.workspace")}
value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
@@ -696,7 +714,7 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
>
<label className={label}>
{t("plugin.link")}
<div className="mt-0.5 flex gap-2">
<div className="mt-1 flex gap-2">
<input
className={field}
value={link}
@@ -777,7 +795,15 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
</li>
))}
</ul>
<div className="flex justify-end">
<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"
@@ -853,7 +879,10 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="flex justify-end">
<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="submit"
className="btn-primary text-sm"
@@ -902,7 +931,7 @@ export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
<input
type="file"
data-testid="file-upload"
className="mt-0.5 block w-full text-xs text-fg-secondary"
className="mt-1 block w-full text-xs text-fg-secondary file:mr-3 file:cursor-pointer file:rounded-lg file:border file:border-border-light file:bg-surface-2 file:px-3 file:py-1.5 file:text-xs file:text-fg-primary hover:file:bg-surface-4"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
@@ -0,0 +1,276 @@
/**
* @file AgentDetails.tsx
* @description What the chat shows of a subagent, like Claude Desktop: an
* Agent/Task call's facts (type, model, status, time, tokens, tool calls),
* the task it was given and the report it returned — read off the call, its
* result (`tool_result.agent`) and, for a background agent, the
* `<task-notification>` that closed it — and, one click further, the
* subagent's own conversation as a small chat right there in the step, every
* step of it. The chat's owner (ConversationView) supplies the links through
* `AgentContext`: which agent transcript a call launched, the notifications
* seen, and how to render one agent's conversation (a nested
* ConversationView — passed in, so this module never imports it back).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { createContext, useContext, useState } from "react";
import { useTranslation } from "react-i18next";
import { CheckCircle2, ChevronRight, CircleSlash, Loader2, XCircle } from "lucide-react";
import type { TranscriptContent, TranscriptMessage } from "../../lib/types";
import { fmt, formatModelName, formatMs } from "../../lib/format";
import { MarkdownContent } from "./MarkdownContent";
export type AgentStatus = "running" | "done" | "error" | "stopped";
/** One subagent as the chat knows it from the call that launched it. */
export interface AgentRef {
/** The Agent/Task `tool_use` id. */
toolUseId: string;
/** Its own transcript's `agent_id`; null while nothing names it yet. */
agentId: string | null;
type: string;
description: string;
model: string | null;
status: AgentStatus;
background: boolean;
prompt: string | null;
report: string | null;
durationMs: number | null;
tokens: number | null;
toolUses: number | null;
}
/** What a `<task-notification>` said about a background agent. */
export interface AgentNotice {
status: string;
result: string | null;
durationMs: number | null;
tokens: number | null;
toolUses: number | null;
}
export interface AgentLinks {
/** tool_use id → agent transcript id, from the session's transcript list. */
ids: Map<string, string>;
/** tool_use id → the notification that closed that background agent. */
notices: Map<string, AgentNotice>;
/** The session runs in a pane: a call with no result yet still works. */
live: boolean;
/** One agent's own conversation, as a chat that fills its box. */
render?: (agent: AgentRef) => React.ReactNode;
}
export const AgentContext = createContext<AgentLinks>({
ids: new Map(),
notices: new Map(),
live: false,
});
const AGENT_TOOLS = new Set(["Agent", "Task"]);
export const isAgentCall = (b: TranscriptContent) =>
b.type === "tool_use" && AGENT_TOOLS.has(b.name ?? "");
// The result names the agent's own transcript (`agent-<id>.jsonl`).
const AGENT_ID = /agentId:\s*([\w-]+)/;
const USAGE = /<usage>([\s\S]*?)<\/usage>/;
const tag = (text: string, name: string) =>
text.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))?.[1]?.trim() ?? null;
/** The time, tokens and tool calls of a `<usage>` block, in either form
* Claude Code writes it: `<total_tokens>N</…>` tags or `total_tokens: N`. */
export function parseUsage(text: string): Pick<AgentNotice, "durationMs" | "tokens" | "toolUses"> {
const body = text.match(USAGE)?.[1] ?? "";
const num = (...names: string[]) => {
for (const n of names) {
const v = body.match(new RegExp(`${n}(?:>|:\\s*)(\\d+)`))?.[1];
if (v) return Number(v);
}
return null;
};
return {
durationMs: num("duration_ms"),
tokens: num("subagent_tokens", "total_tokens"),
toolUses: num("tool_uses"),
};
}
/** Each `<task-notification>` in the transcript, by the call it closes. */
export function agentNotices(messages: TranscriptMessage[]): Map<string, AgentNotice> {
const out = new Map<string, AgentNotice>();
for (const msg of messages) {
if (msg.type !== "user") continue;
for (const c of msg.content) {
if (c.type !== "text" || !c.text?.includes("<task-notification>")) continue;
for (const part of c.text.split("<task-notification>").slice(1)) {
const id = tag(part, "tool-use-id");
if (!id) continue;
out.set(id, {
status: tag(part, "status") ?? "completed",
result: tag(part, "result"),
...parseUsage(part),
});
}
}
}
return out;
}
/** A report without the footer Claude Code appends for the orchestrator. */
function stripFooter(text: string): string {
return text
.replace(USAGE, "")
.replace(/^agentId:.*$/m, "")
.trim();
}
/** Everything the chat can say about the subagent `block` launched. */
export function agentRef(
block: TranscriptContent,
result: TranscriptContent | null | undefined,
links: AgentLinks
): AgentRef {
const raw = block.input as Record<string, unknown> | undefined;
const input = raw && !("_truncated" in raw) ? raw : {};
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
const id = block.id ?? "";
const facts = result?.agent;
const output = result?.output ?? "";
const background = facts?.status === "async_launched" || output.includes("Async agent launched");
const notice = background ? links.notices.get(id) : undefined;
const usage = !background && output ? parseUsage(output) : null;
let status: AgentStatus;
if (result?.is_error) status = "error";
else if (!result || (background && !notice)) status = links.live ? "running" : "stopped";
else if (notice && notice.status !== "completed")
status = notice.status === "failed" ? "error" : "stopped";
else status = "done";
return {
toolUseId: id,
agentId: facts?.id ?? output.match(AGENT_ID)?.[1] ?? links.ids.get(id) ?? null,
type: str(input.subagent_type) ?? "Agent",
description: str(input.description) ?? "",
model: facts?.model ?? str(input.model),
status,
background,
prompt: str(input.prompt),
report: background ? (notice?.result ?? null) : output ? stripFooter(output) || null : null,
durationMs: facts?.duration_ms ?? notice?.durationMs ?? usage?.durationMs ?? null,
tokens: facts?.tokens ?? notice?.tokens ?? usage?.tokens ?? null,
toolUses: facts?.tool_uses ?? notice?.toolUses ?? usage?.toolUses ?? null,
};
}
const STATUS_ICON: Record<AgentStatus, React.ReactNode> = {
running: <Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />,
done: <CheckCircle2 className="h-3.5 w-3.5 flex-shrink-0 text-status-success" />,
error: <XCircle className="h-3.5 w-3.5 flex-shrink-0 text-status-danger" />,
stopped: <CircleSlash className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />,
};
/** One muted line: its status as an icon, then type · model · time ·
* tokens · tool calls, as far as they are known. */
export function AgentFacts({ agent }: { agent: AgentRef }) {
const { t } = useTranslation("sessions");
const parts = [
agent.type,
agent.model && formatModelName(agent.model),
agent.background && t("conversation.agent.background"),
agent.durationMs != null && formatMs(agent.durationMs),
agent.tokens != null && t("conversation.agentContext", { tokens: fmt(agent.tokens) }),
agent.toolUses != null && t("conversation.agent.toolUses", { count: agent.toolUses }),
].filter(Boolean);
return (
<div
data-testid="agent-facts"
title={t(`conversation.agent.status.${agent.status}`)}
className="flex min-w-0 items-center gap-1.5 text-xs text-fg-muted"
>
{STATUS_ICON[agent.status]}
<span className="sr-only">{t(`conversation.agent.status.${agent.status}`)}</span>
<span className="truncate">{parts.join(" · ")}</span>
</div>
);
}
const linkClass =
"inline-flex items-center gap-0.5 text-xs text-fg-muted transition-colors hover:text-fg-secondary";
/** The subagent's own conversation, a thread hung under its step by a line
* on the left — as tall as it is, up to 24rem, then it scrolls. */
export function AgentChatBox({ agent }: { agent: AgentRef }) {
const links = useContext(AgentContext);
return (
<div data-testid="agent-chat" className="mt-2 border-l-2 border-border pl-3">
{links.render?.(agent)}
</div>
);
}
/** An Agent/Task step opened in the chat: one line of facts, its report as
* prose, and "View conversation ›", which puts the agent's own thread (its
* task, every step, its reply) in the report's place. With no transcript
* to show, the task it was given opens on its own instead. */
export function AgentStepBody({
block,
result,
}: {
block: TranscriptContent;
result: TranscriptContent | null;
}) {
const { t } = useTranslation("sessions");
const links = useContext(AgentContext);
const agent = agentRef(block, result, links);
const canOpen = !!agent.agentId && !!links.render;
const [thread, setThread] = useState(false);
const [task, setTask] = useState(false);
return (
<div data-testid="agent-step" className="space-y-1.5">
<AgentFacts agent={agent} />
{thread ? (
<AgentChatBox agent={agent} />
) : (
<>
{!canOpen && agent.prompt && task && (
<div className="max-h-60 overflow-auto border-l-2 border-border pl-3 text-fg-secondary">
<MarkdownContent text={agent.prompt} dense />
</div>
)}
{agent.report && (
<div
className={`max-h-60 overflow-auto ${agent.status === "error" ? "text-status-danger" : "text-fg-secondary"}`}
>
<MarkdownContent text={agent.report} dense />
</div>
)}
</>
)}
{canOpen ? (
<button
type="button"
data-testid="agent-open"
aria-expanded={thread}
onClick={() => setThread(!thread)}
className={linkClass}
>
{t(thread ? "conversation.agent.hide" : "conversation.agent.open")}
<ChevronRight className={`h-3 w-3 transition-transform ${thread ? "-rotate-90" : ""}`} />
</button>
) : (
agent.prompt && (
<button
type="button"
aria-expanded={task}
onClick={() => setTask(!task)}
className={linkClass}
>
{t("conversation.agent.prompt")}
<ChevronRight className={`h-3 w-3 transition-transform ${task ? "rotate-90" : ""}`} />
</button>
)
)}
</div>
);
}
@@ -17,7 +17,9 @@
* A caller that knows the session has ended passes `ended`: nothing more is
* being written, so the catch-up poll drops from 3 s to 60 s and the
* sub-agent list stops being rescanned — a `new_event` frame, a reconnect or
* the view being shown again still fetch the tail at once.
* the view being shown again still fetch the tail at once. The main
* transcript hands MessageList its `sessionId`, so the list can read its
* background agents' own transcripts for their progress.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -71,6 +73,7 @@ import { api } from "../../lib/api";
import { eventBus } from "../../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
import { MessageList, promptTokens } from "./MessageList";
import { agentNotices, type AgentLinks, type AgentRef } from "./AgentDetails";
import { useSpin } from "../../hooks/useSpin";
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { throttle } from "../../lib/throttle";
@@ -93,6 +96,9 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
// at most this often: the first event still fetches at once, the last is never
// lost, and a long transcript is not re-read for every single frame.
const EVENT_FETCH_MS = 1500;
// The server saw this session's transcript grow (`transcript_changed`, itself
// at most every 250 ms): the new lines are on disk, so read them nearly at once.
const PUSH_FETCH_MS = 300;
interface ConversationViewProps {
sessionId: string;
@@ -100,8 +106,15 @@ interface ConversationViewProps {
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
* page-height cap — the parent sizes it and puts a composer under it. */
fill?: boolean;
/** A subagent's conversation inside its step: no toolbar, as tall as its
* content up to 24rem, then it scrolls. */
compact?: boolean;
/** Opens a file named in a turn's "Edited N files" card. */
onOpenFile?: (path: string) => void;
/** Opens an artifact card's file in the chat's artifact panel. */
onOpenArtifact?: (path: string) => void;
/** The messages on screen (for an artifact's versions), as they change. */
onMessages?: (messages: TranscriptMessage[]) => void;
/** The session runs in a pane: show the step in progress. */
live?: boolean;
/** Changed (a chat's send): back to the end, and stick to it. */
@@ -125,7 +138,10 @@ function ConversationViewPane({
sessionId,
initialTranscriptId,
fill = false,
compact = false,
onOpenFile,
onOpenArtifact,
onMessages,
live = false,
follow = 0,
waiting = false,
@@ -137,19 +153,21 @@ function ConversationViewPane({
}: ConversationViewProps) {
const { t } = useTranslation("sessions");
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
const shown = messages;
// A live pane's session has no row until its first hook — and Claude Code
// runs no hook before the folder is trusted, so a trust prompt (or any
// other question before the session starts) waits unseen in the terminal.
const [notStarted, setNotStarted] = useState(false);
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
useEffect(() => onMessages?.(shown), [shown, onMessages]);
useEffect(() => {
if (!onContext) return;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
for (let i = shown.length - 1; i >= 0; i--) {
const m = shown[i]!;
if (m.type === "assistant" && m.usage)
return onContext(promptTokens(m.usage), m.model ?? null);
}
}, [messages, onContext]);
}, [shown, onContext]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingHistory, setLoadingHistory] = useState(false);
@@ -184,9 +202,31 @@ function ConversationViewPane({
// Refresh-button spinner state - separate from initial `loading` so the
// existing skeleton doesn't blink during a manual refresh.
// A subagent's own conversation opens inside its step as a small chat: this
// same view, on that agent's transcript.
const renderAgent = useCallback(
(a: AgentRef) => (
<ConversationView
key={a.agentId}
sessionId={sessionId}
initialTranscriptId={a.agentId}
compact
live={live && a.status === "running"}
ended={a.status !== "running"}
/>
),
[sessionId, live]
);
const agentLinks = useMemo<AgentLinks>(() => {
const ids = new Map<string, string>();
for (const tr of transcripts) if (tr.tool_use_id) ids.set(tr.tool_use_id, tr.id);
return { ids, notices: agentNotices(shown), live, render: renderAgent };
}, [transcripts, shown, live, renderAgent]);
// Load available transcript list (also rescanned on a short interval so
// newly-spawned subagents appear in the dropdown without a page reload).
useEffect(() => {
if (!sessionId) return;
let cancelled = false;
async function loadTranscripts() {
try {
@@ -226,6 +266,12 @@ function ConversationViewPane({
firstLineRef.current = 0;
async function load() {
// No session yet: no history.
if (!sessionId) {
setMessages([]);
setLoading(false);
return;
}
try {
setError(null);
setLoading(true);
@@ -269,6 +315,7 @@ function ConversationViewPane({
// parsed message we've seen. The server already de-overlaps via
// afterLine, so we can safely append.
const fetchNewMessages = useCallback(async () => {
if (!sessionId) return;
if (fetchingRef.current) {
// Coalesce: remember a trigger arrived during this fetch and re-run
// exactly once when the in-flight request settles.
@@ -330,7 +377,12 @@ function ConversationViewPane({
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
[fetchNewMessages]
);
const fetchPushed = useMemo(
() => throttle(() => void fetchNewMessages(), PUSH_FETCH_MS),
[fetchNewMessages]
);
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
useEffect(() => () => fetchPushed.cancel(), [fetchPushed]);
useEffect(() => {
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
// Hidden tab or hidden pane: catching up once on show is enough.
@@ -339,13 +391,14 @@ function ConversationViewPane({
fetchSoon();
return;
}
if (msg.type !== "new_event") return;
if (msg.type !== "new_event" && msg.type !== "transcript_changed") return;
const data = msg.data as { session_id?: string };
if (data.session_id !== sessionId) return;
fetchSoon();
if (msg.type === "transcript_changed") fetchPushed();
else fetchSoon();
});
return unsubscribe;
}, [sessionId, fetchSoon, onScreen]);
}, [sessionId, fetchSoon, fetchPushed, onScreen]);
onShowRef.current = () => {
if (document.visibilityState === "visible") void fetchNewMessages();
@@ -507,7 +560,7 @@ function ConversationViewPane({
// Auto-scroll to bottom after initial load
useEffect(() => {
if (!loading && messages.length > 0) {
if (!loading && shown.length > 0) {
scrollToBottom();
}
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -516,7 +569,7 @@ function ConversationViewPane({
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
{/* Toolbar - always rendered after the initial load so users can
refresh even when no messages have streamed yet. */}
{!loading && !fill && (
{!loading && !fill && !compact && (
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
{transcripts.length > 1 && (
<div className="relative">
@@ -564,7 +617,13 @@ function ConversationViewPane({
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
style={
compact
? { maxHeight: "24rem" }
: fill
? { minHeight: 0 }
: { maxHeight: "calc(100vh - 320px)", minHeight: 200 }
}
>
{/* A chat keeps its first turn clear of the pane's top-right controls
(the outline's file button floats there). */}
@@ -587,10 +646,12 @@ function ConversationViewPane({
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
<div
className={`flex items-center justify-center text-fg-muted text-sm ${compact ? "py-3" : "py-12"}`}
>
{t("conversation.loading")}
</div>
) : messages.length === 0 && notStarted ? (
) : shown.length === 0 && notStarted ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
@@ -606,7 +667,7 @@ function ConversationViewPane({
</button>
)}
</div>
) : messages.length === 0 ? (
) : shown.length === 0 ? (
<div className="mx-auto max-w-md py-12 text-center">
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
@@ -615,13 +676,19 @@ function ConversationViewPane({
</div>
) : (
<MessageList
messages={messages}
messages={shown}
loading={false}
onOpenFile={onOpenFile}
onOpenArtifact={onOpenArtifact}
live={live}
waiting={waiting}
onOpenTerminal={onOpenTerminal}
stopped={stopped}
agentLinks={agentLinks}
compact={compact}
sessionId={
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
}
/>
)}
</div>
@@ -648,7 +715,7 @@ function ConversationViewPane({
)}
{/* New messages indicator */}
{showNewMsg && !fill && (
{showNewMsg && !fill && !compact && (
<button
onClick={() => {
scrollToBottom();
@@ -4,7 +4,7 @@
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
* and auto-linked URLs. A file path — absolute, or relative with a folder and
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
* inline code or as a markdown link's target — opens through `OpenFileContext`
* when the page provides one (the page resolves a relative one against its own
@@ -272,8 +272,14 @@ const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
const REL_PATH_RE =
/^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u;
// A Windows absolute path (`E:\notes\a.xlsx`, `C:/x/y.ts:9`): a drive letter and one segment at least.
const WIN_PATH_RE =
/^[A-Za-z]:[\\/](?:[^\s\\/<>()'"`,;:|*?]+[\\/])*[^\s\\/<>()'"`,;:|*?]*[^\s\\/<>()'"`,;:|*?.!](?::\d+(?::\d+)?)?/;
// In inline code the whole text is the path, so a Windows one may hold spaces.
const WIN_CODE_PATH_RE = /^[A-Za-z]:[\\/][^<>"|?*\n]*[^\s<>"|?*\\/.]$/;
/** A path at the start of `text`, absolute or relative, or null. */
const matchPath = (text: string) => PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
const matchPath = (text: string) =>
PATH_RE.exec(text)?.[0] ?? WIN_PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
// A markdown link target that is a page, not a file.
@@ -393,7 +399,13 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
{codeM[1]}
</code>
);
push(matchPath(codeM[1]!) === codeM[1] ? <PathLink path={codeM[1]!}>{code}</PathLink> : code);
push(
matchPath(codeM[1]!) === codeM[1] || WIN_CODE_PATH_RE.test(codeM[1]!) ? (
<PathLink path={codeM[1]!}>{code}</PathLink>
) : (
code
)
);
i += codeM[0].length;
continue;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,146 @@
/**
* @file AgentDetails.test.tsx
* @description Verifies what the chat shows of a subagent: an Agent step's
* facts, task and report (from its result, or the task-notification that
* closed a background one), and that "View conversation" opens that
* agent's own conversation in place, rendered by the chat's owner.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MessageList } from "../MessageList";
import { agentNotices, agentRef, parseUsage, type AgentLinks } from "../AgentDetails";
import type { TranscriptContent, TranscriptMessage } from "../../../lib/types";
const at = new Date().toISOString();
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
const call: TranscriptContent = {
type: "tool_use",
id: "t1",
name: "Agent",
input: { description: "Map the hooks", subagent_type: "Explore", prompt: "Read **server/**" },
};
const links = (p: Partial<AgentLinks> = {}): AgentLinks => ({
ids: new Map(),
notices: new Map(),
live: false,
...p,
});
describe("agentRef", () => {
it("reads a foreground agent's facts and report, without the orchestrator's footer", () => {
const a = agentRef(
call,
{
type: "tool_result",
id: "t1",
output: "The hooks live in server/routes/hooks.js.\n\nagentId: a1b2 (for resuming)",
agent: {
id: "a1b2",
model: "claude-opus-5-5",
status: "completed",
duration_ms: 65_000,
tokens: 12_000,
tool_uses: 7,
},
},
links()
);
expect(a).toMatchObject({
agentId: "a1b2",
type: "Explore",
status: "done",
background: false,
prompt: "Read **server/**",
report: "The hooks live in server/routes/hooks.js.",
durationMs: 65_000,
tokens: 12_000,
toolUses: 7,
});
});
it("closes a background agent with its notification's report and usage", () => {
const notice = m({
content: [
{
type: "text",
text: "<task-notification>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n<result>All done</result>\n<usage><subagent_tokens>88007</subagent_tokens><tool_uses>31</tool_uses><duration_ms>132350</duration_ms></usage>\n</task-notification>",
},
],
});
const launched: TranscriptContent = {
type: "tool_result",
id: "t1",
output: "Async agent launched successfully.\nagentId: a9b4",
};
const running = agentRef(call, launched, links({ live: true }));
expect(running).toMatchObject({ status: "running", background: true, report: null });
const done = agentRef(call, launched, links({ notices: agentNotices([notice]) }));
expect(done).toMatchObject({
agentId: "a9b4",
status: "done",
report: "All done",
tokens: 88007,
toolUses: 31,
durationMs: 132350,
});
});
it("finds the transcript from the session's list when the result names none", () => {
const a = agentRef(call, null, links({ ids: new Map([["t1", "c3d4"]]) }));
expect(a).toMatchObject({ agentId: "c3d4", status: "stopped" });
});
it("parses the line form of a usage block", () => {
expect(parseUsage("<usage>total_tokens: 500\ntool_uses: 3\nduration_ms: 900</usage>")).toEqual({
tokens: 500,
toolUses: 3,
durationMs: 900,
});
});
});
describe("an Agent step in the chat", () => {
it("shows the agent's facts and report, and opens its conversation in place", () => {
const render_ = vi.fn((a: { agentId: string | null }) => <p>chat of {a.agentId}</p>);
render(
<MessageList
loading={false}
agentLinks={links({ render: render_ })}
messages={[
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
m({ type: "assistant", sender: "assistant", stop_reason: "tool_use", content: [call] }),
m({
content: [
{
type: "tool_result",
id: "t1",
output: "Found **3** hooks",
agent: {
id: "a1b2",
model: null,
status: "completed",
duration_ms: null,
tokens: null,
tool_uses: 4,
},
},
],
}),
]}
/>
);
fireEvent.click(screen.getByRole("button", { name: /ran an agent/i }));
expect(screen.getByTestId("agent-facts").textContent).toContain("Explore");
expect(screen.getByTestId("agent-facts").textContent).toContain("done");
expect(screen.getByTestId("agent-facts").textContent).toContain("4 tool calls");
expect(screen.getByTestId("agent-step").textContent).toContain("Found 3 hooks");
expect(screen.queryByTestId("agent-chat")).toBeNull();
fireEvent.click(screen.getByTestId("agent-open"));
expect(screen.getByTestId("agent-chat").textContent).toBe("chat of a1b2");
// The thread takes the report's place.
expect(screen.getByTestId("agent-step").textContent).not.toContain("Found 3 hooks");
});
});
@@ -0,0 +1,74 @@
/**
* @file ConversationView.pushed.test.tsx
* @description The server says a session's transcript grew
* (`transcript_changed`): the chat showing that session reads the new lines
* at once, and again within a short window after a burst — not at the hook
* throttle's 1.5 s pace — while another session's notice fetches nothing.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ConversationView } from "../ConversationView";
import { api } from "../../../lib/api";
import { eventBus } from "../../../lib/eventBus";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
api: {
sessions: {
transcripts: vi.fn(async () => ({ transcripts: [] })),
transcript: vi.fn(async () => ({
messages: [
{
type: "user",
content: [{ type: "text", text: "hello" }],
timestamp: "2026-10-21T00:00:00Z",
},
],
total: 1,
has_more: false,
first_line: 1,
last_line: 1,
})),
},
},
};
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
const pushed = (session_id: string) =>
act(() => eventBus.publish({ type: "transcript_changed", data: { session_id } } as never));
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
describe("ConversationView on transcript_changed", () => {
it("reads its own session's new lines at once, and a burst within 300 ms", async () => {
render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
vi.useFakeTimers();
const before = calls();
pushed("s1");
expect(calls()).toBe(before + 1);
pushed("s1");
pushed("s1");
await act(async () => {
await vi.advanceTimersByTimeAsync(350);
});
expect(calls()).toBe(before + 2);
});
it("ignores another session's transcript", async () => {
render(<ConversationView sessionId="s1" fill live />);
await screen.findByText("hello");
const before = calls();
pushed("s2");
await act(async () => {});
expect(calls()).toBe(before);
});
});
@@ -36,6 +36,20 @@ describe("file links", () => {
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
});
it("opens a Windows path, bare or in inline code", () => {
const open = withOpen(
"File: `E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx`. Also C:/src/a.ts:9, and `D:\\My Docs\\b.md`"
);
fireEvent.click(screen.getByRole("button", { name: "E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx" }));
fireEvent.click(screen.getByRole("button", { name: "C:/src/a.ts:9" }));
fireEvent.click(screen.getByRole("button", { name: "D:\\My Docs\\b.md" }));
expect(open.mock.calls).toEqual([
["E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx"],
["C:/src/a.ts"],
["D:\\My Docs\\b.md"],
]);
});
it("opens a markdown link to a file instead of navigating", () => {
const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
@@ -0,0 +1,118 @@
/**
* @file MessageList.agents.test.tsx
* @description Verifies a live chat's background-agents card reads each
* running agent's own transcript (by the agentId its launch result names)
* and shows the step it is on and its context size (issue #81).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MessageList, agentProgress } from "../MessageList";
import { api } from "../../../lib/api";
import type { TranscriptMessage } from "../../../lib/types";
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { sessions: { transcript: vi.fn() } } };
});
const at = new Date().toISOString();
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
// The subagent's own transcript: its task, then a search still running.
const tail: TranscriptMessage[] = [
m({ sender: "orchestrator", content: [{ type: "text", text: "Find copyId" }] }),
m({
type: "assistant",
sender: "assistant",
stop_reason: "tool_use",
usage: { input_tokens: 300, output_tokens: 20, cache_read_input_tokens: 54_000 },
content: [
{
type: "tool_use",
id: "g1",
name: "Grep",
input: { pattern: "copyId", description: "Searching for copyId" },
},
],
}),
];
const main: TranscriptMessage[] = [
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
m({
type: "assistant",
sender: "assistant",
stop_reason: "tool_use",
content: [
{
type: "tool_use",
id: "t1",
name: "Agent",
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
},
],
}),
m({
content: [
{
type: "tool_result",
id: "t1",
output: JSON.stringify([
{ type: "text", text: "Async agent launched successfully.\nagentId: a4118367b503adecc" },
]),
},
],
}),
m({
type: "assistant",
sender: "assistant",
stop_reason: "end_turn",
content: [{ type: "text", text: "waiting" }],
}),
];
describe("background agents' progress", () => {
const rects = Element.prototype.getClientRects;
beforeEach(() => {
// jsdom lays nothing out: make the card count as on screen.
Element.prototype.getClientRects = () => [{}] as unknown as DOMRectList;
vi.mocked(api.sessions.transcript).mockResolvedValue({
messages: tail,
total: 2,
has_more: false,
first_line: 1,
last_line: 2,
});
});
afterEach(() => {
Element.prototype.getClientRects = rects;
vi.mocked(api.sessions.transcript).mockReset();
});
it("reads the step an agent is on and its context", () => {
expect(agentProgress(tail)).toEqual({ step: "Searching for copyId", context: 54_300 });
expect(agentProgress(tail.slice(0, 1))).toEqual({ step: null, context: 0 });
});
it("shows them on the agent's line, from its own transcript", async () => {
render(<MessageList live loading={false} messages={main} sessionId="s1" />);
await waitFor(() =>
expect(screen.getByTestId("chat-background-agent-step")).toHaveTextContent(
"Searching for copyId"
)
);
expect(api.sessions.transcript).toHaveBeenCalledWith("s1", {
agent_id: "a4118367b503adecc",
limit: 20,
});
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent("↓ 54.3K tokens");
});
it("reads nothing without the session", () => {
render(<MessageList live loading={false} messages={main} />);
expect(screen.getByTestId("chat-background-agent")).toBeInTheDocument();
expect(api.sessions.transcript).not.toHaveBeenCalled();
});
});
@@ -46,7 +46,7 @@ describe("the Edited N files card", () => {
user("fix it"),
call("1", "Edit", "/w/a.ts"),
result("1", { added: 3, removed: 1 }),
call("2", "Write", "/w/b.md"),
call("2", "Write", "/w/b.json"),
result("2", { added: 10, removed: 0 }),
call("3", "Edit", "/w/a.ts"),
result("3", { added: 2, removed: 2 }),
@@ -65,8 +65,35 @@ describe("the Edited N files card", () => {
expect(within(card).getByText("+15")).toBeTruthy();
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
expect(within(card).queryByText("c.ts")).toBeNull(); // failed
fireEvent.click(within(card).getByTitle("/w/b.md"));
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
fireEvent.click(within(card).getByTitle("/w/b.json"));
expect(onOpenFile).toHaveBeenCalledWith("/w/b.json");
});
it("shows a page or document the turn made as an artifact card, opened in the chat's panel", () => {
const onOpenFile = vi.fn();
const onOpenArtifact = vi.fn();
render(
<MessageList
loading={false}
onOpenFile={onOpenFile}
onOpenArtifact={onOpenArtifact}
messages={[
user("make a page"),
call("1", "Write", "/w/page.html"),
result("1", { added: 20, removed: 0 }),
call("2", "Edit", "/w/App.tsx"),
result("2", { added: 1, removed: 1 }),
user("thanks"),
]}
/>
);
const cards = screen.getAllByTestId("artifact-card");
// A .tsx it only edited is project code, not an artifact.
expect(cards).toHaveLength(1);
expect(cards[0]!.textContent).toContain("page");
fireEvent.click(cards[0]!);
expect(onOpenArtifact).toHaveBeenCalledWith("/w/page.html");
expect(within(screen.getByTestId("edited-files")).getByTitle("/w/App.tsx")).toBeTruthy();
});
it("drags a row as its @path attachment, and the header as every file", () => {
@@ -77,7 +104,7 @@ describe("the Edited N files card", () => {
user("fix it"),
call("1", "Edit", "/w/a.ts"),
result("1", { added: 1, removed: 0 }),
call("2", "Write", "/w/b.md"),
call("2", "Write", "/w/b.json"),
result("2", { added: 2, removed: 0 }),
]}
/>
@@ -90,14 +117,14 @@ describe("the Edited N files card", () => {
});
return data;
};
const one = drag(within(card).getByTitle("/w/b.md"));
const one = drag(within(card).getByTitle("/w/b.json"));
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
{ path: "@/w/b.md", name: "b.md" },
{ path: "@/w/b.json", name: "b.json" },
]);
expect(one["text/plain"]).toBe("/w/b.md");
expect(one["text/plain"]).toBe("/w/b.json");
const all = drag(within(card).getByText("Edited 2 files"));
expect(
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
).toEqual(["@/w/a.ts", "@/w/b.md"]);
).toEqual(["@/w/a.ts", "@/w/b.json"]);
});
});
@@ -5,12 +5,13 @@
* layout), Main agent / System under their own label — instead of treating
* every `type:"user"` line as the user (reported transcript mis-attribution) —
* and folds a run of tool calls into one line; a typed `/command` or `@path`
* shows as a chip.
* shows as a chip. A live chat lists the background subagents still running
* and does not call their turn done (issue #81).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
import { MessageList, backgroundAgents, turnStats, workLabel, workingOn } from "../MessageList";
import type { TranscriptMessage } from "../../../lib/types";
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
@@ -137,7 +138,28 @@ describe("MessageList — sender attribution", () => {
]}
/>
);
expect(screen.getByText(/↓ 123/)).toBeTruthy();
// In the hover bar's tooltip, beside the model and time.
expect(screen.getByTestId("chat-actions").getAttribute("title")).toMatch(/↓ 123/);
});
it("shows a local command as one line with its output, never its caveat", () => {
const line = (text: string, sender: "user" | "system" = "user") =>
msg({ type: "user", sender, content: [{ type: "text", text }] });
render(
<MessageList
loading={false}
messages={[
line("<local-command-caveat>Caveat: the command below…</local-command-caveat>", "system"),
line("<command-name>/model</command-name><command-args>opus</command-args>"),
line("<local-command-stdout>Set model to Opus</local-command-stdout>"),
]}
/>
);
const row = screen.getByTestId("chat-command");
expect(row).toHaveTextContent("/model opus");
expect(row).toHaveTextContent("Set model to Opus");
expect(screen.queryByText(/Caveat/)).toBeNull();
expect(screen.queryByTestId("chat-user")).toBeNull();
});
it("ends a live chat with the step still running", () => {
@@ -167,7 +189,9 @@ describe("MessageList — sender attribution", () => {
const row = screen.getByTestId("chat-running-step");
expect(row).toHaveTextContent("Run the full client suite");
// Like the terminal: the turn's time since the prompt, and its output.
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/);
// (the output shows on hover).
expect(row).toHaveTextContent(/1m \ds/);
expect(row).toHaveTextContent(/↑ 3\.5K tokens/);
// Its result arrived: nothing is running any more.
rerender(
<MessageList
@@ -340,7 +364,88 @@ describe("MessageList — sender attribution", () => {
content: [{ type: "text", text: "[Request interrupted by user]" }],
});
expect(state([prompt, call, result, stopped])).toBeNull();
// A local command (`/clear`) is answered by the CLI itself: no turn.
const local = (text: string) =>
msg({ type: "user", sender: "user", timestamp: at, content: [{ type: "text", text }] });
const clear = local("<command-name>/clear</command-name>");
const out = local("<local-command-stdout></local-command-stdout>");
expect(state([clear, out])).toBeNull();
// A prompt command (a skill) is still answered by the model.
expect(state([clear])).toEqual(thinking);
// Silent for 15 minutes with nothing running: the turn died.
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
});
describe("background subagents", () => {
const at = new Date().toISOString();
const launch = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "tool_use",
content: [
{
type: "tool_use",
id: "t1",
name: "Agent",
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
},
],
});
const launched = msg({
timestamp: at,
content: [
{
type: "tool_result",
id: "t1",
output: JSON.stringify([{ type: "text", text: "Async agent launched successfully." }]),
},
],
});
const reply = msg({
type: "assistant",
sender: "assistant",
timestamp: at,
stop_reason: "end_turn",
content: [{ type: "text", text: "waiting for the agent" }],
});
const notified = msg({
sender: "system",
timestamp: at,
content: [
{
type: "text",
text: "<task-notification>\n<task-id>a1</task-id>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n</task-notification>",
},
],
});
const base = [msg({ sender: "user", timestamp: at }), launch, launched, reply];
it("lists an agent until its notification names the call", () => {
const results = (m: TranscriptMessage[]) =>
new Map(
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
);
expect(backgroundAgents(base, results(base)).map((a) => a.block.id)).toEqual(["t1"]);
const all = [...base, notified];
expect(backgroundAgents(all, results(all))).toEqual([]);
});
it("does not call the turn done while the agent works", () => {
const { rerender } = render(<MessageList live loading={false} messages={base} />);
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent(
"ExploreChecking copyId coverage"
);
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Replied in");
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("1 agent still running");
rerender(<MessageList live loading={false} messages={[...base, notified]} />);
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in");
});
it("shows nothing once the session is no longer live", () => {
render(<MessageList loading={false} messages={base} />);
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
});
});
});
+30 -1
View File
@@ -9,7 +9,8 @@
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
* Settings → Git). There is no AI tool picker: the lane is created with the
* Settings → Git; **Partial clone** makes it blobless, and `CloneProgress`
* shows its queue place, then git's progress). There is no AI tool picker: the lane is created with the
* default AI tool (Settings → AI Tools), and each new session picks its own
* AI on the workspace home. Either
* mode's path field can be filled by typing, by the CwdAutocomplete
@@ -30,6 +31,7 @@ import { useDialog } from "../../hooks/useDialog";
import { CwdAutocomplete } from "../run/RunSetup";
import { PathPickButton } from "../PathPickButton";
import { api } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import type { CwdSuggestion } from "../../lib/api";
import type { Lane, LaneWorktree } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
@@ -116,6 +118,9 @@ export function AddLaneModal({
// server asked for one — a login for the URL's https host.
const [cloneUrl, setCloneUrl] = useState("");
const [cloneName, setCloneName] = useState("");
const [clonePartial, setClonePartial] = useState(false);
/** The running clone's progress token (CloneProgress polls it). */
const [cloneToken, setCloneToken] = useState<string | null>(null);
const [login, setLogin] = useState<{
host: string;
username: string;
@@ -159,6 +164,7 @@ export function AddLaneModal({
setMode("repo");
setCloneUrl("");
setCloneName("");
setClonePartial(false);
setLogin(null);
setSshRefused(false);
setSourceRepo("");
@@ -236,13 +242,17 @@ export function AddLaneModal({
const url = cloneUrl.trim();
if (!url) return;
setSshRefused(false);
const progressToken = newCloneToken();
setCloneToken(progressToken);
try {
const result = await api.lanes.clone({
progressToken,
url,
parent: repo,
name: cloneName.trim() || undefined,
title: name || undefined,
tool,
...(clonePartial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
@@ -274,6 +284,8 @@ export function AddLaneModal({
setError(err instanceof Error ? err.message : String(err));
}
setBusy(false);
} finally {
setCloneToken(null);
}
return;
}
@@ -547,6 +559,23 @@ export function AddLaneModal({
spellCheck={false}
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<label
className="mt-2 flex items-center gap-1.5 text-[11px] text-fg-secondary"
title={t("clone.partialHint")}
>
<input
type="checkbox"
data-testid="add-lane-clone-partial"
checked={clonePartial}
onChange={(e) => setClonePartial(e.target.checked)}
/>
{t("clone.partial")}
</label>
{cloneToken && (
<div className="mt-2">
<CloneProgress token={cloneToken} />
</div>
)}
</div>
)}
@@ -0,0 +1,224 @@
/**
* @file CloneHereDialog.tsx
* @description The Files page's **New → Clone repository**: clones a URL into
* the folder the File Explorer has open, through the same
* `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL
* to its allowlist and the folder to the user's own, and clones as the
* folder's account with that account's own SSH key / HTTPS login — and opens
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { FolderGit2 } from "lucide-react";
import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
import { cloneDirName } from "./AddLaneModal";
type Login = { host: string; username: string; token: string; remember: boolean };
export function CloneHereDialog({
open,
parent,
onClose,
onCloned,
}: {
open: boolean;
/** The absolute folder the clone goes into. */
parent: string;
onClose: () => void;
onCloned: (lane: Lane, path: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const [url, setUrl] = useState("");
const [name, setName] = useState("");
const [login, setLogin] = useState<Login | null>(null);
const [sshRefused, setSshRefused] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useDialog(open, panelRef, busy ? () => {} : onClose);
useEffect(() => {
if (!open) return;
setUrl("");
setName("");
setLogin(null);
setSshRefused(false);
setBusy(false);
setError(null);
}, [open, parent]);
if (!open) return null;
const folder = name.trim() || cloneDirName(url);
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
const submit = async () => {
const u = url.trim();
if (!u || busy) return;
setBusy(true);
setError(null);
setSshRefused(false);
try {
const result = await api.lanes.clone({
url: u,
parent,
name: name.trim() || undefined,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
});
onCloned(result.lane, result.path);
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || u;
if (e.code === "ECREDENTIALS") {
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
setLogin((l) => ({
host,
username: l?.username ?? "",
token: "",
remember: l?.remember ?? true,
}));
} else if (e.code === "ESSHAUTH") {
setSshRefused(true);
setError(t("clone.sshRefused", { host }));
} else {
setError(err instanceof Error ? err.message : String(err));
}
setBusy(false);
}
};
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={busy ? undefined : onClose}
// A portal still bubbles through React: keep keys away from the File
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
data-testid="fx-clone-dialog"
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
onClick={(e) => e.stopPropagation()}
>
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
<FolderGit2 className="h-4 w-4" />
{t("fx.cloneRepo")}
</h2>
<form
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
className="space-y-3"
>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-url`}>
{t("clone.urlLabel")}
</label>
<input
id={`${titleId}-url`}
autoFocus
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://host/owner/repo.git"
disabled={busy}
className="input w-full font-mono text-xs"
/>
<p className="mt-1 text-[11px] text-fg-muted">{t("clone.urlHint")}</p>
</div>
<div>
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-name`}>
{t("clone.nameLabel")}
</label>
<input
id={`${titleId}-name`}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={cloneDirName(url)}
disabled={busy}
className="input w-full font-mono text-xs"
/>
<p className="mt-1 break-all text-[11px] text-fg-muted">
{t("clone.parentHint", { path: into })}
</p>
</div>
{login && (
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
<input
aria-label={t("clone.username")}
placeholder={t("clone.username")}
autoComplete="username"
value={login.username}
onChange={(e) => setLogin({ ...login, username: e.target.value })}
disabled={busy}
className="input w-full text-xs"
/>
<input
aria-label={t("clone.token")}
placeholder={t("clone.token")}
type="password"
autoComplete="current-password"
value={login.token}
onChange={(e) => setLogin({ ...login, token: e.target.value })}
disabled={busy}
className="input w-full text-xs"
/>
<label className="flex items-center gap-2 text-xs text-fg-secondary">
<input
type="checkbox"
checked={login.remember}
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
disabled={busy}
/>
{t("clone.remember")}
</label>
</div>
)}
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}{" "}
{sshRefused && (
<a href="/settings/git#git-identity" className="underline">
{t("clone.sshSettings")}
</a>
)}
</p>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
{t("fx.cancel")}
</button>
<button
type="submit"
disabled={busy || !url.trim()}
className="btn-primary text-xs"
data-testid="fx-clone-submit"
>
{busy ? t("clone.cloning") : t("fx.cloneSubmit")}
</button>
</div>
</form>
</div>
</div>,
document.body
);
}
@@ -0,0 +1,93 @@
/**
* @file CloneProgress — the live line under a running clone: its place in the
* server's clone queue while it waits (clones run a few at a time machine-wide,
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
* with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
* the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
* yet arrived, or an older server — just shows nothing yet.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, type CloneProgressView } from "../../lib/api";
const POLL_MS = 1000;
/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
export function newCloneToken(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
export function CloneProgress({ token }: { token: string }) {
const { t } = useTranslation("lanes");
const [view, setView] = useState<CloneProgressView | null>(null);
useEffect(() => {
let alive = true;
setView(null);
// Never throws: progress is a courtesy, the clone's own answer decides.
const poll = () =>
Promise.resolve()
.then(() => api.lanes.cloneProgress(token))
.then((v) => {
if (alive) setView(v);
})
.catch(() => {});
void poll();
const timer = setInterval(() => void poll(), POLL_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [token]);
if (!view || view.state === "done" || view.state === "failed") return null;
if (view.state === "queued") {
return (
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
{t("clone.queued", {
position: view.position ?? 1,
waiting: view.waiting,
running: view.running,
limit: view.limit,
})}
</p>
);
}
const phase = view.phase
? view.phase === "other"
? view.label || ""
: t(`clone.phase.${view.phase}`)
: t("clone.connecting");
const percent = view.percent ?? 0;
return (
<div data-testid="clone-progress" className="space-y-1">
<p className="flex justify-between gap-2 text-[11px] text-fg-secondary">
<span className="truncate">
{phase}
{view.phase ? ` ${percent}%` : ""}
</span>
{view.detail && (
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
)}
</p>
{view.phase && (
<div
className="h-1 overflow-hidden rounded bg-surface-2"
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
aria-label={phase}
>
<div className="h-full bg-accent-solid transition-all" style={{ width: `${percent}%` }} />
</div>
)}
</div>
);
}
@@ -4,18 +4,62 @@
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
* added as an extra folder, one after another. A host that wants a login
* (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
* URLs with it, like Add workspace → Clone.
* URLs with it, like Add workspace → Clone. Before anything runs, each URL
* is checked against the user's own git identity (`GET /api/me/git`): an SSH
* link while the account has no SSH key is not cloned at all — the user is
* sent to Settings → Git first — and an HTTPS host with no saved login offers
* the login row up front (optional: a public repo needs none). The identity is
* read again when the window regains focus, so a key made in another tab
* counts at once. The running clone shows its queue place, then git's
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Loader2, X } from "lucide-react";
import { api, type LaneSourcesView } from "../../lib/api";
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
import { CloneProgress, newCloneToken } from "./CloneProgress";
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string };
type Row = {
url: string;
state: "wait" | "busy" | "done" | "failed";
note?: string;
/** The note points at Settings → Git (no key, or a key the host refused). */
ssh?: boolean;
/** While busy: the progress token CloneProgress polls. */
token?: string;
};
type Login = { host: string; username: string; token: string; remember: boolean };
/** How a clone URL reaches its host: `ssh://…` or scp-like `user@host:path`, else HTTPS. */
export function cloneTarget(url: string): { kind: "ssh" | "https"; host: string } | null {
const scp = /^(?:[^@\s/]+@)?([^:/\s]+):(?!\/\/)/.exec(url);
if (scp?.[1]) return { kind: "ssh", host: scp[1] };
try {
const u = new URL(url);
if (u.protocol === "ssh:") return { kind: "ssh", host: u.hostname };
if (u.protocol === "https:" || u.protocol === "http:") return { kind: "https", host: u.host };
} catch {
/* not a URL yet — the server's allowlist answers that one */
}
return null;
}
/** What the identity lacks for one URL: `ssh` blocks the clone, `https` only asks. */
function missingFor(
url: string,
id: GitIdentity | null
): { kind: "ssh" | "https"; host: string } | null {
const target = cloneTarget(url);
if (!id || !target) return null;
if (target.kind === "ssh") {
const keys = id.sshKeys?.length ?? (id.ssh ? 1 : 0);
return keys > 0 ? null : target;
}
return id.https.some((h) => h.host === target.host) ? null : target;
}
export function CloneSourcesForm({
laneId,
parent,
@@ -31,6 +75,25 @@ export function CloneSourcesForm({
const [rows, setRows] = useState<Row[]>([]);
const [busy, setBusy] = useState(false);
const [login, setLogin] = useState<Login | null>(null);
const [partial, setPartial] = useState(false);
const [identity, setIdentity] = useState<GitIdentity | null>(null);
// An unknown identity (an older server, a failed read) checks nothing: the
// server's own ECREDENTIALS / ESSHAUTH answers still cover it.
const loadIdentity = useCallback(
() =>
Promise.resolve()
.then(() => api.gitIdentity.get())
.then(setIdentity)
.catch(() => {}),
[]
);
useEffect(() => {
void loadIdentity();
const onFocus = () => void loadIdentity();
window.addEventListener("focus", onFocus);
return () => window.removeEventListener("focus", onFocus);
}, [loadIdentity]);
const urls = (s: string) => [
...new Set(
@@ -44,16 +107,27 @@ export function CloneSourcesForm({
const cloneAll = async (list: string[]) => {
if (busy || !list.length) return;
setBusy(true);
const next: Row[] = list.map((url) => ({ url, state: "wait" }));
// An SSH link with no key on the account would only fail at the host:
// it is not tried, and its row says what to set up first.
const next: Row[] = list.map((url) => {
const miss = missingFor(url, identity);
return miss?.kind === "ssh"
? { url, state: "failed", note: t("clone.sshNoKey", { host: miss.host }), ssh: true }
: { url, state: "wait" };
});
const show = () => setRows([...next]);
show();
let asked: Login | null = null;
for (const row of next) {
if (row.state === "failed") continue;
row.state = "busy";
row.token = newCloneToken();
show();
try {
const view = await api.lanes.cloneFolder(laneId, {
url: row.url,
progressToken: row.token,
...(partial ? { partial: true } : {}),
...(login?.username.trim() && login.token
? {
credentials: {
@@ -80,10 +154,12 @@ export function CloneSourcesForm({
};
} else if (e.code === "ESSHAUTH") {
row.note = t("clone.sshRefused", { host });
row.ssh = true;
} else {
row.note = e.message || String(err);
}
}
row.token = undefined;
show();
}
if (asked) setLogin(asked);
@@ -99,6 +175,18 @@ export function CloneSourcesForm({
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
// Checked as the user types, so a missing key is said before Clone is pressed.
const missing = urls(text)
.map((u) => missingFor(u, identity))
.filter((m): m is NonNullable<typeof m> => m !== null);
const sshHosts = [...new Set(missing.filter((m) => m.kind === "ssh").map((m) => m.host))];
const httpsHosts = [...new Set(missing.filter((m) => m.kind === "https").map((m) => m.host))];
const settingsLink = (
<a href="/settings/git#git-identity" target="_blank" rel="noreferrer" className="underline">
{t("clone.sshSettings")}
</a>
);
return (
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
@@ -112,6 +200,41 @@ export function CloneSourcesForm({
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
<label
className="flex items-center gap-1.5 text-[11px] text-fg-secondary"
title={t("clone.partialHint")}
>
<input
type="checkbox"
data-testid="workspace-clone-partial"
checked={partial}
onChange={(e) => setPartial(e.target.checked)}
/>
{t("clone.partial")}
</label>
{sshHosts.length > 0 && (
<p
role="alert"
data-testid="workspace-clone-need-ssh"
className="text-[11px] text-status-danger"
>
{t("home.cloneNeedSsh", { hosts: sshHosts.join(", ") })} {settingsLink}
</p>
)}
{httpsHosts.length > 0 && !login && (
<p data-testid="workspace-clone-need-login" className="text-[11px] text-status-warning">
{t("home.cloneNeedLogin", { hosts: httpsHosts.join(", ") })}{" "}
<button
type="button"
className="underline"
onClick={() =>
setLogin({ host: httpsHosts[0] ?? "", username: "", token: "", remember: true })
}
>
{t("home.cloneEnterLogin")}
</button>
</p>
)}
{login && (
<div className="space-y-1.5 rounded border border-border-light p-2">
<div className="flex gap-2">
@@ -178,7 +301,17 @@ export function CloneSourcesForm({
{r.url}
</span>
</span>
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
{r.state === "busy" && r.token && (
<div className="ml-[18px] mt-0.5">
<CloneProgress token={r.token} />
</div>
)}
{r.note && (
<span className="ml-[18px] block text-status-danger">
{r.note}
{r.ssh && <> {settingsLink}</>}
</span>
)}
</li>
))}
</ul>
+172 -24
View File
@@ -91,9 +91,48 @@ import { confirmDialog } from "../../lib/confirm";
import { formatBytes } from "../../lib/format";
import { copyText } from "../../lib/clipboard";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { CloneHereDialog } from "./CloneHereDialog";
/** One upload batch at most (the server caps one file the same). */
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
/** Files up to this size travel together, many per request (`uploadBatch`):
* their cost is per FILE (a request, a path walk, a sudo), not per byte. */
const SMALL_FILE = 4 * 1024 * 1024;
const PACK_BYTES = 32 * 1024 * 1024;
const PACK_FILES = 1000;
/** Keeps a pack's path list well under the server's 1 MB JSON body (upload-check). */
const PACK_NAME_BYTES = 400 * 1024;
/** Small files in packs (each ≥ 2 files) and the rest, one by one. */
export function packUploads<T extends { file: Blob; rel: string }>(files: T[]) {
const packs: T[][] = [];
const singles: T[] = [];
let cur: T[] = [];
let bytes = 0;
let names = 0;
for (const f of files) {
if (f.file.size > SMALL_FILE) {
singles.push(f);
continue;
}
const n = f.rel.length * 3 + 64;
if (
cur.length &&
(cur.length >= PACK_FILES || bytes + f.file.size > PACK_BYTES || names + n > PACK_NAME_BYTES)
) {
packs.push(cur);
cur = [];
bytes = 0;
names = 0;
}
cur.push(f);
bytes += f.file.size;
names += n;
}
if (cur.length) packs.push(cur);
for (const p of packs.filter((p) => p.length < 2)) singles.push(...p);
return { packs: packs.filter((p) => p.length >= 2), singles };
}
const MIME = "application/x-agenthub-fx";
const VIEW_KEY = "agenthub.files.view";
const HIDDEN_KEY = "agenthub.files.hidden";
@@ -268,6 +307,7 @@ export function FileBrowser({
const [busy, setBusy] = useState(false);
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null);
const [cloning, setCloning] = useState(false);
const [ask, setAsk] = useState<Ask | null>(null);
const [askAll, setAskAll] = useState(false);
const [progress, setProgress] = useState<{
@@ -314,6 +354,22 @@ export function FileBrowser({
}, []);
// Another user's home (an admin's view) is read-only.
const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly;
// Clone into the open folder: the absolute path of a workspace's own folder
// or of the user's own home (never a session worktree, an extra folder or
// another user's home); the server checks the folder again.
const cloneRoot = isHomePlace(target)
? target.home === "me"
? (homes.find((h) => h.id === "me")?.dir ?? null)
: null
: typeof target === "number"
? (lanes.find((l) => l.id === target)?.cwd ?? null)
: null;
const cloneParent =
cloneRoot && view === "files"
? path
? `${cloneRoot.replace(/\/+$/, "")}/${path}`
: cloneRoot
: null;
/** Go to `p` in workspace `tgt`, remembering where we were for Back. */
const go = (p: string, tgt: FsPlace = target) => {
@@ -542,32 +598,95 @@ export function FileBrowser({
}
setBusy(true);
let sent = 0;
let done = 0;
let saved = 0;
let forAll: FsConflict | null = null;
try {
for (let i = 0; i < files.length; i++) {
const { file, rel } = files[i]!;
const dest = join(dir, rel);
setProgress({ name: rel, done: i, total: files.length, sent, bytes });
const send = (c: FsConflict) =>
api.files.upload(target, dest, file, c, (n) =>
setProgress({ name: rel, done: i, total: files.length, sent: sent + n, bytes })
);
try {
const r = await send(forAll ?? "fail").catch(async (e) => {
if (errCode(e) !== "EEXISTS") throw e;
const a = await askConflict([rel], files.length > 1);
if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
if (a.all) forAll = a.choice;
return send(a.choice);
});
if (!r.skipped) saved++;
} catch (e) {
if (errCode(e) === "ECANCEL") break;
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
}
sent += file.size;
// false once the server says a batch cannot be written here (an old helper).
let batchable = true;
const show = (name: string, extra = 0) =>
setProgress({ name, done, total: files.length, sent: sent + extra, bytes });
/** One file; false = the user cancelled. */
const one = async ({ file, rel }: { file: File; rel: string }) => {
const dest = join(dir, rel);
show(rel);
const send = (c: FsConflict) => api.files.upload(target, dest, file, c, (n) => show(rel, n));
try {
const r = await send(forAll ?? "fail").catch(async (e) => {
if (errCode(e) !== "EEXISTS") throw e;
const a = await askConflict([rel], files.length > 1);
if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
if (a.all) forAll = a.choice;
return send(a.choice);
});
if (!r.skipped) saved++;
} catch (e) {
if (errCode(e) === "ECANCEL") return false;
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
}
sent += file.size;
done++;
return true;
};
/** Many small files in one request: "ok", "cancel", or "fallback" (send them one by one). */
const pack = async (b: { file: File; rel: string }[]) => {
const label = `${b[0]!.rel} (+${b.length - 1})`;
const size = b.reduce((n, f) => n + f.file.size, 0);
show(label);
// Settled BEFORE the bytes go: a 409 after a 32 MB body would waste it.
const choose = async (taken: string[]) => {
if (forAll) return forAll;
if (!taken.length) return "fail" as const;
const a = await askConflict(taken, files.length > 1);
if (a.all && a.choice) forAll = a.choice;
return a.choice;
};
try {
const c = await api.files.uploadCheck(
target,
dir,
b.map((f) => f.rel)
);
if (!c.batch) {
batchable = false;
return "fallback";
}
let policy = await choose(c.taken);
if (!policy) return "cancel";
const send = (p: FsConflict) =>
api.files.uploadBatch(target, dir, b, p, (n) => show(label, n));
const r = await send(policy).catch(async (e) => {
// Taken meanwhile: ask about those, then once more.
if (errCode(e) !== "EEXISTS") throw e;
policy = await choose((e as { names?: string[] }).names ?? []);
if (!policy) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
return send(policy);
});
saved += r.saved;
} catch (e) {
const code = errCode(e);
if (code === "ECANCEL") return "cancel";
// Refused before a byte was written (a bad name, an old helper…):
// each file then gets its own answer.
if (code === "EUPGRADE") batchable = false;
if (code && /^E(UPGRADE|BADNAME|BADPATH|SECRET|BADBODY|TOOBIG|EXISTS)$/.test(code)) {
return "fallback";
}
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
}
sent += size;
done += b.length;
return "ok";
};
try {
const { packs, singles } = packUploads(files);
let go = true;
for (const b of packs) {
const r = batchable ? await pack(b) : "fallback";
if (r === "cancel") go = false;
if (r === "fallback") for (const f of b) if (go && !(await one(f))) go = false;
if (!go) break;
}
for (const f of singles) if (!go || !(await one(f))) break;
} finally {
setProgress(null);
setBusy(false);
@@ -633,7 +752,7 @@ export function FileBrowser({
writePref(VIEW_KEY, l);
};
const onKey = (e: ReactKeyboardEvent) => {
if (renaming || creating || ask) return;
if (renaming || creating || ask || cloning) return;
const mod = e.ctrlKey || e.metaKey;
const k = e.key.toLowerCase();
// Another user's home is read-only: no key that writes.
@@ -784,6 +903,19 @@ export function FileBrowser({
onClick={() => setMenu(null)}
data-testid="file-explorer"
>
{cloneParent && (
<CloneHereDialog
open={cloning}
parent={cloneParent}
onClose={() => setCloning(false)}
onCloned={(lane, at) => {
setCloning(false);
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
setLanes((ls) => (ls.some((l) => l.id === lane.id) ? ls : [...ls, lane]));
changed();
}}
/>
)}
{/* ── Places ── */}
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
@@ -1018,6 +1150,17 @@ export function FileBrowser({
>
{t("fx.newFile")}
</DropItem>
{cloneParent && (
<DropItem
icon={<FolderGit2 className="h-4 w-4" />}
onClick={() => {
setMenu(null);
setCloning(true);
}}
>
{t("fx.cloneRepo")}
</DropItem>
)}
</DropButton>
<DropButton
label={t("fx.upload")}
@@ -1449,6 +1592,11 @@ export function FileBrowser({
{t("fx.newFolder")}
</MenuItem>
<MenuItem onSelect={close(() => startCreate("file"))}>{t("fx.newFile")}</MenuItem>
{cloneParent && (
<MenuItem onSelect={close(() => setCloning(true))}>
{t("fx.cloneRepo")}
</MenuItem>
)}
<MenuSeparator />
<MenuItem onSelect={close(() => fileInput.current?.click())}>
{t("fx.uploadFiles")}
@@ -1422,6 +1422,27 @@ export function LaneChangesPanel({
}
};
// Discard all: every non-conflicted file in one `discard`, asked once in a
// dialog that names how many untracked files it will delete.
const discardAll = async () => {
const rows = files.filter((f) => !f.conflicted);
if (!rows.length) return;
const ok = await confirmDialog({
title: t("changes.discardAllTitle"),
message: t("changes.discardAllConfirm", {
count: rows.length,
untracked: rows.filter((f) => f.untracked).length,
}),
confirmLabel: t("changes.discardAll"),
});
if (ok)
await act(
"discard",
rows.map((f) => f.path),
true
);
};
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
const mergeAct = async (
action: "resolve" | "merge-commit" | "merge-abort",
@@ -1728,7 +1749,12 @@ export function LaneChangesPanel({
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
<MenuItem
danger
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
// Pushed: the server refuses it (EPUSHED), so say why here
// instead of failing after the confirm.
disabled={
busy || opOpen || headPushed || commitCtx.menu.item.parents.length !== 1
}
title={headPushed ? t("changes.undoCommitPushed") : undefined}
testId="lane-changes-menu-undo-commit"
onSelect={pick(
commitCtx.close,
@@ -2751,6 +2777,17 @@ export function LaneChangesPanel({
>
{t(amend ? "changes.amendAndPush" : "changes.commitAndPush")}
</button>
<button
type="button"
data-testid="lane-changes-discard-all"
disabled={busy}
onClick={() => void discardAll()}
title={t("changes.discardAllTitle")}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-3 w-3" aria-hidden />
{t("changes.discardAll")}
</button>
</div>
</div>
)}
@@ -9,8 +9,9 @@
* first, and a session some tab already holds opens THAT tab instead of a
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
* message is pasted there (never sent: Enter is the user's), with a toast; while
* it is live the composer is off. Only Claude and Codex can resume a session by id —
* any other tool's session offers a new session from the workspace home
* it is live the composer is off. Only Claude, Codex and a catalog tool with
* `resumeById` (omp) can resume a session by id — any other tool's session
* offers a new session from the workspace home
* instead. The header carries the transcript page's Resume/Fork
* (`SessionResumeButton`, behind its warning). The session's tool and folder
* come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the
@@ -32,6 +33,7 @@ import { toast } from "../../lib/toast";
import i18n from "../../i18n";
import { liveRunFor } from "./WorkspaceSessionList";
import { SessionResumeButton } from "./SessionResumeButton";
import { useAiTools } from "../../hooks/useAiTools";
/** A message for a session that a tab already holds goes into THAT tab as a
* paste, once its terminal is on screen (it mounts after the navigation);
@@ -47,6 +49,7 @@ function pasteWhenShown(runId: string, text: string) {
}
/** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */
// Before the catalog loads: the two hand-written launchers resume by id.
const RESUMABLE = new Set(["claude", "codex"]);
export function SessionReplay({
@@ -65,6 +68,7 @@ export function SessionReplay({
}) {
const { t } = useTranslation("lanes");
const navigate = useNavigate();
const { byId } = useAiTools();
const [session, setSession] = useState<Session | null>(null);
const [prompt, setPrompt] = useState("");
const [images, setImages] = useState<PromptImage[]>([]);
@@ -88,9 +92,9 @@ export function SessionReplay({
}, [sessionId]);
const tool = session?.tool || "claude";
const resumable = RESUMABLE.has(tool);
const resumable = byId(tool)?.resumeById ?? RESUMABLE.has(tool);
const livePane = liveRunFor(lane, liveRuns, sessionId);
const text = promptWithImages(prompt, images);
const text = promptWithImages(prompt, images, tool === "claude");
const send = async () => {
if (!text || busy || uploading || livePane || !resumable) return;
@@ -1,7 +1,8 @@
/**
* @file SessionResumeButton.tsx
* @description The transcript page's explicit Resume and Fork for a Claude
* session inside a workspace. Opening a session never resumes it; this is the
* session inside a workspace (Resume only for a catalog tool that resumes by
* id — omp's `--resume <id>`). Opening a session never resumes it; this is the
* one place that does, behind a warning, because a resume re-reads the whole
* transcript into context. Fork resumes it into a NEW session id
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
@@ -26,6 +27,7 @@ import { ConfirmModal } from "../ConfirmModal";
import { startInFreeTabDetailed } from "./startSession";
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
import { usePermissions } from "../AuthGate";
import { useAiTools } from "../../hooks/useAiTools";
/** A path without its trailing separators — `/` or a Windows `\`. */
const strip = (p: string) => p.replace(/[\\/]+$/, "");
@@ -54,6 +56,7 @@ export function SessionResumeButton({
const navigate = useNavigate();
const { lanes } = useLanes();
const { canEditLane } = usePermissions();
const { byId } = useAiTools();
const [runs, setRuns] = useState<RunHandle[] | null>(null);
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
const [busy, setBusy] = useState(false);
@@ -70,12 +73,13 @@ export function SessionResumeButton({
};
}, [session.id]);
// Claude only: a Codex session id is not what
// `codex resume` takes for every kind of thread.
// Claude, or a catalog tool that resumes by id (no launcher of its own): a
// Codex session id is not what `codex resume` takes for every kind of thread.
const tool = session.tool || "claude";
const catalogResume = !!byId(tool)?.resumeById && !byId(tool)?.launcher;
// The workspace that owns it, else the one whose folder holds it.
const lane =
tool !== "claude" || !session.cwd
(tool !== "claude" && !catalogResume) || !session.cwd
? null
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
owningLane(session.cwd, tool, lanes));
@@ -140,7 +144,7 @@ export function SessionResumeButton({
{t("sessions.resume")}
</button>
)}
{resumable && (
{resumable && tool === "claude" && (
<button
type="button"
onClick={() => setConfirming("fork")}
+28 -16
View File
@@ -11,7 +11,7 @@
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
* **Show more** past the first page, and the sidebar row's Rename/Archive in
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
@@ -20,7 +20,6 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { Checkbox } from "../Checkbox";
import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm";
import { useEffect, useRef, useState } from "react";
@@ -34,6 +33,7 @@ import {
MoreHorizontal,
ArrowUp,
SquareTerminal,
Zap,
} from "lucide-react";
import {
api,
@@ -76,7 +76,7 @@ export {
} from "./startSession";
import { startInFreeTab } from "./startSession";
import { WorkspaceContext } from "./WorkspaceContext";
import { readDefaultMode } from "../../lib/sessionDefaults";
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
/** Recents' page size; Show more adds another. */
const RECENT_PAGE = 30;
@@ -165,12 +165,14 @@ function HomeBody({
// Claude and Codex always take a model and an effort; a catalog tool a
// model only when its file names the flag, an effort never.
const hasModel = claude || codex || !!entry?.modelFlag;
// The account's own default (first-run setup) — never bypass.
// The account's own defaults (first-run setup, Settings → AI tools →
// Session defaults); a pick here is for this session only.
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
const [approval, setApproval] = useState<CodexApproval>("on-request");
// Per session, never remembered.
const [sandbox, setSandbox] = useState<CodexSandbox>(() => readCodexDefaults().sandbox);
const [approval, setApproval] = useState<CodexApproval>(() => readCodexDefaults().approval);
// Off for every tool but Codex, which starts from the account's default.
const [bypass, setBypass] = useState(false);
useEffect(() => setBypass(codex && readCodexDefaults().bypass), [codex]);
const canBypass = !claude && !!entry?.bypass;
const [effort, setEffort] = useState<EffortLevel>("");
const hasEffort = claude || codex;
@@ -254,7 +256,7 @@ function HomeBody({
}
};
const text = promptWithImages(prompt, images);
const text = promptWithImages(prompt, images, claude);
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.
@@ -607,15 +609,25 @@ function HomeBody({
</div>
)}
{canBypass && (
<span className="px-2">
<Checkbox
tone="danger"
checked={bypass}
onChange={setBypass}
label={t("run:fields.bypass")}
labelClassName="text-xs text-status-danger"
// A chip like the selects beside it: quiet when off, red when on.
<button
type="button"
aria-pressed={bypass}
data-testid="workspace-home-bypass"
title={t(codex ? "run:fields.yoloHint" : "run:fields.bypass")}
onClick={() => setBypass(!bypass)}
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-status-danger/60 ${
bypass
? "bg-status-danger/10 font-medium text-status-danger ring-1 ring-inset ring-status-danger/30 hover:bg-status-danger/20"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
<Zap
className={`h-3.5 w-3.5 ${bypass ? "fill-current" : "text-fg-muted"}`}
aria-hidden
/>
</span>
{t(codex ? "run:fields.yolo" : "run:fields.bypassShort")}
</button>
)}
</div>
{danger && (
@@ -786,9 +786,25 @@ describe("AddLaneModal — Clone", () => {
)
);
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].partial).toBeUndefined();
await waitFor(() => expect(onAdded).toHaveBeenCalled());
});
it("sends partial only when the box is ticked", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: laneFixture({ id: 7 }),
created: true,
path: "/home/an/code/big",
});
const user = userEvent.setup();
await fillClone(user, "https://git.example.com/o/big.git");
await user.click(screen.getByTestId("add-lane-clone-partial"));
await user.click(screen.getByRole("button", { name: "Add workspace" }));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ partial: true }))
);
});
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
vi.mocked(api.lanes.clone)
.mockRejectedValueOnce(
@@ -0,0 +1,79 @@
/**
* @file Tests for CloneProgress: a queued clone shows its place in the
* server's clone queue, a running one git's phase, percent and speed with a
* bar, and nothing shows before the server knows the token. Also that
* `newCloneToken` makes a token the server accepts.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import "../../../i18n";
vi.mock("../../../lib/api", () => ({
api: { lanes: { cloneProgress: vi.fn() } },
}));
import { api, type CloneProgressView } from "../../../lib/api";
import { CloneProgress, newCloneToken } from "../CloneProgress";
const base: CloneProgressView = {
state: "running",
position: null,
running: 2,
waiting: 1,
limit: 2,
phase: null,
label: null,
percent: null,
detail: null,
};
describe("CloneProgress", () => {
// Braces: a function returned from beforeEach is run as its teardown.
beforeEach(() => {
vi.mocked(api.lanes.cloneProgress).mockReset();
});
it("shows the clone's place in the queue while it waits", async () => {
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
...base,
state: "queued",
position: 1,
});
render(<CloneProgress token="t" />);
expect(await screen.findByTestId("clone-progress")).toHaveTextContent(
"Waiting in the clone queue — #1 of 1 (2/2 clones running)"
);
});
it("shows git's phase, percent and speed with a bar while it runs", async () => {
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
...base,
phase: "receiving",
label: "Receiving objects",
percent: 45,
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
});
render(<CloneProgress token="t" />);
const line = await screen.findByTestId("clone-progress");
expect(line).toHaveTextContent("Downloading 45%");
expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s");
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
});
it("shows nothing while the server does not know the token yet", async () => {
vi.mocked(api.lanes.cloneProgress).mockImplementation(() =>
Promise.reject(Object.assign(new Error("no such clone"), { code: "ENOTFOUND" }))
);
render(<CloneProgress token="t" />);
await new Promise((r) => setTimeout(r, 20));
expect(screen.queryByTestId("clone-progress")).toBeNull();
});
it("makes tokens the server accepts", () => {
const a = newCloneToken();
expect(a).toMatch(/^[A-Za-z0-9_-]{16,64}$/);
expect(newCloneToken()).not.toBe(a);
});
});
@@ -1,7 +1,10 @@
/**
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
* an extra folder, a host that wants a login opens the login row, and Retry
* re-runs only the failed URLs with that login.
* re-runs only the failed URLs with that login. An SSH link while the
* account has no key is never sent — the user is pointed at Settings → Git —
* and an HTTPS host with no saved login is flagged before Clone. The
* partial-clone box adds `partial: true` to every clone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -11,16 +14,20 @@ import userEvent from "@testing-library/user-event";
import "../../../i18n";
vi.mock("../../../lib/api", () => ({
api: { lanes: { cloneFolder: vi.fn() } },
api: { lanes: { cloneFolder: vi.fn() }, gitIdentity: { get: vi.fn() } },
}));
import { api, type LaneSourcesView } from "../../../lib/api";
import { CloneSourcesForm } from "../CloneSourcesForm";
import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
describe("CloneSourcesForm", () => {
beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset());
beforeEach(() => {
vi.mocked(api.lanes.cloneFolder).mockReset();
// Unknown identity: nothing is checked up front.
vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server"));
});
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
const user = userEvent.setup();
@@ -53,9 +60,61 @@ describe("CloneSourcesForm", () => {
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
progressToken: expect.any(String),
url: "https://private.test/b.git",
credentials: { username: "dev", password: "tok", remember: true },
});
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
});
it("asks for partial clones when the box is ticked", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/big.git");
await user.click(screen.getByTestId("workspace-clone-partial"));
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() =>
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
progressToken: expect.any(String),
url: "https://h/big.git",
partial: true,
})
);
});
it("tells an SSH link from an HTTPS one", () => {
expect(cloneTarget("git@github.com:o/r.git")).toEqual({ kind: "ssh", host: "github.com" });
expect(cloneTarget("ssh://git@h.test:2222/o/r")).toEqual({ kind: "ssh", host: "h.test" });
expect(cloneTarget("https://h.test/o/r.git")).toEqual({ kind: "https", host: "h.test" });
expect(cloneTarget("not a url")).toBeNull();
});
it("does not clone an SSH link before the account has a key, and flags an HTTPS host with no login", async () => {
const user = userEvent.setup();
vi.mocked(api.gitIdentity.get).mockResolvedValue({
ssh: null,
sshKeys: [],
https: [],
} as unknown as GitIdentity);
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
await waitFor(() => expect(api.gitIdentity.get).toHaveBeenCalled());
await user.type(
screen.getByTestId("workspace-clone-input"),
"git@github.com:o/r.git\nhttps://h.test/a.git"
);
expect(screen.getByTestId("workspace-clone-need-ssh")).toHaveTextContent("github.com");
expect(screen.getByTestId("workspace-clone-need-login")).toHaveTextContent("h.test");
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
url: "https://h.test/a.git",
progressToken: expect.any(String),
});
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git");
});
});
@@ -21,11 +21,13 @@ import { confirmDialog } from "../../../lib/confirm";
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
source ? { lane, source } : session ? { lane, session } : lane,
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
api: {
lanes: {
list: vi.fn(),
clone: vi.fn(),
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
},
files: {
@@ -43,6 +45,8 @@ vi.mock("../../../lib/api", () => ({
restore: vi.fn(),
purge: vi.fn(),
upload: vi.fn(),
uploadBatch: vi.fn(),
uploadCheck: vi.fn(),
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
},
},
@@ -194,6 +198,41 @@ describe("FileExplorer", () => {
);
});
it("sends many small files as one batch, asking once about every taken name", async () => {
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
vi.mocked(api.files.uploadBatch).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const files = ["a.txt", "b.txt", "c.txt"].map((n) => new File(["hi"], n));
await user.upload(screen.getByTestId("fx-upload-files"), files);
const ask = await screen.findByTestId("fx-conflict");
await user.click(within(ask).getByRole("button", { name: "Replace" }));
await waitFor(() =>
expect(api.files.uploadBatch).toHaveBeenCalledWith(
1,
"",
files.map((file) => ({ file, rel: file.name })),
"replace",
expect.any(Function)
)
);
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
expect(api.files.upload).not.toHaveBeenCalled();
});
it("falls back to one request per file when the server cannot take a batch", async () => {
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
await user.upload(screen.getByTestId("fx-upload-files"), files);
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(2));
expect(api.files.uploadBatch).not.toHaveBeenCalled();
});
it("refuses a batch over 500 MB before sending anything", async () => {
open();
await screen.findByText("a.txt");
@@ -432,3 +471,87 @@ describe("FileExplorer", () => {
expect(api.files.remove).not.toHaveBeenCalled();
});
});
describe("FileExplorer — New → Clone repository", () => {
const openClone = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("button", { name: "New" }));
await user.click(screen.getByRole("menuitem", { name: "Clone repository" }));
return screen.getByTestId("fx-clone-dialog");
};
it("clones into the open folder of the workspace and says where", async () => {
vi.mocked(api.lanes.clone).mockResolvedValue({
lane: { id: 9, title: "repo", cwd: "/w/one/src/repo" },
created: true,
path: "/w/one/src/repo",
} as never);
const user = userEvent.setup();
open();
fireEvent.doubleClick(await screen.findByText("src"));
await screen.findByText("x.js");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
expect(within(dialog).getByText(/\/w\/one\/src\/repo/)).toBeInTheDocument();
await user.click(within(dialog).getByTestId("fx-clone-submit"));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenCalledWith({
url: "git@host:o/repo.git",
parent: "/w/one/src",
name: undefined,
})
);
expect(toast.success).toHaveBeenCalled();
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
});
it("asks for a login on ECREDENTIALS and sends it once", async () => {
vi.mocked(api.lanes.clone)
.mockRejectedValueOnce(
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
)
.mockResolvedValueOnce({
lane: { id: 9, title: "r", cwd: "/w/one/r" },
created: true,
path: "/w/one/r",
} as never);
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
const login = await within(dialog).findByTestId("fx-clone-login");
await user.type(within(login).getByLabelText("Username"), "ann");
await user.type(within(login).getByLabelText("Password or access token"), "tok");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
await waitFor(() =>
expect(api.lanes.clone).toHaveBeenLastCalledWith(
expect.objectContaining({
parent: "/w/one",
credentials: { username: "ann", password: "tok", remember: true },
})
)
);
});
it("keeps typing in the dialog away from the explorer's shortcuts", async () => {
vi.mocked(api.files.remove).mockResolvedValue({ trashed: [] });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
fireEvent.click(row("a.txt"));
const dialog = await openClone(user);
const url = within(dialog).getByLabelText("Repository URL");
await user.type(url, "abc{Backspace}{Delete}");
expect(api.files.remove).not.toHaveBeenCalled();
expect(url).toHaveValue("ab");
});
it("is not offered in an extra folder", async () => {
const user = userEvent.setup();
render(<FileExplorer open laneId={1} source={3} onClose={vi.fn()} onChanged={vi.fn()} />);
await screen.findByText("a.txt");
await user.click(screen.getByRole("button", { name: "New" }));
expect(screen.queryByRole("menuitem", { name: "Clone repository" })).toBeNull();
});
});
@@ -770,6 +770,24 @@ describe("LaneChangesPanel", () => {
});
});
it("discards every file at once only after the dialog is confirmed", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(screen.getByTestId("lane-changes-discard-all"));
expect(api.lanes.gitAction).not.toHaveBeenCalled();
await user.click(screen.getByTestId("lane-changes-discard-all"));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
confirm: true,
})
);
});
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -1062,6 +1080,20 @@ describe("LaneChangesPanel", () => {
);
});
it("does not offer Undo for a top commit its upstream already holds", async () => {
withParent();
vi.mocked(api.lanes.git).mockResolvedValue(
facts({ hasRemote: true, upstream: "origin/feat/thing", ahead: 0, behind: 0 })
);
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" }));
const undo = within(await commitMenu(user)).getByTestId("lane-changes-menu-undo-commit");
await waitFor(() => expect(undo).toBeDisabled());
expect(undo).toHaveAttribute("title", expect.stringMatching(/already on the remote/));
});
it("cherry-picks only from another branch's history", async () => {
withParent();
vi.mocked(api.lanes.git).mockResolvedValue(facts());
@@ -245,7 +245,9 @@ describe("WorkspaceHome", () => {
// As the Workspace page: `&run=` replaces the home with the session view.
function HomeUntilRun() {
const [params] = useSearchParams();
return params.get("run") ? null : <WorkspaceHome lane={lane()} onMenuAction={() => undefined} />;
return params.get("run") ? null : (
<WorkspaceHome lane={lane()} onMenuAction={() => undefined} />
);
}
const user = userEvent.setup();
render(
@@ -29,9 +29,9 @@ import { PetSettings } from "../settings/PetSettings";
import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings";
import { markOnboarded, useOnboarding } from "../../lib/userPrefs";
import {
DEFAULT_MODES,
readDefaultMode,
readDefaultTool,
SAFE_MODES,
writeDefaultMode,
writeDefaultTool,
type DefaultMode,
@@ -310,7 +310,7 @@ function AgentStep() {
writeDefaultMode(m);
setMode(m);
}}
options={DEFAULT_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
options={SAFE_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
/>
</div>
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
@@ -2,7 +2,7 @@
* @file Onboarding.test.tsx
* @description The first-run setup (docs/plans/onboarding): the account's
* preferences are loaded into localStorage on sign-in and every later change
* is sent back; the starting choices never hold bypass; the wizard shows only
* is sent back; the wizard never offers bypass (a Settings default may hold it); the wizard shows only
* for an account that is new to it, writes each pick like Settings does and
* ends in the tour; the tour follows the real UI step by step.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -55,7 +55,7 @@ vi.mock("../../settings/PetSettings", () => ({
}));
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults";
import { readDefaultMode, SAFE_MODES, writeDefaultMode } from "../../../lib/sessionDefaults";
import { OnboardingWizard } from "../OnboardingWizard";
import { OnboardingTour, startTour } from "../OnboardingTour";
import { __resetAiTools } from "../../../hooks/useAiTools";
@@ -98,10 +98,12 @@ describe("account preferences", () => {
}
});
it("never keeps bypass as the starting permission mode", () => {
it("keeps a bypass default from Settings, but the pet chat's safe read and the wizard never offer it", () => {
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
expect(readDefaultMode()).toBe("auto");
writeDefaultMode("bypassPermissions" as never);
expect(readDefaultMode()).toBe("bypassPermissions");
expect(readDefaultMode({ safe: true })).toBe("auto");
expect(SAFE_MODES).not.toContain("bypassPermissions");
writeDefaultMode("nonsense" as never);
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
writeDefaultMode("plan");
expect(readDefaultMode()).toBe("plan");
+34 -2
View File
@@ -1,7 +1,10 @@
/**
* @file ChatAddMenu.tsx
* @description The Chat view's `+` menu, Claude Desktop's: Upload or add
* files (the prompt box's own picker), what is already there to attach —
* files (the prompt box's own picker), Choose a file on this computer (a
* browser on the dashboard's own machine only: the OS's own picker,
* `POST /api/lanes/pick-path`, attaches the file by its path — any size,
* nothing uploaded), what is already there to attach —
* Project files (a search of the box's folder, folders too:
* `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context
* files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's,
@@ -21,6 +24,7 @@ import {
ChevronRight,
FileText,
FolderOpen,
HardDrive,
History,
Paperclip,
Plug,
@@ -29,7 +33,14 @@ import {
Settings2,
} from "lucide-react";
import { api } from "../../lib/api";
import { projectAttachment, recentUploads, type Attachment } from "../../lib/promptAttach";
import {
isLocalBrowser,
localAttachment,
projectAttachment,
recentUploads,
type Attachment,
} from "../../lib/promptAttach";
import { toast } from "../../lib/toast";
interface Entry {
name: string;
@@ -244,6 +255,17 @@ export function ChatAddMenu({
close();
onAttach([item]);
};
const pickLocal = async () => {
close();
// The dialog opens on the desktop, sometimes behind this window.
toast.info(t("chat.pickLocalOpen"));
try {
const r = await api.lanes.pickPath("file", cwd || undefined);
if (r.path) onAttach([localAttachment(r.path)]);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
const manage = (tab: "skills" | "plugins") => {
close();
navigate(`/cc-config?tab=${tab}`);
@@ -291,6 +313,16 @@ export function ChatAddMenu({
onUpload();
}}
/>
{isLocalBrowser() && (
<Row
icon={<HardDrive className="h-4 w-4" />}
label={t("chat.pickLocal")}
title={t("chat.pickLocalHint")}
testId="chat-add-pick-local"
onHover={() => setSub(null)}
onClick={() => void pickLocal()}
/>
)}
<Row
icon={<FolderOpen className="h-4 w-4" />}
label={t("chat.projectFiles")}
+295 -39
View File
@@ -18,15 +18,40 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef, useState, type DragEvent } from "react";
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { Copy, FileText, Loader2, X } from "lucide-react";
import {
ChevronLeft,
ChevronRight,
Copy,
Download,
FileText,
Loader2,
Maximize2,
Minimize2,
Share2,
X,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { copyText } from "../../lib/clipboard";
import { useDialog } from "../../hooks/useDialog";
import { dragSkill } from "../../lib/promptAttach";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { MarkdownPreview } from "../MarkdownPreview";
import { CodeBlock } from "../conversation/CodeBlock";
import { useTheme } from "../../hooks/useTheme";
import { toast } from "../../lib/toast";
import type { TranscriptMessage } from "../../lib/types";
import {
RENDERED,
artifactDocument,
artifactKind,
artifactLang,
artifactVersions,
frameSandbox,
parseCsv,
type ArtifactKind,
} from "../../lib/artifactView";
/** Where a Chat pane's files can be read from. */
export interface PreviewScope {
@@ -146,33 +171,89 @@ function useContent(path: string, scope: PreviewScope): Content {
return content;
}
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
/** A CSV/TSV as a table (the first rows; a file is not a spreadsheet). */
function CsvTable({ text, tab }: { text: string; tab: boolean }) {
const rows = useMemo(() => parseCsv(text, tab ? "\t" : ",").slice(0, 1000), [text, tab]);
const [head, ...body] = rows;
return (
<div className="min-h-0 flex-1 overflow-auto p-3">
<table className="w-full border-collapse text-xs">
{head && (
<thead className="sticky top-0 bg-surface-2">
<tr>
{head.map((h, i) => (
<th
key={i}
className="border border-border px-2 py-1 text-left font-medium text-fg-primary"
>
{h}
</th>
))}
</tr>
</thead>
)}
<tbody>
{body.map((r, i) => (
<tr key={i} className="odd:bg-surface-1 even:bg-surface-2/40">
{r.map((c, j) => (
<td key={j} className="border border-border px-2 py-1 text-fg-secondary">
{c}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
/** An HTML, SVG, Mermaid or React artifact, running in its own sandbox:
* scripts (none for an SVG) but never the dashboard's origin. */
function ArtifactFrame({ kind, text, title }: { kind: ArtifactKind; text: string; title: string }) {
const { theme } = useTheme();
const doc = useMemo(() => artifactDocument(kind, text, theme === "dark"), [kind, text, theme]);
return (
<iframe
data-testid="artifact-frame"
title={title}
srcDoc={doc ?? ""}
sandbox={frameSandbox(kind)}
referrerPolicy="no-referrer"
className={`min-h-0 w-full flex-1 border-0 ${kind === "html" || kind === "react" ? "bg-white" : ""}`}
/>
);
}
type View = "preview" | "code";
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
const { t } = useTranslation("run");
const loaded = useContent(path, scope);
const kind = artifactKind(path);
// An image the route refused (gone, server mode) shows like no preview.
const [broken, setBroken] = useState<string | null>(null);
const content: Content =
loaded.kind === "image" && loaded.src === broken ? { kind: "none" } : loaded;
if (content.kind === "loading") {
const shown: Content =
content.kind === "image" && content.src === broken ? { kind: "none" } : content;
if (shown.kind === "loading") {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
</div>
);
}
if (content.kind === "image") {
if (shown.kind === "image") {
return (
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
<img
src={content.src}
src={shown.src}
alt={baseName(path)}
onError={() => setBroken(content.src)}
onError={() => setBroken(shown.src)}
className="max-h-full max-w-full object-contain"
/>
</div>
);
}
if (content.kind === "none") {
if (shown.kind === "none") {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
<FileText className="h-8 w-8" />
@@ -181,69 +262,244 @@ function Body({ path, scope }: { path: string; scope: PreviewScope }) {
</div>
);
}
return (
<div className="min-h-0 flex-1 overflow-auto">
{isMarkdown(path) ? (
<MarkdownPreview text={content.text} className="p-4" />
) : (
<pre className="p-4 font-mono text-xs leading-relaxed text-fg-secondary">
{content.text}
</pre>
)}
{content.truncated && (
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
)}
</div>
const note = shown.truncated && (
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
);
if (view === "code" || !RENDERED.has(kind)) {
return (
<div className="min-h-0 flex-1 overflow-auto p-3">
<CodeBlock code={shown.text} lang={artifactLang(path)} maxHeight="none" showLineNumbers />
{note}
</div>
);
}
if (kind === "markdown") {
return (
<div className="min-h-0 flex-1 overflow-auto">
<MarkdownPreview text={shown.text} className="p-4" />
{note}
</div>
);
}
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
}
/** One file: header (name · EXT, Copy path, Close) over its content. */
const iconBtn =
"flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40";
/** Saves text (or an image's URL) as a file through the browser. */
function download(name: string, content: Content) {
const a = document.createElement("a");
if (content.kind === "image") a.href = content.src;
else if (content.kind === "text")
a.href = URL.createObjectURL(new Blob([content.text], { type: "text/plain;charset=utf-8" }));
else return;
a.download = name;
a.click();
if (content.kind === "text") setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
/** One file, Claude Desktop's artifact view: name · EXT, Preview | Code, the
* versions the conversation made of it, Copy, Download, Share to the
* Artifacts Hub, full screen, Close. */
export function FilePreview({
path,
scope,
onClose,
className = "",
messages,
}: {
path: string;
scope: PreviewScope;
onClose: () => void;
className?: string;
/** The conversation, for the versions its Write/Edit calls left. */
messages?: TranscriptMessage[];
}) {
const { t } = useTranslation("run");
return (
const kind = artifactKind(path);
const loaded = useContent(path, scope);
const [view, setView] = useState<View>(RENDERED.has(kind) ? "preview" : "code");
const [full, setFull] = useState(false);
const [sharing, setSharing] = useState(false);
const versions = useMemo(
() => (messages ? artifactVersions(messages, path) : []),
[messages, path]
);
// null: the newest — the file as it is now.
const [picked, setPicked] = useState<number | null>(null);
// Another file opens on its own default tab, at its newest version.
useEffect(() => {
setPicked(null);
setView(RENDERED.has(artifactKind(path)) ? "preview" : "code");
}, [path]);
const last = Math.max(versions.length - 1, 0);
const at = picked ?? last;
const older = picked !== null && picked < last ? versions[picked] : null;
const content: Content =
older && older.content !== null
? { kind: "text", text: older.content, truncated: false }
: loaded;
const known = (i: number) => i === last || versions[i]?.content != null;
const step = (dir: -1 | 1) => {
for (let i = at + dir; i >= 0 && i <= last; i += dir) {
if (known(i)) return setPicked(i === last ? null : i);
}
};
const share = async () => {
if (sharing) return;
setSharing(true);
try {
const file = slashed(path);
const base = slashed(scope.laneCwd).replace(/\/+$/, "");
if (scope.laneId !== null && base && file.startsWith(`${base}/`) && picked === null) {
await api.artifacts.pinFile({
laneId: scope.laneId,
path: file.slice(base.length + 1),
title: displayName(path),
...(scope.sessionId ? { sessionId: scope.sessionId } : {}),
});
} else if (content.kind === "text") {
await api.artifacts.uploadFile(new File([content.text], displayName(path)), {
title: displayName(path),
});
} else throw new Error(t("chat.artifact.cannotShare"));
toast.success(t("chat.artifact.shared"));
} catch (err) {
toast.error(err);
} finally {
setSharing(false);
}
};
const panel = (
<section
data-testid="chat-file-preview"
className={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`}
className={
full
? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
: `flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`
}
>
<header className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}>
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
<button
type="button"
onClick={() => void copyText(path)}
title={t("chat.outline.copyPath", { path })}
className="min-w-0 truncate text-left text-sm text-fg-primary hover:underline"
>
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
</span>
<span className="text-xs text-fg-muted">{extOf(path)}</span>
<div className="ml-auto flex items-center gap-1">
</button>
<span className="flex-shrink-0 text-xs text-fg-muted">{extOf(path)}</span>
{versions.length > 1 && (
<span
data-testid="artifact-versions"
className="flex flex-shrink-0 items-center text-xs text-fg-muted"
>
<button
type="button"
onClick={() => step(-1)}
disabled={![...Array(at).keys()].some(known)}
aria-label={t("chat.artifact.older")}
title={t("chat.artifact.older")}
className={iconBtn}
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<span className="tabular-nums">
{t("chat.artifact.version", { n: at + 1, total: versions.length })}
</span>
<button
type="button"
onClick={() => step(1)}
disabled={at >= last}
aria-label={t("chat.artifact.newer")}
title={t("chat.artifact.newer")}
className={iconBtn}
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</span>
)}
<div className="ml-auto flex flex-shrink-0 items-center gap-1">
{RENDERED.has(kind) && kind !== "image" && (
<div role="tablist" className="mr-1 flex rounded-md border border-border p-0.5 text-xs">
{(["preview", "code"] as const).map((v) => (
<button
key={v}
type="button"
role="tab"
aria-selected={view === v}
data-testid={`artifact-tab-${v}`}
onClick={() => setView(v)}
className={`rounded px-2 py-0.5 ${view === v ? "bg-surface-3 text-fg-primary" : "text-fg-muted hover:text-fg-primary"}`}
>
{t(`chat.artifact.${v}`)}
</button>
))}
</div>
)}
<button
type="button"
onClick={() => void copyText(path)}
title={t("chat.outline.copyPath", { path })}
aria-label={t("chat.outline.copyPath", { path })}
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
data-testid="artifact-copy"
onClick={() => void copyText(content.kind === "text" ? content.text : path)}
title={t("chat.artifact.copy")}
aria-label={t("chat.artifact.copy")}
className={iconBtn}
>
<Copy className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => download(displayName(path), content)}
disabled={content.kind !== "text" && content.kind !== "image"}
title={t("chat.artifact.download")}
aria-label={t("chat.artifact.download")}
className={iconBtn}
>
<Download className="h-3.5 w-3.5" />
</button>
<button
type="button"
data-testid="artifact-share"
onClick={() => void share()}
disabled={sharing || content.kind === "loading" || content.kind === "none"}
title={t("chat.artifact.share")}
aria-label={t("chat.artifact.share")}
className={iconBtn}
>
{sharing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Share2 className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
data-testid="artifact-expand"
onClick={() => setFull((f) => !f)}
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
className={iconBtn}
>
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={onClose}
title={t("chat.outline.close")}
aria-label={t("chat.outline.close")}
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
className={iconBtn}
>
<X className="h-4 w-4" />
</button>
</div>
</header>
<Body path={path} scope={scope} />
<Body path={path} content={content} view={view} />
</section>
);
// Full screen: over the whole page, where a pane's edges cannot clip it.
return full ? createPortal(panel, document.body) : panel;
}
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */
@@ -12,7 +12,8 @@
* new lane's own live session for its slot, or an empty pane — never the
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
* tab) attaches to that tmux session only; nothing is started or resumed.
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
* A pane of a `CHAT_TOOLS` tool (Claude, Codex, omp, Cursor, Antigravity) also
* has a Chat view (`PaneChat`): the session's
* conversation laid OVER the terminal, which stays mounted and attached under
* it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle
* left of the pane's split buttons); the Chat UI setting (Settings →
@@ -29,9 +30,7 @@ import type { Lane } from "../../lib/types";
import { useAiTools } from "../../hooks/useAiTools";
import { TerminalView } from "./TerminalView";
import { PaneChat } from "./PaneChat";
import { useSessionChat } from "../../lib/chatUi";
const CHAT_TOOLS = new Set(["claude", "codex"]);
import { CHAT_TOOLS, useSessionChat } from "../../lib/chatUi";
export interface LaneConsolePaneProps {
lanes: Lane[];
@@ -218,6 +217,7 @@ export function LaneConsolePane({
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
autoFocus={autoFocus && !(chatable && chat)}
covered={!!(chatable && chat)}
tool={handle.tool}
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
// the tab bar moves on (another tab, or the home's prompt box)
// rather than leaving a dead terminal the user has to click out of.
+339 -21
View File
@@ -15,7 +15,8 @@
* in the box beside `+`. A menu the CLI asks with — AskUserQuestion, a
* permission or trust prompt, Codex's approval — is read off the terminal
* underneath (`readRunMenu`) and shown in the prompt box's place as buttons
* that press its keys there (`PaneMenuCard`); its "Type something" row opens
* that press its keys there (`PaneMenuCard`) — a permission prompt with its tool, command and a deny-with-note
* (`PermissionMenuCard`); its "Type something" row opens
* a field in the card, whose text is typed there as the answer
* (`answerRunMenu`).
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
@@ -56,7 +57,17 @@
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
import {
ArrowUp,
Check,
ChevronLeft,
ChevronRight,
FileText,
MessageCircleQuestion,
ShieldQuestion,
Square,
SquareCheck,
} from "lucide-react";
import { isSubmitEnter } from "../../lib/keys";
import { ConversationView } from "../conversation/ConversationView";
import {
@@ -77,7 +88,11 @@ import { Divider, showTerminal } from "./PaneGrid";
import {
answerRunMenu,
chooseRunOption,
notePaneFocus,
onPaneOutput,
stepRunMenu,
interruptRun,
readRunBusy,
readRunMenu,
submitToRun,
type PaneMenu,
@@ -89,7 +104,9 @@ import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { setTabBadge } from "../../lib/tabBadge";
import { fmt } from "../../lib/format";
import { agentAwaitingReason } from "../../lib/types";
import { agentAwaitingReason, type TranscriptMessage, type WSMessage } from "../../lib/types";
import { eventBus } from "../../lib/eventBus";
import { throttle } from "../../lib/throttle";
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
const MODES: [PermissionMode, string][] = [
@@ -100,6 +117,11 @@ const MODES: [PermissionMode, string][] = [
["bypassPermissions", "Bypass"],
];
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
// Pane output re-reads the transcript at most this often: a TUI spinner
// redraws several times a second, and each read walks the transcript file.
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;
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}`;
@@ -117,6 +139,7 @@ const storedSide = () => {
* (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.
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;
return /^claude-(?:[23]|instant|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$)/.test(model)
? 200_000
@@ -125,7 +148,13 @@ 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;
@@ -212,33 +241,222 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission
);
}
/** A permission prompt (Claude's tool approval, Codex's command approval,
* the folder-trust question): it offers a Yes and a No. */
export function isPermissionMenu(menu: PaneMenu): boolean {
if (menu.multi || menu.tabs) return false;
const labels = menu.options.map((o) => o.label);
return labels.some((l) => /^Yes\b/i.test(l)) && labels.some((l) => /^No\b/i.test(l));
}
/** A menu row's label without its key hint (`Yes, proceed (y)`). */
const bareLabel = (label: string) => label.replace(/\s+\((?:[a-z]|esc|shift\+tab)\)$/i, "");
/** A permission prompt read off the terminal: the tool, what it will run, and the CLI's own
* answers as buttons — each still presses its key in the pane. "No, and
* tell … differently" also takes a note, sent as the next message once the
* CLI is back at its prompt. */
function PermissionMenuCard({
menu,
onChoose,
onDenyNote,
onCancel,
}: {
menu: PaneMenu;
onChoose: (index: number) => void;
onDenyNote: (index: number, note: string) => void;
onCancel: () => void;
}) {
const { t } = useTranslation("run");
const [noting, setNoting] = useState(false);
const [note, setNote] = useState("");
const q = menu.question.split("\n");
const title = menu.detail?.[0] ?? q[0] ?? "";
const body = (menu.detail ? menu.detail.slice(1) : q.slice(1)).join("\n");
const ask = menu.detail ? menu.question : "";
const yes = menu.options.findIndex((o) => /^Yes\b/i.test(o.label));
const tell = menu.options.findIndex((o) => /^No, and tell\b/i.test(o.label));
const sendNote = () => {
if (note.trim() && tell >= 0) onDenyNote(tell, note.trim());
};
return (
<div data-testid="pane-chat-permission" className="flex flex-col gap-2">
<div className="flex items-start gap-2">
<ShieldQuestion className="mt-0.5 h-4 w-4 flex-shrink-0 text-status-warning" />
<div className="min-w-0 flex-1">
<p className="whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
{title}
</p>
{ask && <p className="text-xs text-fg-muted">{ask}</p>}
</div>
</div>
{body && (
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-fg-secondary">
{body}
</pre>
)}
{noting ? (
<div className="flex items-center gap-2 rounded-lg border border-accent/50 bg-surface-2 px-3 py-1">
<input
autoFocus
data-testid="pane-chat-permission-note"
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
sendNote();
} else if (e.key === "Escape") setNoting(false);
}}
placeholder={t("chat.request.notePlaceholder")}
aria-label={t("chat.request.notePlaceholder")}
className="min-w-0 flex-1 bg-transparent py-0.5 text-sm text-fg-primary outline-none placeholder:text-fg-muted"
/>
<button
type="button"
onClick={sendNote}
disabled={!note.trim()}
className="btn-secondary text-xs disabled:opacity-40"
>
{t("chat.request.send")}
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
{menu.options.map((o, i) => (
<button
key={`${i}-${o.label}`}
type="button"
data-testid="pane-chat-permission-option"
autoFocus={i === yes}
onClick={() => onChoose(i)}
title={o.hint || undefined}
className={`${i === yes ? "btn-primary" : "btn-secondary"} max-w-full break-words text-left text-xs`}
>
{bareLabel(o.label)}
</button>
))}
{tell >= 0 && (
<button
type="button"
data-testid="pane-chat-permission-tell"
onClick={() => setNoting(true)}
className="text-xs text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
>
{t("chat.request.denyWithNote")}
</button>
)}
<button
type="button"
onClick={onCancel}
className="ml-auto rounded-md border border-border px-2 py-0.5 text-xs text-fg-muted hover:bg-surface-3"
>
{t("chat.menu.cancel")}
</button>
</div>
)}
</div>
);
}
/** The menu the CLI asks with, as buttons: a pick presses its key in the
* pane; its "Type something" row opens a field right there instead —
* pressing that row's digit again would type the digit into the CLI's own. */
* pressing that row's digit again would type the digit into the CLI's own.
* A permission prompt takes its own layout (`PermissionMenuCard`), any
* other menu the question layout. */
export function PaneMenuCard({
menu,
onChoose,
onAnswer,
onCancel,
onStep,
onDenyNote,
}: {
menu: PaneMenu;
onChoose: (index: number) => void;
onAnswer: (text: string) => void;
onCancel: () => void;
/** Previous (-1) / next (1) question of a multi-question AskUserQuestion. */
onStep?: (dir: -1 | 1) => void;
/** A permission prompt's "No, and tell …", with the note to send after it. */
onDenyNote?: (index: number, note: string) => void;
}) {
const { t } = useTranslation("run");
const [typing, setTyping] = useState(false);
const [draft, setDraft] = useState("");
const submit = () => draft.trim() && onAnswer(draft);
if (isPermissionMenu(menu))
return (
<div
data-testid="pane-chat-menu"
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
>
<PermissionMenuCard
menu={menu}
onChoose={onChoose}
onDenyNote={onDenyNote ?? ((i) => onChoose(i))}
onCancel={onCancel}
/>
</div>
);
return (
<div
data-testid="pane-chat-menu"
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
>
{menu.tabs && (
<div
data-testid="pane-chat-menu-tabs"
className="mb-2 flex flex-wrap items-center gap-1 text-xs"
>
{onStep && (
<button
type="button"
onClick={() => onStep(-1)}
aria-label={t("chat.menu.prev")}
title={t("chat.menu.prev")}
className="rounded-md border border-border p-0.5 text-fg-muted hover:bg-surface-3"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
)}
{menu.tabs.map((tab, i) => (
<span
key={`${i}-${tab.label}`}
className={`flex items-center gap-1 rounded-md border px-1.5 py-0.5 ${
i === menu.tab
? "border-accent/50 bg-surface-2 text-fg-primary"
: "border-border text-fg-muted"
}`}
>
{tab.done ? (
<Check className="h-3 w-3 text-accent" />
) : (
<Square className="h-3 w-3" />
)}
{tab.label}
</span>
))}
{onStep && (
<button
type="button"
onClick={() => onStep(1)}
aria-label={t("chat.menu.next")}
title={t("chat.menu.next")}
className="rounded-md border border-border p-0.5 text-fg-muted hover:bg-surface-3"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
{menu.question && (
<p className="mb-2 whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
{menu.question}
</p>
<div className="mb-2 flex items-start gap-2">
<MessageCircleQuestion className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
<p className="whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
{menu.question}
</p>
</div>
)}
<div className="flex flex-col gap-1">
{menu.options.map((o, i) =>
@@ -338,9 +556,10 @@ export const PaneChat = memo(function PaneChat({
const runId = run.id;
const claude = run.tool === "claude";
// The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`).
const [prompt, setPrompt] = useState(() => readChatDraft(sessionId).prompt);
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(sessionId).images);
useEffect(() => writeChatDraft(sessionId, prompt, images), [sessionId, prompt, images]);
const draftKey = sessionId;
const [prompt, setPrompt] = useState(() => readChatDraft(draftKey).prompt);
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(draftKey).images);
useEffect(() => writeChatDraft(draftKey, prompt, images), [draftKey, prompt, images]);
const [uploading, setUploading] = useState(false);
const [follow, setFollow] = useState(0);
const [model, setModel] = useState(run.model ?? "");
@@ -403,7 +622,11 @@ export const PaneChat = memo(function PaneChat({
// status filter ignored: check the status here.
.then((r) => {
const main = r.agents.filter((a) => a.type === "main");
setWorking(main.some((a) => a.status === "working"));
// A fresh start, resume or `/clear` leaves main "working" at an
// empty prompt (SessionStart's flag) until the first prompt.
setWorking(
main.some((a) => a.status === "working" && agentAwaitingReason(a) !== "session_start")
);
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
})
.catch(() => {}),
@@ -412,6 +635,46 @@ export const PaneChat = memo(function PaneChat({
useEffect(() => {
void readWorking();
}, [readWorking]);
// The terminal underneath drew something: the CLI is at work, so its
// transcript may have grown — read it now, for any tool, hooks or not. A
// spinner redraws all the time, hence the throttle.
useEffect(() => {
if (!sessionId) return;
const poke = throttle(
() =>
eventBus.publish({
type: "transcript_changed",
data: { session_id: sessionId },
} as unknown as WSMessage),
OUTPUT_FETCH_MS
);
const off = onPaneOutput(runId, poke);
return () => {
off();
poke.cancel();
};
}, [runId, sessionId]);
// The terminal's own word on a turn: its busy line (BUSY_LINE) while it
// shows, else — a tool whose line we do not know — output in the last
// OUTPUT_BUSY_MS. Hooks miss a tool that sends none (omp, Cursor, Codex
// without its opt-in hooks), and arrive late on a dashboard they skip.
const [screenBusy, setScreenBusy] = useState(false);
useEffect(() => {
let last = 0;
const off = onPaneOutput(runId, () => (last = Date.now()));
const read = () => {
const known = readRunBusy(runId, run.tool);
setScreenBusy(known ?? (last > 0 && Date.now() - last < OUTPUT_BUSY_MS));
};
read();
const timer = window.setInterval(() => {
if (isShown(onScreen)) read();
}, 700);
return () => {
off();
window.clearInterval(timer);
};
}, [runId, run.tool, onScreen]);
useLiveRefresh(readWorking, {
throttleMs: 500,
// While Stop shows, also poll: a missed agent_updated (the hook reached
@@ -440,6 +703,7 @@ export const PaneChat = memo(function PaneChat({
}, 700);
return () => window.clearInterval(timer);
}, [runId, onScreen]);
const busy = working || screenBusy;
const asking = waiting || menu !== null;
// Gone until the pane redraws: no second press on a stale menu.
const choose = (index: number) => {
@@ -452,15 +716,22 @@ export const PaneChat = memo(function PaneChat({
setFollow((n) => n + 1);
} else toast.error(t("chat.notSent"));
};
// "No, and tell … differently": the CLI drops back to its prompt, where
// the note is what the user would type next.
const denyWithNote = (index: number, note: string) => {
if (!menu || !chooseRunOption(runId, menu, index)) return void toast.error(t("chat.notSent"));
setMenu(null);
window.setTimeout(() => void command(note), 800);
};
// A hidden tab says when this session waits on the user or its turn ended.
const was = useRef({ working: false, asking: false });
useEffect(() => {
if (asking) setTabBadge(runId, "waiting");
else if (working) setTabBadge(runId, null);
else if (busy) setTabBadge(runId, null);
else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
was.current = { working, asking };
}, [runId, working, asking]);
was.current = { working: busy, asking };
}, [runId, busy, asking]);
useEffect(() => () => setTabBadge(runId, null), [runId]);
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
tokens: 0,
@@ -476,7 +747,20 @@ export const PaneChat = memo(function PaneChat({
// Stable, so typing in the prompt box does not re-render the conversation
// (ConversationView is memoized).
const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]);
const openTerminal = useCallback(
() => 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(
(named: string) => {
setPreview(resolveIn(cwd, named));
},
[cwd]
);
const openFile = useCallback(
(named: string) => {
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
@@ -514,7 +798,7 @@ export const PaneChat = memo(function PaneChat({
const send = async () => {
if (!text || uploading) return;
const out = promptForPane(prompt, images);
const out = promptForPane(prompt, images, claude);
if (!(await command(out.text, out.files))) return;
setFollow((n) => n + 1);
setPrompt("");
@@ -526,6 +810,8 @@ export const PaneChat = memo(function PaneChat({
<div
ref={rootRef}
data-prompt-drop
// The last chat focused is where the Changes panel's comments go.
onFocusCapture={() => notePaneFocus(runId)}
className="relative flex min-h-0 flex-1 [container-type:inline-size]"
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
>
@@ -542,6 +828,8 @@ export const PaneChat = memo(function PaneChat({
onContext={onContext}
onNotStarted={setNotStarted}
onOpenFile={openFile}
onOpenArtifact={openArtifact}
onMessages={setConvMessages}
/>
{!outlineOpen && !preview && (
<button
@@ -558,7 +846,7 @@ export const PaneChat = memo(function PaneChat({
)}
</div>
<div className="mx-4">
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
<div className="mx-auto w-full max-w-3xl">
{menu && (
<PaneMenuCard
// A new question starts with its field closed.
@@ -566,6 +854,10 @@ export const PaneChat = memo(function PaneChat({
menu={menu}
onChoose={choose}
onAnswer={answer}
onDenyNote={denyWithNote}
onStep={(dir) => {
if (!stepRunMenu(runId, dir)) toast.error(t("chat.notSent"));
}}
onCancel={() => {
if (interruptRun(runId)) setMenu(null);
else toast.error(t("chat.notSent"));
@@ -575,7 +867,7 @@ export const PaneChat = memo(function PaneChat({
{/* In the card's place while the CLI asks: hidden, never unmounted,
so a draft or an upload in flight survives the question. */}
<div
className={`rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50 ${menu ? "hidden" : ""}`}
className={`rounded-[1.25rem] border border-border bg-surface-1 p-2.5 shadow-[0_2px_12px_-4px_rgb(0_0_0/0.12)] transition-[border-color,box-shadow] focus-within:border-border-light focus-within:shadow-[0_4px_20px_-6px_rgb(0_0_0/0.2)] ${menu ? "hidden" : ""}`}
>
<PromptEditor
bare
@@ -602,6 +894,26 @@ export const PaneChat = memo(function PaneChat({
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
/>
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
{run.tool === "codex" &&
// Codex's `/model` (then its effort) and `/permissions` are
// numbered pickers: typed here, answered on the menu card.
(
[
["/permissions", t("fields.permissionMode")],
["/model", t("fields.model")],
] as const
).map(([line, label]) => (
<button
key={line}
type="button"
data-testid={`pane-chat-codex-${line.slice(1)}`}
onClick={() => void command(line)}
title={t("chat.codexPicker")}
className="rounded-md px-2 py-1 text-xs text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
{label}
</button>
))}
<div className="ml-auto flex items-center gap-1">
<ContextMeter tokens={context.tokens} model={context.model} />
{claude && (
@@ -617,7 +929,7 @@ export const PaneChat = memo(function PaneChat({
}}
/>
)}
{working && !text ? (
{busy && !text ? (
<button
type="button"
data-testid="pane-chat-stop"
@@ -630,8 +942,13 @@ export const PaneChat = memo(function PaneChat({
}}
aria-label={t("chat.stop")}
title={t("chat.stop")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
className="relative flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
>
{/* A turning ring: the turn is still going. */}
<span
aria-hidden
className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-accent"
/>
<Square className="h-3 w-3 fill-current" />
</button>
) : (
@@ -657,6 +974,7 @@ export const PaneChat = memo(function PaneChat({
<FilePreview
path={preview}
scope={scope}
messages={convMessages}
onClose={() => setPreview(null)}
// Beside the chat when the pane has room, over it when not.
className="absolute inset-x-0 top-0 bottom-[calc(100%-var(--chat-area,100%))] z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
+6 -6
View File
@@ -70,7 +70,7 @@ import {
} from "../../lib/paneLayout";
import { AiToolIcon } from "../AiToolIcon";
import { RunDot, type RunState } from "./ConsoleTabs";
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
import { CHAT_TOOLS, setSessionChat, useSessionChat } from "../../lib/chatUi";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
import { promptForPane } from "./RunSetup";
@@ -238,15 +238,15 @@ function PaneMenu({
const ITEM =
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
/** Chat UI ⇄ terminal for a `CHAT_TOOLS` pane — this session's own view
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
* unsent prompt goes along: the terminal's input box into the chat's draft,
* the draft (its files as paths) back into the input box, never sent. */
/** A session's chat switched to its terminal: the unsent prompt goes into
* the CLI's input box, without Enter. */
export function showTerminal(runId: string, sessionId: string) {
export function showTerminal(runId: string, sessionId: string, tool?: string) {
const draft = readChatDraft(sessionId);
const { text, files } = promptForPane(draft.prompt, draft.images);
const { text, files } = promptForPane(draft.prompt, draft.images, tool === "claude");
if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []);
setSessionChat(sessionId, false);
}
@@ -254,7 +254,7 @@ export function showTerminal(runId: string, sessionId: string) {
function ChatToggle({ run }: { run?: RunHandle }) {
const { t } = useTranslation("lanes");
const chat = useSessionChat(run?.sessionId);
if (!run?.sessionId || (run.tool !== "claude" && run.tool !== "codex")) return null;
if (!run?.sessionId || !CHAT_TOOLS.has(run.tool)) return null;
const sessionId = run.sessionId;
const label = chat ? t("panes.showTerminal") : t("panes.showChat");
return (
@@ -265,7 +265,7 @@ function ChatToggle({ run }: { run?: RunHandle }) {
title={label}
aria-label={label}
onClick={() => {
if (chat) return showTerminal(run.id, sessionId);
if (chat) return showTerminal(run.id, sessionId, run.tool);
const draft = readChatDraft(sessionId);
const text = takeRunInput(run.id);
if (text)
+91 -25
View File
@@ -11,7 +11,9 @@
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
* remembered for the + menu's Recent uploads), and a panel row (Files,
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
* as an `@path` chip — so is a local file a browser on the dashboard's
* own machine drops or pastes with its `file://` path, never uploaded —
* a `bare` one takes a drop anywhere on its box;
* its `/command` and `@path` tokens are coloured by a mirror laid over
* the textarea (`lib/promptTokens.ts`);
* a borderless (`bare`) one grows with its text up to 40vh;
@@ -67,9 +69,14 @@ import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
import {
ATTACH_MIME,
addAttachments,
claudeMention,
droppedAttachments,
droppedSkill,
droppedText,
isLocalBrowser,
localAttachment,
localCandidates,
sameFilesHere,
SKILL_MIME,
rememberUploads,
} from "../../lib/promptAttach";
@@ -203,24 +210,35 @@ interface PromptEditorProps {
export type PromptImage = { path: string; url?: string; name: string };
/** The prompt as sent: the text, then each file's saved path — which is how
* the CLI is handed an image or a file. */
export function promptWithImages(text: string, images: PromptImage[]): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
* the CLI is handed an image or a file; for `claude`, as `@` mentions. */
export function promptWithImages(text: string, images: PromptImage[], claude = false): string {
return [text.trim(), ...images.map((i) => (claude ? claudeMention(i.path) : i.path))]
.filter(Boolean)
.join(" ");
}
const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i;
/** The prompt as pasted into a live pane: the text with every non-image file
* in it (as `promptWithImages`), and the images' paths apart — each goes in
* a paste of its own, the one shape `claude` turns into `[Image #n]` on every
* platform (a Windows path inside a paste of text stayed a plain path). */
/** The prompt as pasted into a live pane. `claude`: one paste, every file an
* `@` mention, and a trailing space so a mention at the end does not leave
* the `@` file suggester open to swallow the Enter after it. Any other tool:
* the text with every non-image file in it, and the images' paths apart,
* each in a paste of its own (how `codex` attaches a pasted image path). */
export function promptForPane(
text: string,
images: PromptImage[]
images: PromptImage[],
claude = false
): { text: string; files: string[] } {
if (claude) {
const all = promptWithImages(text, images, true);
return { text: images.length ? `${all} ` : all, files: [] };
}
const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path);
return {
text: promptWithImages(text, images.filter((i) => !isImage(i))),
text: promptWithImages(
text,
images.filter((i) => !isImage(i))
),
files: images.filter(isImage).map((i) => i.path),
};
}
@@ -290,7 +308,7 @@ export function PromptEditor({
Promise.all([
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
api.ccConfig.plugins(fileCwd).catch(() => ({ plugins: [] })),
]).then(([c, sk, pl]) => {
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
// installed at both user and project scope is listed once.
@@ -390,7 +408,11 @@ export function PromptEditor({
// A pasted or dropped file is saved on the server's machine (the same
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
// its path is added to the prompt only when it is sent (promptWithImages).
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
const [upload, setUpload] = useState<"uploading" | "failed" | "tooBig" | "tooBigLocal" | null>(
null
);
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
const [limitMb, setLimitMb] = useState(0);
const [viewing, setViewing] = useState<PromptImage | null>(null);
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
const uploading = upload === "uploading";
@@ -401,20 +423,64 @@ export function PromptEditor({
const picked = Array.from(list ?? []);
if (picked.length === 0) return false;
setUpload("uploading");
Promise.all(picked.map((f) => api.run.dropFile(f, lane)))
.then((saved) => {
setUpload(null);
const added = picked.map((f, i) => ({
path: saved[i]!.path,
// Each file on its own: one refused file does not cost the others.
void Promise.allSettled(picked.map((f) => api.run.dropFile(f, lane))).then((results) => {
const added: PromptImage[] = [];
const errors: unknown[] = [];
results.forEach((r, i) => {
if (r.status === "rejected") return void errors.push(r.reason);
const f = picked[i]!;
added.push({
path: r.value.path,
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
name: f.name,
}));
});
});
if (added.length) {
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
keepError.current = errors.length > 0; // these came with the error
onImagesChange((prev) => [...prev, ...added]);
})
.catch(() => setUpload("failed"));
}
// Over the server's limit (ETOOBIG): refused before the body is sent.
const tooBig = errors
.map((e) => e as { code?: string; maxBytes?: number } | null)
.find((e) => e?.code === "ETOOBIG");
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
setUpload(
!errors.length ? null : !tooBig ? "failed" : isLocalBrowser() ? "tooBigLocal" : "tooBig"
);
});
return true;
};
/** Dropped or pasted files: by their own paths when the browser is on the
* dashboard's machine and the server finds the same files there (a file
* manager hands over `file://` paths beside the bytes), else uploaded. */
const attachDropped = (
dt: DataTransfer | null | undefined,
list: FileList | null | undefined
) => {
const files = Array.from(list ?? []);
if (files.length === 0) return false;
const paths = localCandidates(dt, files);
if (!paths) return attachImages(files);
setUpload("uploading");
void sameFilesHere(paths, files).then((same) => {
if (!same) return void attachImages(files);
setUpload(null);
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
});
return true;
};
// Anything attached since — a path, a panel row, a + menu pick — ends a past
// upload's error; not the files that uploaded beside the one refused.
const keepError = useRef(false);
const imageCount = useRef(images.length);
useEffect(() => {
const grew = images.length > imageCount.current;
imageCount.current = images.length;
if (grew && !keepError.current) setUpload((u) => (u === "uploading" ? u : null));
keepError.current = false;
}, [images.length]);
const removeImage = (img: PromptImage) => {
if (img.url) URL.revokeObjectURL(img.url);
onImagesChange((prev) => prev.filter((x) => x !== img));
@@ -443,7 +509,7 @@ export function PromptEditor({
} else if (skill) {
e.preventDefault();
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files)) e.preventDefault();
else {
// Only text (a file manager's or an editor's `file://` list, a
// selection, a link): what the terminal beneath would paste.
@@ -608,7 +674,7 @@ export function PromptEditor({
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
if (attachImages(e.clipboardData?.files)) e.preventDefault();
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
else {
// A very long paste goes in as a text file, like Claude Desktop:
// the prompt then carries its path, never the text (#68: a CLI's
@@ -652,10 +718,10 @@ export function PromptEditor({
</div>
{upload && (
<p
role={upload === "failed" ? "alert" : "status"}
className={`mt-1 text-[11px] ${upload === "failed" ? "text-status-danger" : "text-fg-muted"}`}
role={upload === "uploading" ? "status" : "alert"}
className={`mt-1 text-[11px] ${upload === "uploading" ? "text-fg-muted" : "text-status-danger"}`}
>
{t(`drop.${upload}`)}
{t(`drop.${upload}`, { mb: limitMb })}
</p>
)}
{kind && (
+103 -21
View File
@@ -16,8 +16,11 @@
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop, and how `claude` attaches an image or reads
* a file. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
* reads them when the prompt is sent — an image as its bare path, which claude
* shows as `[Image #n]` like a native drop; a browser on the dashboard's own
* machine pastes a file manager's files (dropped, or copied: `file://` text)
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
* pasted into whichever of the workspace's panes was focused last.
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
@@ -62,12 +65,24 @@ import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
import { api } from "../../lib/api";
import { writeClipboard } from "../../lib/clipboard";
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
import {
MENU_CURSOR_ROW,
notePaneFocus,
notePaneOutput,
registerPane,
} from "../../lib/panePaste";
import {
claudePastes,
droppedFilePaths,
isLocalBrowser,
localCandidates,
sameFilesHere,
} from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
type DropState = "hint" | "paste" | "uploading" | "failed";
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocal";
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
@@ -145,6 +160,15 @@ const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Fi
const hasText = (e: DragEvent) =>
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
/** The `file://` paths a paste carries as text — a file manager's copy — or
* null: its uri-list, else its plain text when every line is one. */
function copiedFilePaths(dt: DataTransfer | null): string[] | null {
const text = dt?.getData("text/plain") ?? "";
if (droppedFilePaths(dt)) return droppedFilePaths(dt);
if (!/^file:\/\//.test(text.trim())) return null;
return droppedFilePaths({ getData: () => text } as unknown as DataTransfer);
}
interface TerminalViewProps {
runId: string;
wsBaseUrl: string;
@@ -160,6 +184,10 @@ interface TerminalViewProps {
* so it is moved out of the viewport with a transform, which keeps its
* layout size (no PTY resize; the chat still reads and answers its menus). */
covered?: boolean;
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped image
* as its bare path and any other file as an `@` mention (`claudePastes`),
* any other tool its plain path. */
tool?: string;
}
/** Memoized: the Workspace re-renders its pane tree on every lane_update, and
@@ -170,6 +198,7 @@ export const TerminalView = memo(function TerminalView({
onExit,
autoFocus = true,
covered = false,
tool,
}: TerminalViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
// Read through a ref: an inline `onExit` from the parent changes identity on
@@ -193,6 +222,8 @@ export const TerminalView = memo(function TerminalView({
const tRef = useRef(t);
tRef.current = t;
const [drop, setDrop] = useState<DropState | null>(null);
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
const [limitMb, setLimitMb] = useState(0);
// The socket dropped after it had been up: keystrokes are not reaching tmux.
const [reconnecting, setReconnecting] = useState(false);
// Ctrl+M: Tab and Shift+Tab move focus out instead of reaching the pane.
@@ -207,10 +238,10 @@ export const TerminalView = memo(function TerminalView({
// only goes away when the pointer leaves the terminal itself.
const dragDepth = useRef(0);
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const flash = (state: DropState) => {
const flash = (state: DropState, ms = 2500) => {
setDrop(state);
if (flashTimer.current) clearTimeout(flashTimer.current);
flashTimer.current = setTimeout(() => setDrop(null), 2500);
flashTimer.current = setTimeout(() => setDrop(null), ms);
};
useEffect(
() => () => {
@@ -227,15 +258,18 @@ export const TerminalView = memo(function TerminalView({
e.preventDefault();
dragDepth.current = 0;
setDrop(null);
const local = isLocalBrowser() ? droppedFilePaths(e.dataTransfer) : null;
const text = e.dataTransfer.getData("text/plain");
if (text) termRef.current?.paste(text + " ");
if (local) pastePaths(local);
else if (text) termRef.current?.paste(text + " ");
termRef.current?.focus();
return;
}
if (!hasFiles(e)) return;
e.preventDefault();
dragDepth.current = 0;
void attachFiles(Array.from(e.dataTransfer.files));
const files = Array.from(e.dataTransfer.files);
void attachFiles(files, localCandidates(e.dataTransfer, files));
};
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
@@ -247,26 +281,60 @@ export const TerminalView = memo(function TerminalView({
const ctrlV = ctrlVRef.current;
ctrlVRef.current = false;
const files = Array.from(e.clipboardData?.files ?? []);
// A file manager's copied files reach a browser as `file://` text only.
const local = files.length === 0 && isLocalBrowser() ? copiedFilePaths(e.clipboardData) : null;
if (local) {
e.preventDefault();
e.stopPropagation();
pastePaths(local);
return;
}
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
e.preventDefault();
e.stopPropagation();
if (files.length > 0) void attachFiles(files);
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
else termRef.current?.input("\x16", true);
};
const attachFiles = async (files: File[]) => {
/** Pastes files' paths as a local terminal hands them over — paste(), not
* a raw send: it honours bracketed-paste mode, so the CLI sees a paste
* rather than typed keystrokes. A `claude` pane gets its images as bare
* paths, which it shows as `[Image #n]`, and other files as `@` mentions
* (`claudePastes`). */
const pastePaths = (paths: string[]) => {
const term = termRef.current;
if (!term) return;
for (const text of tool === "claude" ? claudePastes(paths) : [paths.join(" ")])
term.paste(text + " ");
term.focus();
};
/** Pastes the files' paths: their own (`local`, the `file://` paths a
* browser on the dashboard's machine is handed beside the bytes) once the
* server finds the same files there — nothing uploaded —, else the paths
* the uploads were saved at. A refused file does not cost the others. */
const attachFiles = async (files: File[], local: string[] | null = null) => {
if (files.length === 0) return;
setDrop("uploading");
try {
const saved = await Promise.all(files.map((f) => api.run.dropFile(f, runId)));
// paste(), not a raw send: it honours bracketed-paste mode, so claude
// sees pasted paths rather than typed keystrokes.
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
termRef.current?.focus();
setDrop(null);
} catch {
flash("failed");
let paths = local && (await sameFilesHere(local, files)) ? local : null;
const errors: unknown[] = [];
if (!paths) {
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
paths = results.flatMap((r) => {
if (r.status === "fulfilled") return [r.value.path];
errors.push(r.reason);
return [];
});
}
if (paths.length) pastePaths(paths);
// Over the server's limit (ETOOBIG): refused before the body is sent.
const tooBig = errors
.map((e) => e as { code?: string; maxBytes?: number } | null)
.find((e) => e?.code === "ETOOBIG");
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
if (!errors.length) setDrop(null);
else if (!tooBig) flash("failed");
else flash(isLocalBrowser() ? "tooBigLocal" : "tooBig", 6000); // a sentence to read
};
useEffect(() => {
@@ -554,6 +622,7 @@ export const TerminalView = memo(function TerminalView({
}
}
term.write(data);
notePaneOutput(runId);
}
};
sock.onclose = (e?: CloseEvent) => {
@@ -716,6 +785,17 @@ export const TerminalView = memo(function TerminalView({
rows.push(row);
}
return { rows, cols: term.cols };
},
// Plain text of the rows on screen, dim cells kept (a status line's
// `esc to interrupt` is dim): the chat's busy read.
() => {
const term = termRef.current;
if (!term) return [];
const buf = term.buffer.active;
const rows: string[] = [];
for (let y = 0; y < term.rows; y++)
rows.push(buf.getLine(buf.baseY + y)?.translateToString(true) ?? "");
return rows;
}
),
[runId]
@@ -872,10 +952,12 @@ export const TerminalView = memo(function TerminalView({
<div
data-testid="terminal-drop"
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
drop === "failed" || drop === "tooBig" || drop === "tooBigLocal"
? "border-red-400 text-red-300"
: "border-sky-400 text-sky-200"
}`}
>
{t(`drop.${drop}`)}
{t(`drop.${drop}`, { mb: limitMb })}
</div>
)}
</div>
@@ -2,13 +2,14 @@
* @file ChatAddMenu.test.tsx
* @description The `+` menu's existing-resource pickers: Project files (a
* search with folders, attached as `@path`), the workspace's Context files
* (files only, none missing) and this browser's Recent uploads.
* (files only, none missing), this browser's Recent uploads and, for a
* browser on the dashboard's machine, a file chosen in the OS's own picker.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
import { ChatAddMenu } from "../ChatAddMenu";
import { api } from "../../../lib/api";
@@ -21,6 +22,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
api: {
run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) },
lanes: {
pickPath: vi.fn().mockResolvedValue({ path: "/home/u/Videos/demo clip.mp4" }),
sources: vi.fn().mockResolvedValue({
sources: [
{ id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false },
@@ -61,6 +63,28 @@ beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => vi.unstubAllGlobals());
describe("ChatAddMenu — a file on this computer", () => {
it("attaches the file the OS picker chose by its path, uploading nothing", async () => {
const onAttach = renderMenu();
fireEvent.click(screen.getByTestId("chat-add-pick-local"));
expect(api.lanes.pickPath).toHaveBeenCalledWith("file", "/repo");
await vi.waitFor(() =>
expect(onAttach).toHaveBeenCalledWith([
{ path: '@"/home/u/Videos/demo clip.mp4"', name: "demo clip.mp4" },
])
);
});
it("is not offered to a browser on another machine", () => {
// Only a loopback address: a name that merely starts with "localhost" is not one.
vi.stubGlobal("location", { ...window.location, hostname: "localhost.example.com" });
renderMenu();
expect(screen.queryByTestId("chat-add-pick-local")).toBeNull();
});
});
describe("ChatAddMenu — existing resources", () => {
it("searches the project, folders included, and attaches a pick as @path", async () => {
const onAttach = renderMenu();
@@ -31,6 +31,10 @@ vi.mock("../../../lib/api", async (importOriginal) => {
}),
gitRawUrl: () => "",
},
artifacts: {
pinFile: vi.fn().mockResolvedValue({ item: { id: 1 } }),
uploadFile: vi.fn().mockResolvedValue({ item: { id: 2 } }),
},
sessions: {
readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}`,
readFile: vi.fn((_id: string, p: string) =>
@@ -75,7 +79,11 @@ const scope = { laneId: 4, laneCwd: "/w", cwd: "/w", sessionId: "s1" };
describe("FilePreview", () => {
it("reads a workspace file through the lane", async () => {
render(<FilePreview path="/w/src/a.ts" scope={scope} onClose={() => {}} />);
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
expect(
await screen.findByText(
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
)
).toBeTruthy();
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
});
@@ -89,7 +97,11 @@ describe("FilePreview", () => {
it("reads Windows paths the same way", async () => {
const win = { laneId: 4, laneCwd: "D:\\w", cwd: "D:\\w", sessionId: "s1" };
render(<FilePreview path={"D:\\w\\src\\a.ts"} scope={win} onClose={() => {}} />);
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
expect(
await screen.findByText(
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
)
).toBeTruthy();
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
cleanup();
const tool = "C:\\Users\\k\\.claude\\projects\\D--w\\s1\\tool-results\\b.txt";
@@ -144,7 +156,11 @@ describe("FileListModal", () => {
);
expect(screen.getByText("No preview for this file here")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "a.ts" }));
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
expect(
await screen.findByText(
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
)
).toBeTruthy();
});
});
@@ -172,3 +188,77 @@ describe("SkillListModal", () => {
expect(screen.getByText("No preview for this file here")).toBeTruthy();
});
});
describe("the artifact view", () => {
const html = (lines: string[]) =>
vi.mocked(api.lanes.gitFile).mockResolvedValueOnce({
available: true,
path: "page.html",
rev: "work",
binary: false,
truncated: false,
lines,
} as never);
it("runs a page in a sandbox with scripts but no same origin, and shows its code", async () => {
html(["<button>go</button>"]);
render(<FilePreview path="/w/page.html" scope={scope} onClose={() => {}} />);
const frame = (await screen.findByTestId("artifact-frame")) as HTMLIFrameElement;
expect(frame.getAttribute("sandbox")).toContain("allow-scripts");
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
expect(frame.getAttribute("srcdoc")).toContain("<button>go</button>");
fireEvent.click(screen.getByTestId("artifact-tab-code"));
expect(screen.queryByTestId("artifact-frame")).toBeNull();
expect(
await screen.findByText(
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("<button>go</button>")
)
).toBeTruthy();
cleanup();
});
it("steps back to an older version the conversation wrote", async () => {
html(["<p>two</p>"]);
const tool = (id: string, input: Record<string, unknown>, name = "Write") => ({
type: "assistant" as const,
timestamp: null,
content: [{ type: "tool_use" as const, id, name, input }],
});
const messages = [
tool("1", { file_path: "/w/page.html", content: "<p>one</p>" }),
tool("2", { file_path: "/w/page.html", old_string: "one", new_string: "two" }, "Edit"),
];
render(
<FilePreview
path="/w/page.html"
scope={scope}
onClose={() => {}}
messages={messages as never}
/>
);
const versions = await screen.findByTestId("artifact-versions");
expect(versions.textContent).toContain("2");
expect(
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
).toContain("two");
fireEvent.click(versions.querySelector("button")!);
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>");
cleanup();
});
it("saves a workspace file to the Artifacts library by its path", async () => {
html(["<p>x</p>"]);
render(<FilePreview path="/w/page.html" scope={scope} onClose={() => {}} />);
await screen.findByTestId("artifact-frame");
fireEvent.click(screen.getByTestId("artifact-share"));
await vi.waitFor(() =>
expect(api.artifacts.pinFile).toHaveBeenCalledWith({
laneId: 4,
path: "page.html",
title: "page.html",
sessionId: "s1",
})
);
cleanup();
});
});
@@ -7,9 +7,11 @@
* activity panel, collapsed into a file button with the output count, whose
* files open a preview in its place; a file in a turn's "Edited N files" card
* opens in the Workspace's Changes panel when it is in the pane's folder; and
* Stop in Send's place while the main agent works, pressing Esc in the pane;
* Stop in Send's place while the main agent works (its hooks, or the pane's
* own busy line / fresh output), pressing Esc in the pane;
* and the menu the CLI asks with, shown as buttons that press its keys, a
* message sent meanwhile typed as its "Type something" answer.
* message sent meanwhile typed as its "Type something" answer; a Codex
* terminal's Model / Permission mode buttons typing its own pickers.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -22,11 +24,14 @@ import {
answerRunMenu,
chooseRunOption,
interruptRun,
onPaneOutput,
readRunBusy,
readRunMenu,
submitToRun,
type PaneMenu,
} from "../../../lib/panePaste";
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
import { eventBus } from "../../../lib/eventBus";
vi.mock("../../conversation/ConversationView", () => ({
// Stands in for a turn's "Edited N files" card: a click names a file.
@@ -51,8 +56,12 @@ vi.mock("../../../lib/panePaste", () => ({
submitToRun: vi.fn(() => true),
interruptRun: vi.fn(() => true),
readRunMenu: vi.fn(() => null),
readRunBusy: vi.fn(() => false),
chooseRunOption: vi.fn(() => true),
answerRunMenu: vi.fn(() => true),
stepRunMenu: vi.fn(() => true),
notePaneFocus: vi.fn(),
onPaneOutput: vi.fn(() => () => {}),
}));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -212,6 +221,54 @@ describe("PaneChat — Stop", () => {
}
});
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 });
vi.mocked(readRunBusy).mockReturnValue(true);
try {
renderChat();
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
expect(readRunBusy).toHaveBeenCalledWith("agenthub-lane-1", "claude");
vi.mocked(readRunBusy).mockReturnValue(false);
await act(async () => {
await vi.advanceTimersByTimeAsync(800);
});
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
} 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);
const fns: (() => void)[] = [];
vi.mocked(onPaneOutput).mockImplementation((_runId, fn) => {
fns.push(fn);
return () => {};
});
try {
renderChat();
await waitFor(() => expect(fns.length).toBeGreaterThan(0));
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
fns.forEach((fn) => fn());
await act(async () => {
await vi.advanceTimersByTimeAsync(800);
});
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
// Quiet for longer than OUTPUT_BUSY_MS: at rest again.
await act(async () => {
await vi.advanceTimersByTimeAsync(2800);
});
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
} finally {
vi.useRealTimers();
vi.mocked(readRunBusy).mockReturnValue(false);
vi.mocked(onPaneOutput).mockImplementation(() => () => {});
}
});
it("keeps Send for a session at rest", async () => {
// With session_id the server answers EVERY agent of the session (its
// status filter does not apply): the main agent is there, waiting.
@@ -430,7 +487,7 @@ describe("PaneChat — the unsent prompt", () => {
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
expect(screen.getByText("a.ts")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts", []);
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts ", []);
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
});
@@ -462,7 +519,9 @@ describe("PaneChat — the unsent prompt", () => {
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith(
"agenthub-lane-1",
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts',
// A trailing space: an @ mention at the end would leave claude's file
// suggester open, and it takes the Enter.
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts ',
[]
);
});
@@ -551,4 +610,103 @@ describe("PaneChat — a menu the CLI asks with", () => {
vi.mocked(readRunMenu).mockReturnValue(null);
}
});
it("re-reads its transcript when the pane draws, at most once a second", async () => {
// Every listener the chat put on the pane (the re-read, the busy clock).
const fns: (() => void)[] = [];
const drew = () => fns.forEach((fn) => fn());
vi.mocked(onPaneOutput).mockImplementation((_runId, fn) => {
fns.push(fn);
return () => {};
});
const seen: unknown[] = [];
const off = eventBus.subscribe((m) => {
if (m.type === "transcript_changed") seen.push(m.data);
});
try {
renderChat();
await waitFor(() => expect(fns.length).toBeGreaterThan(0));
vi.useFakeTimers();
drew();
drew();
drew();
expect(seen).toEqual([{ session_id: "s1" }]);
await act(async () => {
await vi.advanceTimersByTimeAsync(1100);
});
expect(seen).toHaveLength(2);
} finally {
vi.useRealTimers();
off();
vi.mocked(onPaneOutput).mockImplementation(() => () => {});
}
});
const PERMISSION: PaneMenu = {
question: "Do you want to proceed?",
detail: ["Bash command", "npm test", "Run the tests"],
options: [
{ n: 1, label: "Yes", hint: "", checked: null },
{ n: 2, label: "Yes, and don't ask again for npm test", hint: "", checked: null },
{ n: 3, label: "No, and tell Claude what to do differently (esc)", hint: "", checked: null },
],
cursor: 0,
multi: false,
typeAt: -1,
};
it("lays a permission prompt out as a card, each answer its own key", async () => {
vi.mocked(readRunMenu).mockReturnValue(PERMISSION);
vi.mocked(chooseRunOption).mockClear();
try {
renderChat();
const card = await screen.findByTestId("pane-chat-permission");
expect(card).toHaveTextContent("Bash command");
expect(card).toHaveTextContent("npm test");
expect(card).toHaveTextContent("Do you want to proceed?");
const options = screen.getAllByTestId("pane-chat-permission-option");
// The key hint is dropped from the label.
expect(options.map((o) => o.textContent)).toEqual([
"Yes",
"Yes, and don't ask again for npm test",
"No, and tell Claude what to do differently",
]);
fireEvent.click(options[1]!);
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", PERMISSION, 1);
} finally {
vi.mocked(readRunMenu).mockReturnValue(null);
}
});
it("denies with a note: picks the No row, then sends the note as the next message", async () => {
vi.mocked(readRunMenu).mockReturnValue(PERMISSION);
vi.mocked(chooseRunOption).mockClear();
vi.mocked(submitToRun).mockClear();
try {
renderChat();
fireEvent.click(await screen.findByTestId("pane-chat-permission-tell"));
const field = screen.getByTestId("pane-chat-permission-note");
fireEvent.change(field, { target: { value: "use pnpm" } });
fireEvent.keyDown(field, { key: "Enter" });
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", PERMISSION, 2);
await waitFor(() => expect(submitToRun).toHaveBeenCalled(), { timeout: 2000 });
expect(vi.mocked(submitToRun).mock.calls[0]![1]).toContain("use pnpm");
} finally {
vi.mocked(readRunMenu).mockReturnValue(null);
}
});
});
describe("PaneChat — a Codex terminal", () => {
it("types /model and /permissions, whose pickers come back as menu cards", () => {
renderChat({ tool: "codex" });
fireEvent.click(screen.getByTestId("pane-chat-codex-model"));
fireEvent.click(screen.getByTestId("pane-chat-codex-permissions"));
expect(vi.mocked(submitToRun).mock.calls.map((c) => c[1])).toEqual(["/model", "/permissions"]);
});
it("offers neither to a Claude pane", () => {
renderChat();
expect(screen.queryByTestId("pane-chat-codex-model")).toBeNull();
});
});
@@ -56,7 +56,11 @@ vi.mock("../../../lib/api", async (importOriginal) => {
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
}),
},
run: { files: r({ items: [] }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
run: {
files: r({ items: [] }),
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
localPaths: r({ sizes: [1] }),
},
ccConfig: {
commands: r({
items: [
@@ -342,6 +346,120 @@ describe("PromptEditor — newlines and images", () => {
});
});
it("attaches a local file manager's files by their own paths, uploading nothing", async () => {
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value="read"
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("read", images)}</output>
</>
);
}
render(<Harness />);
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
const data: Record<string, string> = { "text/uri-list": "file:///home/u/demo.mp4" };
fireEvent.drop(box(), {
dataTransfer: { files: [big], types: ["Files"], getData: (k: string) => data[k] ?? "" },
});
expect(await screen.findByText("demo.mp4")).toBeTruthy();
expect(api.run.localPaths).toHaveBeenCalledWith(["/home/u/demo.mp4"]);
expect(api.run.dropFile).not.toHaveBeenCalled();
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
});
it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => {
vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] });
const onImagesChange = vi.fn();
render(
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={[]}
onImagesChange={onImagesChange}
/>
);
const doc = new File(["x"], "spec.pdf", { type: "application/pdf" });
const data: Record<string, string> = { "text/uri-list": "file:///Users/me/spec.pdf" };
fireEvent.drop(box(), {
dataTransfer: { files: [doc], types: ["Files"], getData: (k: string) => data[k] ?? "" },
});
await vi.waitFor(() => expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined));
});
it("keeps the files that uploaded when another one is refused", async () => {
vi.mocked(api.run.dropFile)
.mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/a-ok.txt" })
.mockRejectedValueOnce(Object.assign(new Error("too big"), { code: "ETOOBIG" }));
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
return (
<>
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={images}
onImagesChange={setImages}
/>
<output data-testid="sent">{promptWithImages("", images)}</output>
</>
);
}
render(<Harness />);
fireEvent.drop(box(), {
dataTransfer: { files: [new File(["x"], "ok.txt"), new File(["y"], "huge.iso")] },
});
expect(await screen.findByText("ok.txt")).toBeTruthy();
expect(screen.getByTestId("sent").textContent).toBe("/tmp/agenthub-drops-1/a-ok.txt");
expect(screen.getByRole("alert").textContent).toMatch(/too large/);
// Anything attached afterwards — here a Files panel row — ends the error.
const data: Record<string, string> = {
[ATTACH_MIME]: JSON.stringify({ path: "@src/a.ts", name: "a.ts" }),
};
fireEvent.drop(box(), { dataTransfer: { getData: (k: string) => data[k] ?? "" } });
expect(await screen.findByText("a.ts")).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
});
it("says a file over the server's limit is too big, and how to attach it", async () => {
vi.mocked(api.run.dropFile).mockRejectedValueOnce(
Object.assign(new Error("too big"), { code: "ETOOBIG", status: 413, maxBytes: 20971520 })
);
render(
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={[]}
onImagesChange={() => {}}
/>
);
fireEvent.drop(box(), { dataTransfer: { files: [new File(["x"], "huge.iso")] } });
expect((await screen.findByRole("alert")).textContent).toMatch(
/too large to upload \(max 20 MB\).*Choose a file on this computer/
);
});
it("attaches a Files panel row dropped on it as an @path chip, once", () => {
function Harness() {
const [images, setImages] = useState<PromptImage[]>([]);
@@ -593,7 +711,10 @@ describe("promptForPane — images apart, every other file in the text", () => {
// A Windows path inside a paste of text stayed a plain path in claude; a
// paste that is only the path becomes [Image #n].
const images: PromptImage[] = [
{ path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" },
{
path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
name: "image.png",
},
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
{ path: "/data/drops/0123456789abcdef.JPG", name: "b.jpg" },
@@ -606,4 +727,29 @@ describe("promptForPane — images apart, every other file in the text", () => {
],
});
});
it("hands claude every file as an @ mention in one paste, with a trailing space", () => {
// A bare pasted path stayed plain text on Windows, and a path in the
// command-line prompt is never an image; claude reads an @ mention at submit.
const images: PromptImage[] = [
{
path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
name: "image.png",
},
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
{ path: "C:\\Users\\Jo Do\\drops\\0123456789abcdef.png", name: "b.png" },
];
expect(promptForPane(" look ", images, true)).toEqual({
text:
"look @C:/Users/u/.claude/agenthub/drops/f326fc666d3bdd92-image.png " +
'@/data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG" ' +
'@"C:/Users/Jo Do/drops/0123456789abcdef.png" ',
files: [],
});
expect(promptForPane("just text", [], true)).toEqual({ text: "just text", files: [] });
expect(promptWithImages("see", [images[1]!], true)).toBe(
"see @/data/drops/0123456789abcdef-log.txt"
);
});
});
@@ -26,8 +26,14 @@ const inputMock = vi.fn();
// The newest terminal's hidden input, where the browser's IME types.
let textarea: HTMLTextAreaElement;
const dropFileMock = vi.fn();
const localPathsMock = vi.fn();
vi.mock("../../../lib/api", () => ({
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
api: {
run: {
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
localPaths: (paths: string[]) => localPathsMock(paths),
},
},
}));
const loadAddonMock = vi.fn();
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
@@ -512,6 +518,23 @@ describe("TerminalView", () => {
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
});
it("pastes a local file manager's own paths, uploading nothing", async () => {
dropFileMock.mockReset();
localPathsMock.mockReset().mockResolvedValue({ sizes: [1] });
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
const data: Record<string, string> = {
"text/uri-list": "file:///home/u/My%20Videos/demo.mp4",
};
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { types: ["Files"], files: [big], getData: (k: string) => data[k] ?? "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith('@"/home/u/My Videos/demo.mp4" '));
expect(localPathsMock).toHaveBeenCalledWith(["/home/u/My Videos/demo.mp4"]);
expect(dropFileMock).not.toHaveBeenCalled();
});
it("pastes every path of several dropped files, images or not", async () => {
dropFileMock
.mockReset()
@@ -0,0 +1,154 @@
/**
* @file SessionDefaultsSettings — Settings → AI tools → Session defaults: the
* account's own starting choices for a new Claude Code session (permission
* mode, Bypass included) and a new Codex session (sandbox, approval, bypass),
* which the workspace home's prompt box starts from (`lib/sessionDefaults.ts`,
* synced per account). Turning bypass on as a default asks first; a pick in
* the prompt box still changes only that one session.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ShieldAlert } from "lucide-react";
import type { CodexApproval, CodexSandbox } from "../../lib/api";
import { useAiTools } from "../../hooks/useAiTools";
import { confirmDialog } from "../../lib/confirm";
import {
CODEX_APPROVALS,
CODEX_SANDBOXES,
DEFAULT_MODES,
readCodexDefaults,
readDefaultMode,
writeCodexDefaults,
writeDefaultMode,
type DefaultMode,
} from "../../lib/sessionDefaults";
import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow } from "./SettingsRow";
const MODE_KEYS: Record<DefaultMode, string> = {
auto: "Auto",
acceptEdits: "AcceptEdits",
default: "Default",
plan: "Plan",
bypassPermissions: "Bypass",
};
const SANDBOX_KEYS: Record<CodexSandbox, string> = {
"read-only": "sandboxReadOnly",
"workspace-write": "sandboxWorkspaceWrite",
"danger-full-access": "sandboxFullAccess",
};
const APPROVAL_KEYS: Record<CodexApproval, string> = {
"on-request": "approvalOnRequest",
never: "approvalNever",
};
function Danger({ children }: { children: string }) {
return (
<p className="flex items-start gap-2 py-2 text-xs text-status-danger">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{children}
</p>
);
}
export function SessionDefaultsSettings() {
const { t } = useTranslation("settings");
const aiTools = useAiTools();
const shown = (id: string) => aiTools.tools.some((x) => x.id === id && x.enabled);
const [mode, setMode] = useState<DefaultMode>(() => readDefaultMode() as DefaultMode);
const [codex, setCodex] = useState(readCodexDefaults);
const askBypass = (tool: string) =>
confirmDialog({
title:
tool === "Codex"
? t("sessionDefaults.codexConfirmTitle")
: t("sessionDefaults.confirmTitle", { tool }),
message: t("sessionDefaults.confirmBody", { tool }),
confirmLabel: t("sessionDefaults.confirmLabel"),
});
const pickMode = async (m: DefaultMode) => {
if (m === "bypassPermissions" && !(await askBypass("Claude Code"))) return;
writeDefaultMode(m);
setMode(m);
};
const patchCodex = (patch: Partial<typeof codex>) => {
writeCodexDefaults(patch);
setCodex((c) => ({ ...c, ...patch }));
};
const pickCodexBypass = async (on: boolean) => {
if (on && !(await askBypass("Codex"))) return;
patchCodex({ bypass: on });
};
if (!shown("claude") && !shown("codex")) return null;
return (
<SettingsSection
id="session-defaults"
title={t("sessionDefaults.title")}
description={t("sessionDefaults.description")}
>
{shown("claude") && (
<>
<SettingsRow
label={t("sessionDefaults.claudeMode")}
description={t("sessionDefaults.claudeModeDesc")}
control={
<SettingsSegmented
ariaLabel={t("sessionDefaults.claudeMode")}
value={mode}
onChange={(m) => void pickMode(m)}
options={DEFAULT_MODES.map((m) => ({
value: m,
label: t(`lanes:home.permission${MODE_KEYS[m]}`),
}))}
/>
}
/>
{mode === "bypassPermissions" && <Danger>{t("run:hint.permissionWarning")}</Danger>}
</>
)}
{shown("codex") && (
<>
<SettingsRow
label={t("sessionDefaults.codexSandbox")}
control={
<SettingsSegmented
ariaLabel={t("sessionDefaults.codexSandbox")}
value={codex.sandbox}
onChange={(sandbox) => patchCodex({ sandbox })}
options={CODEX_SANDBOXES.map((v) => ({
value: v,
label: t(`run:fields.${SANDBOX_KEYS[v]}`),
}))}
/>
}
/>
<SettingsRow
label={t("sessionDefaults.codexApproval")}
control={
<SettingsSegmented
ariaLabel={t("sessionDefaults.codexApproval")}
value={codex.approval}
onChange={(approval) => patchCodex({ approval })}
options={CODEX_APPROVALS.map((v) => ({
value: v,
label: t(`run:fields.${APPROVAL_KEYS[v]}`),
}))}
/>
}
/>
<SettingsSwitchRow
label={t("sessionDefaults.codexBypass")}
description={t("sessionDefaults.codexBypassDesc")}
checked={codex.bypass}
onChange={(on) => void pickCodexBypass(on)}
/>
{codex.bypass && <Danger>{t("run:hint.bypassWarning", { tool: "Codex" })}</Danger>}
</>
)}
</SettingsSection>
);
}
@@ -0,0 +1,72 @@
/**
* @file SessionDefaultsSettings.test.tsx
* @description Session defaults: Bypass becomes Claude's default permission
* mode or Codex's default bypass only after the confirm, a cancelled confirm
* keeps the old default, the plain modes save at once, and the pet chat's
* safe read never returns bypass.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SessionDefaultsSettings } from "../SessionDefaultsSettings";
import { confirmDialog } from "../../../lib/confirm";
import { readCodexDefaults, readDefaultMode } from "../../../lib/sessionDefaults";
const aiTools = vi.hoisted(() => ({
tools: [
{ id: "claude", label: "Claude Code", installed: true, enabled: true },
{ id: "codex", label: "Codex", installed: true, enabled: true },
],
}));
vi.mock("../../../hooks/useAiTools", () => ({ useAiTools: () => aiTools }));
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn() }));
const confirm = vi.mocked(confirmDialog);
const radio = (name: RegExp) => screen.getAllByRole("radio", { name })[0]!;
describe("SessionDefaultsSettings", () => {
beforeEach(() => {
localStorage.clear();
confirm.mockReset();
});
it("saves a plain mode without asking", () => {
render(<SessionDefaultsSettings />);
fireEvent.click(radio(/plan/i));
expect(confirm).not.toHaveBeenCalled();
expect(readDefaultMode()).toBe("plan");
});
it("makes Bypass Claude's default only once confirmed", async () => {
confirm.mockResolvedValueOnce(false);
render(<SessionDefaultsSettings />);
fireEvent.click(radio(/bypass/i));
await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1));
expect(readDefaultMode()).toBe("auto");
confirm.mockResolvedValueOnce(true);
fireEvent.click(radio(/bypass/i));
await waitFor(() => expect(readDefaultMode()).toBe("bypassPermissions"));
expect(readDefaultMode({ safe: true })).toBe("auto");
});
it("turns Codex's default bypass on only once confirmed, off without asking", async () => {
confirm.mockResolvedValueOnce(true);
render(<SessionDefaultsSettings />);
const sw = screen.getByRole("switch");
fireEvent.click(sw);
await waitFor(() => expect(readCodexDefaults().bypass).toBe(true));
fireEvent.click(screen.getByRole("switch"));
await waitFor(() => expect(readCodexDefaults().bypass).toBe(false));
expect(confirm).toHaveBeenCalledTimes(1);
});
it("shows nothing when neither Claude nor Codex is enabled", () => {
const saved = aiTools.tools;
aiTools.tools = [];
const { container } = render(<SessionDefaultsSettings />);
expect(container.firstChild).toBeNull();
aiTools.tools = saved;
});
});
+183 -4
View File
@@ -8,26 +8,31 @@
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
* API key or a pasted `auth.json` — or removes one, each time with their own
* password again; an analyst only reads. The secret only goes IN: the server
* never sends it back.
* never sends it back. An account's Edit (pencil) picks the users who run on
* it (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and
* a Claude login goes into their home at once — no restart.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import {
api,
type AccountPin,
type AccountPool,
type PoolAccountInput,
type PoolProfile,
type PoolTool,
type ServerUserUsage,
} from "../../lib/api";
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { isSubmitEnter } from "../../lib/keys";
import { useSpin } from "../../hooks/useSpin";
import { ConfirmModal } from "../ConfirmModal";
import { Checkbox } from "../Checkbox";
type Row = {
tool: PoolTool;
@@ -59,6 +64,8 @@ function rowsOf(pool: AccountPool): Row[] {
];
}
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
function errorText(t: TFunction<"users">, e: unknown): string {
const err = e as Error & { code?: string };
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
@@ -72,6 +79,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [removing, setRemoving] = useState<Row | null>(null);
const [editing, setEditing] = useState<Row | null>(null);
const load = useCallback(
(force = false) =>
@@ -148,6 +156,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
<div className="text-[11px] text-fg-muted">
{t("pool.added", { at: formatDateTime(r.addedAt) })}
</div>
{pool && pinsOf(pool, r.tool).some((p) => p.account === r.name) && (
<div className="text-[11px] text-fg-secondary">
{t("pool.pinned", {
users: pinsOf(pool, r.tool)
.filter((p) => p.account === r.name)
.map((p) => p.username)
.join(", "),
})}
</div>
)}
</td>
<td className="px-5 py-2.5 text-sm text-fg-secondary">
{t(`pool.kind.${r.kind}`)}
@@ -188,7 +206,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
</div>
</td>
{isAdmin && (
<td className="px-5 py-2 text-right">
<td className="px-5 py-2 text-right whitespace-nowrap">
<button
type="button"
onClick={() => setEditing(r)}
title={t("pool.edit", { name: r.name })}
aria-label={t("pool.edit", { name: r.name })}
className="btn-icon"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => setRemoving(r)}
@@ -206,7 +233,6 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
</table>
</div>
)}
{adding && (
<AddAccountDialog
onClose={() => setAdding(false)}
@@ -228,10 +254,163 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
}}
/>
)}
{editing && pool && (
<EditAccountDialog
pool={pool}
row={editing}
onClose={() => setEditing(null)}
onDone={(r) => {
const left = r.added.filter(
(u) => u.switched === "own-login" || u.switched === "expired"
);
toast.success(t("pool.editDone", { name: editing.name }));
for (const u of left) {
toast.info(
t(`pool.pinDone.${u.switched}`, { user: u.username, account: editing.name })
);
}
setEditing(null);
void load();
}}
/>
)}
</section>
);
}
/**
* One account's Edit: tick the users whose sessions run on it. A user already
* on another account of the tool moves here when ticked; unticking one sends
* them back to the least-used account. Saved in one request, password again.
*/
function EditAccountDialog({
pool,
row,
onClose,
onDone,
}: {
pool: AccountPool;
row: Row;
onClose: () => void;
onDone: (r: Awaited<ReturnType<typeof api.accountPool.setUsers>>) => void;
}) {
const { t } = useTranslation("users");
const pins = pinsOf(pool, row.tool);
const initial = pins.filter((p) => p.account === row.name).map((p) => p.userId);
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
const [chosen, setChosen] = useState<Set<number>>(() => new Set(initial));
const [current, setCurrent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.users
.list()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
}, []);
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
const ready = changed && current.length > 0;
const toggle = (id: number, on: boolean) =>
setChosen((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
const submit = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen], current));
} catch (e) {
setError(errorText(t, e));
} finally {
setBusy(false);
}
};
return (
<ConfirmModal
open
title={t("pool.edit", { name: row.name })}
message={t("pool.editHint")}
confirmLabel={t("pool.save")}
cancelLabel={t("common:cancel")}
destructive={false}
busy={busy}
disabled={!ready}
onConfirm={() => void submit()}
onCancel={onClose}
>
<div
className="mt-3 space-y-3"
onKeyDown={(e) => {
if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
}}
>
<div>
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
<div className="mt-1 max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
{users === null && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
)}
{users?.length === 0 && (
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
)}
{users?.map((u) => {
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
return (
<div key={u.id} className="px-3 py-2">
<Checkbox
checked={chosen.has(u.id)}
onChange={(on) => toggle(u.id, on)}
label={
<span>
{u.username}
{u.disabled && (
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
)}
{elsewhere && (
<span className="ml-1.5 text-[11px] text-fg-muted">
{t("pool.onAccount", { account: elsewhere.account })}
</span>
)}
</span>
}
labelClassName="text-sm text-fg-primary"
/>
</div>
);
})}
</div>
</div>
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
<input
type="password"
className="input w-full text-sm"
value={current}
onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password"
aria-label={t("yourPassword")}
/>
</Field>
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}
</p>
)}
</div>
</ConfirmModal>
);
}
type ClaudeSource = "profile" | "credentials" | "token";
function AddAccountDialog({
@@ -5,7 +5,8 @@
* admin adds an account only with their own password typed (the token goes
* in once and is not shown back) — a saved login moved in (the server's own
* included), or a setup-token — and removes one the same way, and an analyst
* sees no add or remove control.
* sees no add or remove control. An account's Edit ticks the users who run on
* it, saved with the admin's own password; the users show on the account.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -19,10 +20,12 @@ const accountPool = vi.hoisted(() => ({
add: vi.fn(),
remove: vi.fn(),
profiles: vi.fn(),
setUsers: vi.fn(),
}));
const users = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, api: { accountPool } };
return { ...actual, api: { accountPool, users } };
});
import { AccountPoolCard } from "../AccountPoolCard";
@@ -66,6 +69,12 @@ const POOL: AccountPool = {
beforeEach(() => {
for (const fn of Object.values(accountPool)) fn.mockReset();
accountPool.list.mockResolvedValue(POOL);
users.list.mockResolvedValue({
users: [
{ id: 7, username: "mia", disabled: false },
{ id: 8, username: "leo", disabled: false },
],
});
accountPool.profiles.mockResolvedValue({
profiles: [
{
@@ -158,4 +167,43 @@ describe("AccountPoolCard", () => {
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
);
});
it("sets who runs on an account from its Edit, with the admin's own password", async () => {
accountPool.list.mockResolvedValue({
...POOL,
pins: {
claude: [
{ userId: 8, username: "leo", account: "team-1" },
{ userId: 7, username: "mia", account: "team-0" },
],
codex: [],
},
});
accountPool.setUsers.mockResolvedValue({
added: [{ userId: 7, username: "mia", switched: "switched" }],
removed: [{ userId: 8, username: "leo" }],
});
const user = userEvent.setup();
render(<AccountPoolCard isAdmin />);
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /switch a user/i })).toBeNull();
await user.click(screen.getByRole("button", { name: "Edit team-1" }));
const dialog = await screen.findByRole("dialog");
const leo = await screen.findByRole("checkbox", { name: /leo/ });
const mia = screen.getByRole("checkbox", { name: /mia/ });
expect(leo).toHaveAttribute("aria-checked", "true");
expect(mia).toHaveAttribute("aria-checked", "false");
expect(screen.getByText("now on team-0")).toBeInTheDocument();
await user.click(mia);
await user.click(leo);
const save = Array.from(dialog.querySelectorAll("button")).find(
(b) => b.textContent === "Save"
)!;
expect(save).toBeDisabled();
await user.type(screen.getByLabelText("Your password"), "boss-pass");
await user.click(save);
await waitFor(() =>
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
);
});
});
+68 -4
View File
@@ -43,7 +43,7 @@
"deleteBody": "It is removed from your library and the Hub. Anything already installed from it stays installed.",
"deleteConfirm": "Delete",
"installTitle": "Install “{{title}}”?",
"installSkillBody": "These files are written to your own ~/.claude/skills/{{name}}/. A skill can carry scripts your agent will run — only install what you trust:",
"installSkillBody": "These files are written to your own ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/, so every AI tool and session finds it. A skill can carry scripts your agent will run — only install what you trust:",
"installPluginBody": "Runs, as your account: claude plugin marketplace add {{market}} and claude plugin install {{name}} --scope user. Only install what you trust.",
"installConfirm": "Install",
"replaceTitle": "Replace your skill “{{name}}”?",
@@ -70,7 +70,8 @@
"form.save": "Add",
"form.cancel": "Cancel",
"added": "Added",
"files": "{{count}} files",
"files_one": "{{count}} file",
"files_other": "{{count}} files",
"close": "Close",
"fromSession": "From a session",
"form.saveEdit": "Save",
@@ -78,7 +79,7 @@
"skill.tab.write": "Write new",
"skill.tab.existing": "From my skills",
"skill.name": "Skill name",
"skill.slugHint": "Installs as ~/.claude/skills/{{slug}}/ and runs as /{{slug}}",
"skill.slugHint": "Installs as ~/.claude/skills/{{slug}}/ (and ~/.agents/skills/) and runs as /{{slug}}",
"skill.slugBad": "Use letters, digits, '.', '_' or '-'",
"skill.description": "Description — when Claude should use this skill",
"skill.descriptionHint": "e.g. Use when writing release notes from the git log",
@@ -110,5 +111,68 @@
"file.fromComputer": "From your computer (≤ 25 MB)",
"file.upload": "Upload",
"file.tooBig": "The file is larger than 25 MB.",
"file.fromWorkspace": "Or pin a document a workspace's sessions wrote"
"file.fromWorkspace": "Or pin a document a workspace's sessions wrote",
"editTitle": "Edit “{{title}}”",
"edit.title": "Title",
"edit.description": "Description",
"previewFailed": "Could not load this file.",
"truncated": "Showing the first 1 MB — download the file to see all of it.",
"tab.all": "All",
"installedBadge": "Installed",
"installsTitle": "Installs",
"installsYou": "You",
"installsUnknown": "A removed user",
"installsReplaced": "replaced an older copy",
"installsPluginWhere": "claude plugin install --scope user",
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
"installsNoneOfYours": "You have not installed it — only your own installs are listed.",
"reinstall": "Reinstall",
"reinstallHint": "Write this version over your installed copy",
"reinstallTitle": "Reinstall “{{title}}”?",
"reinstallBody": "Your installed ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are replaced with this version. The current copy is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
"uninstall": "Uninstall",
"uninstallHint": "Remove it from your account",
"uninstallTitle": "Uninstall “{{title}}”?",
"uninstallSkillBody": "~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are moved to ~/.claude/agenthub-replaced-skills/, not deleted. Sessions already open keep it until they reload.",
"uninstallPluginBody": "Runs claude plugin uninstall {{name}} for your account; the plugin's data folder is kept. Open sessions keep it until /reload-plugins.",
"uninstalled": "Uninstalled {{name}}",
"deleteBodyShared": "It is shared, so it also disappears from the Hub for everyone. To keep it but stop sharing, use Unshare instead. Anything already installed from it stays installed.",
"deleteBodyKeep": "It leaves your library but stays in the Hub, because it is shared. To take it off the Hub too, use Remove from Hub afterwards (or Unshare first).",
"deleteKept": "Removed from your library — still in the Hub",
"hubOnly": "Hub only",
"hubOnlyHint": "You removed it from your library; it stays shared in the Hub",
"hubRemove": "Remove from Hub",
"hubRemoveTitle": "Remove “{{title}}” from the Hub?",
"hubRemoveBody": "It is no longer in your library, so it is deleted for good. Anything already installed from it stays installed.",
"edit.otherFiles": "Its other {{count}} file(s) are kept as they are.",
"edit.reinstallNote": "Your installed copy keeps the old version until you Reinstall it.",
"preview.instructions": "Instructions",
"preview.frontmatter": "Frontmatter (SKILL.md header)",
"preview.whenUsed": "When Claude uses it",
"preview.noDescription": "No description",
"preview.installsAs": "Installs as ~/.claude/skills/{{slug}}/ and ~/.agents/skills/{{slug}}/",
"blurb.skill": "Reusable instructions Claude follows",
"blurb.plugin": "Bundles of skills, commands and hooks",
"blurb.page": "Pages your sessions published on claude.ai",
"blurb.file": "Documents and outputs worth keeping",
"clearSearch": "Clear search",
"categories": "Categories",
"seeAll": "See all",
"results_one": "{{count}} result",
"results_other": "{{count}} results",
"empty.title": "Your library is empty",
"empty.hubTitle": "The Hub is quiet",
"empty.searchTitle": "No matches",
"empty.search": "Nothing matches this search or category — try other words, or pick All.",
"details": "Details",
"more": "More actions",
"about": "About",
"stat.kind": "Type",
"stat.installs": "Installs",
"stat.files": "Files",
"stat.size": "Size",
"stat.updated": "Updated",
"detail.runAs": "Run it as",
"detail.market": "Marketplace",
"detail.added": "Added {{date}}"
}
+19
View File
@@ -220,6 +220,15 @@
"clone.remember": "Remember this login for my account",
"clone.sshRefused": "{{host}} refused the SSH login. Add your public key to that host, or make one in Settings → Git.",
"clone.sshSettings": "Open Settings → Git",
"clone.partial": "Partial clone (faster for big repos)",
"clone.partialHint": "Downloads every commit but a file's contents only when it is checked out (--filter=blob:none). Checking out an old revision later fetches from the remote.",
"clone.queued": "Waiting in the clone queue — #{{position}} of {{waiting}} ({{running}}/{{limit}} clones running)",
"clone.connecting": "Connecting…",
"clone.phase.remote": "Server preparing",
"clone.phase.receiving": "Downloading",
"clone.phase.resolving": "Unpacking",
"clone.phase.checkout": "Writing files",
"clone.sshNoKey": "Not cloned: your account has no SSH key yet. Make one in Settings → Git, add its public key to {{host}}, then retry.",
"moreActions": "More actions",
"pipelinePicker.label": "Pipeline template",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
@@ -298,6 +307,7 @@
"changes.undoCommitTitle": "Undo the last commit?",
"changes.undoCommitBody": "{{short}} \"{{subject}}\" is removed from the branch. Its changes stay staged, so you can edit them and commit again.",
"changes.undoCommitConfirm": "Undo commit",
"changes.undoCommitPushed": "The last commit is already on the remote branch (as of the last fetch); undoing it would rewrite shared history. Revert it instead — or Fetch first if the remote no longer has it.",
"changes.revertConfirm": "Add a commit on {{branch}} that undoes {{short}} \"{{subject}}\"? Files in this workspace change now.",
"changes.cherryPickConfirm": "Apply {{short}} \"{{subject}}\" from {{from}} onto {{branch}} as a new commit? Files in this workspace change now.",
"changes.amendTitle": "Amend the last commit?",
@@ -421,6 +431,9 @@
"home.cloneRun": "Clone",
"home.cloneRetry": "Retry the failed ones",
"home.cloneDone": "Added",
"home.cloneNeedSsh": "SSH links need an SSH key first — your account has none. Make one in Settings → Git and add its public key to {{hosts}} before cloning.",
"home.cloneNeedLogin": "No saved login for {{hosts}}. A private repository needs your username and an access token (or save one in Settings → Git).",
"home.cloneEnterLogin": "Enter a login",
"home.suggested": "Untracked worktrees",
"home.remove": "Remove",
"home.missing": "missing",
@@ -518,6 +531,9 @@
"changes.unversionedGroup": "Unversioned Files",
"changes.commitTicked": "Commit ({{count}})",
"changes.commitAndPush": "Commit and Push",
"changes.discardAll": "Discard all",
"changes.discardAllTitle": "Discard all changes?",
"changes.discardAllConfirm": "Every change in {{count}} file(s) goes back to the last commit, and {{untracked}} new file(s) git does not track yet are deleted. This cannot be undone.",
"changes.amendAndPush": "Amend and Push",
"changes.pushFailed": "Committed, but the push failed: {{error}}",
"changes.tickFile": "Include {{path}} in the commit",
@@ -548,6 +564,9 @@
"fx.refresh": "Refresh",
"fx.newFolder": "New folder",
"fx.newFile": "New file",
"fx.cloneRepo": "Clone repository",
"fx.cloneSubmit": "Clone",
"fx.cloned": "Cloned into {{path}} — opened as workspace “{{workspace}}”.",
"fx.uploadFiles": "Upload files",
"fx.uploadFolder": "Upload a folder",
"fx.download": "Download",
+31 -1
View File
@@ -50,6 +50,9 @@
"permissionAuto": "auto (recommended - asks only for risky actions)",
"permissionBypass": "bypassPermissions (DANGER - allows everything without asking)",
"bypass": "Bypass permissions for this run",
"yolo": "YOLO",
"yoloHint": "YOLO mode for this session: no sandbox, no approvals (--dangerously-bypass-approvals-and-sandbox)",
"bypassShort": "Bypass",
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
"permissionMode": "Permission mode",
"permissionPlan": "plan (read-only planning)",
@@ -68,6 +71,8 @@
"paste": "Drop to paste into the terminal",
"uploading": "Uploading…",
"failed": "Could not attach the file",
"tooBig": "File too large to upload (max {{mb}} MB) — put it in the workspace folder and attach it from Files",
"tooBigLocal": "File too large to upload (max {{mb}} MB) — attach it by path instead: + → Choose a file on this computer",
"remove": "Remove attachment",
"view": "View image"
},
@@ -84,6 +89,7 @@
"findClose": "Close find"
},
"chat": {
"codexPicker": "Opens Codex's own picker — answer it in the chat",
"placeholder": "Write a message…",
"send": "Send",
"notSent": "The terminal is not connected — the message was not sent.",
@@ -94,6 +100,9 @@
"notStopped": "The terminal is not connected — nothing was stopped.",
"add": "Add files, skills…",
"upload": "Upload or add files",
"pickLocal": "Choose a file on this computer",
"pickLocalHint": "Attach a file on this machine by its path — nothing is uploaded",
"pickLocalOpen": "The file picker is open on this computer's screen…",
"projectFiles": "Project files",
"contextFiles": "Context files",
"recentUploads": "Recent uploads",
@@ -131,7 +140,28 @@
},
"menu": {
"placeholder": "Type your answer…",
"cancel": "Cancel (Esc)"
"cancel": "Cancel (Esc)",
"prev": "Previous question",
"next": "Next question"
},
"request": {
"notePlaceholder": "Tell the model what to do instead…",
"send": "Send",
"denyWithNote": "Deny with a note"
},
"artifact": {
"preview": "Preview",
"code": "Code",
"copy": "Copy",
"download": "Download",
"share": "Save to Artifacts",
"shared": "Saved to your Artifacts library — share it from the Artifacts page.",
"cannotShare": "This file cannot be saved to Artifacts.",
"expand": "Full screen",
"collapse": "Exit full screen",
"older": "Older version",
"newer": "Newer version",
"version": "Version {{n}} of {{total}}"
}
},
"interrupted": {
+30 -1
View File
@@ -210,7 +210,36 @@
"moreSteps_other": "+{{count}} more",
"quiet": "no new activity for {{time}}",
"askDeclined": "Declined — no answer given",
"turnDone": "Done in {{time}}"
"turnDone": "Done in {{time}}",
"turnReplied": "Replied in {{time}}",
"agentsStill_one": "{{count}} agent still running",
"agentsStill_other": "{{count}} agents still running",
"agentsRunning_one": "{{count}} agent running in the background",
"agentsRunning_other": "{{count}} agents running in the background",
"agentContext": "{{tokens}} tokens",
"agent": {
"open": "View conversation",
"prompt": "Task",
"background": "background",
"toolUses_one": "{{count}} tool call",
"toolUses_other": "{{count}} tool calls",
"status": {
"running": "running",
"done": "done",
"error": "failed",
"stopped": "stopped"
},
"hide": "Hide conversation"
},
"artifact": {
"html": "Web page",
"svg": "Image · SVG",
"markdown": "Document",
"mermaid": "Diagram",
"react": "React component",
"csv": "Table",
"open": "Click to open"
}
},
"overview": {
"agent": "Agent",
+16 -2
View File
@@ -1302,11 +1302,25 @@
},
"chatUi": {
"title": "Chat UI",
"description": "How a new Claude or Codex session opens in its workspace. Saved for your account only.",
"description": "How a new Claude, Codex, omp, Cursor or Antigravity session opens in its workspace. Saved for your account only.",
"label": "Open new sessions as a chat",
"labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
"labelDesc": "A new session first shows its conversation like Claude Desktop, with a message box, instead of its terminal (the terminal stays underneath). Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and questions are answered from cards in the chat.",
"saveFailed": "Could not save: {{message}}"
},
"sessionDefaults": {
"title": "Session defaults",
"description": "What a new session starts with in a workspace's prompt box. Saved for your account only; a pick in the prompt box changes only that one session.",
"claudeMode": "Claude Code permission mode",
"claudeModeDesc": "Bypass permissions lets every new Claude session run any tool without asking.",
"codexSandbox": "Codex sandbox",
"codexApproval": "Codex approval",
"codexBypass": "Codex: YOLO mode",
"codexBypassDesc": "Every new Codex session starts in YOLO mode — `--dangerously-bypass-approvals-and-sandbox`: no sandbox, no approvals.",
"confirmTitle": "Make bypass the default for {{tool}}?",
"confirmBody": "Every new {{tool}} session you start will run commands and edit files without asking, until you change this back. Use it only with folders and prompts you trust.",
"confirmLabel": "Make it the default",
"codexConfirmTitle": "Make YOLO the default for Codex?"
},
"agentInstructions": {
"title": "My agent instructions",
"description": "Your own rules in Markdown — coding style, language, workflow. Every new session in a workspace you own starts with them, before that workspace's own instructions. Saved for your account only; sessions already running keep what they started with.",
+16 -2
View File
@@ -166,7 +166,8 @@
"EACTIVE": "This server's Claude Code keeps that login in the macOS Keychain. Switch it to another profile first.",
"ENOTFOUND": "That saved login no longer exists.",
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
"EUPSTREAM": "Anthropic could not be reached. Try again."
"EUPSTREAM": "Anthropic could not be reached. Try again.",
"ENOUSER": "That user no longer exists."
},
"source": "From",
"sources": {
@@ -196,7 +197,20 @@
"token": "The sk-ant-… that `claude setup-token` printed.",
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
},
"refreshFailed": "refresh failed"
"refreshFailed": "refresh failed",
"pinned": "Used by: {{users}}",
"loadingUsers": "Loading…",
"pinDone": {
"own-login": "{{user}} signed in with their own login, which was left alone",
"expired": "{{account}} has expired — {{user}}'s new sessions fall back to the least-used account"
},
"edit": "Edit {{name}}",
"editHint": "Tick the users whose sessions run on this account instead of the least-used one — even when it is full. A user on another account moves here. A Claude login is written into their home at once: sessions already running move to it at their next login check, with no restart. A setup-token or Codex account applies to new sessions. Untick to send a user back to the least-used account.",
"save": "Save",
"users": "Users on this account",
"noUsers": "No users yet.",
"onAccount": "now on {{account}}",
"editDone": "{{name}} saved"
},
"delete": {
"button": "Delete user",
+68 -4
View File
@@ -43,7 +43,7 @@
"deleteBody": "Mục này bị gỡ khỏi thư viện của bạn và Hub. Những bản người khác đã cài vẫn giữ nguyên.",
"deleteConfirm": "Xoá",
"installTitle": "Cài “{{title}}”?",
"installSkillBody": "Các file này được ghi vào ~/.claude/skills/{{name}}/ của chính bạn. Skill có thể kèm script mà agent sẽ chạy — chỉ cài thứ bạn tin:",
"installSkillBody": "Các file này được ghi vào ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ của chính bạn, để mọi AI tool và mọi phiên đều dùng được. Skill có thể kèm script mà agent sẽ chạy — chỉ cài thứ bạn tin:",
"installPluginBody": "Chạy dưới tài khoản của bạn: claude plugin marketplace add {{market}} và claude plugin install {{name}} --scope user. Chỉ cài thứ bạn tin.",
"installConfirm": "Cài",
"replaceTitle": "Thay skill “{{name}}” của bạn?",
@@ -70,7 +70,8 @@
"form.save": "Thêm",
"form.cancel": "Huỷ",
"added": "Đã thêm",
"files": "{{count}} file",
"files_one": "{{count}} file",
"files_other": "{{count}} file",
"close": "Đóng",
"fromSession": "Từ một phiên",
"form.saveEdit": "Lưu",
@@ -78,7 +79,7 @@
"skill.tab.write": "Viết mới",
"skill.tab.existing": "Từ skill có sẵn",
"skill.name": "Tên skill",
"skill.slugHint": "Cài vào ~/.claude/skills/{{slug}}/ và gọi bằng /{{slug}}",
"skill.slugHint": "Cài vào ~/.claude/skills/{{slug}}/ (và ~/.agents/skills/) và gọi bằng /{{slug}}",
"skill.slugBad": "Chỉ dùng chữ, số, '.', '_' hoặc '-'",
"skill.description": "Mô tả — khi nào Claude nên dùng skill này",
"skill.descriptionHint": "VD: Dùng khi viết release notes từ git log",
@@ -110,5 +111,68 @@
"file.fromComputer": "Từ máy tính (≤ 25 MB)",
"file.upload": "Tải lên",
"file.tooBig": "File lớn hơn 25 MB.",
"file.fromWorkspace": "Hoặc ghim tài liệu do các phiên của workspace đã ghi"
"file.fromWorkspace": "Hoặc ghim tài liệu do các phiên của workspace đã ghi",
"editTitle": "Sửa “{{title}}”",
"edit.title": "Tiêu đề",
"edit.description": "Mô tả",
"previewFailed": "Không tải được file này.",
"truncated": "Chỉ hiển thị 1 MB đầu — tải file về để xem toàn bộ.",
"tab.all": "Tất cả",
"installedBadge": "Đã cài",
"installsTitle": "Lượt cài",
"installsYou": "Bạn",
"installsUnknown": "Người dùng đã xoá",
"installsReplaced": "thay bản cũ",
"installsPluginWhere": "claude plugin install --scope user",
"installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết",
"installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.",
"reinstall": "Cài lại",
"reinstallHint": "Ghi bản này đè lên bản bạn đã cài",
"reinstallTitle": "Cài lại “{{title}}”?",
"reinstallBody": "Bản đã cài ở ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được thay bằng bản này. Bản hiện tại được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá.",
"uninstall": "Gỡ cài đặt",
"uninstallHint": "Gỡ khỏi tài khoản của bạn",
"uninstallTitle": "Gỡ cài đặt “{{title}}”?",
"uninstallSkillBody": "~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá. Các phiên đang mở vẫn giữ nó cho tới khi tải lại.",
"uninstallPluginBody": "Chạy claude plugin uninstall {{name}} cho tài khoản của bạn; thư mục dữ liệu của plugin được giữ lại. Các phiên đang mở vẫn giữ nó cho tới khi /reload-plugins.",
"uninstalled": "Đã gỡ {{name}}",
"deleteBodyShared": "Mục này đang được chia sẻ nên cũng sẽ biến mất khỏi Hub với mọi người. Muốn giữ lại nhưng thôi chia sẻ thì dùng Bỏ chia sẻ. Những gì đã cài từ nó vẫn được giữ.",
"deleteBodyKeep": "Mục này rời khỏi thư viện của bạn nhưng vẫn ở trên Hub vì đang được chia sẻ. Muốn gỡ khỏi Hub luôn thì sau đó bấm Gỡ khỏi Hub (hoặc Bỏ chia sẻ trước).",
"deleteKept": "Đã xoá khỏi thư viện — vẫn còn trên Hub",
"hubOnly": "Chỉ trên Hub",
"hubOnlyHint": "Bạn đã xoá khỏi thư viện; mục này vẫn được chia sẻ trên Hub",
"hubRemove": "Gỡ khỏi Hub",
"hubRemoveTitle": "Gỡ “{{title}}” khỏi Hub?",
"hubRemoveBody": "Mục này không còn trong thư viện của bạn nên sẽ bị xoá hẳn. Những gì đã cài từ nó vẫn được giữ.",
"edit.otherFiles": "{{count}} file khác của skill được giữ nguyên.",
"edit.reinstallNote": "Bản bạn đã cài vẫn là bản cũ cho đến khi bấm Cài lại.",
"preview.instructions": "Hướng dẫn",
"preview.frontmatter": "Frontmatter (phần đầu SKILL.md)",
"preview.whenUsed": "Khi nào Claude dùng",
"preview.noDescription": "Chưa có mô tả",
"preview.installsAs": "Cài vào ~/.claude/skills/{{slug}}/ và ~/.agents/skills/{{slug}}/",
"blurb.skill": "Bộ hướng dẫn tái sử dụng cho Claude",
"blurb.plugin": "Gói skill, lệnh và hook",
"blurb.page": "Trang mà phiên đã publish lên claude.ai",
"blurb.file": "Tài liệu và kết quả đáng giữ lại",
"clearSearch": "Xoá tìm kiếm",
"categories": "Danh mục",
"seeAll": "Xem tất cả",
"results_one": "{{count}} kết quả",
"results_other": "{{count}} kết quả",
"empty.title": "Thư viện đang trống",
"empty.hubTitle": "Hub chưa có gì",
"empty.searchTitle": "Không tìm thấy",
"empty.search": "Không có mục nào khớp tìm kiếm hoặc danh mục này — thử từ khác, hoặc chọn Tất cả.",
"details": "Chi tiết",
"more": "Thao tác khác",
"about": "Giới thiệu",
"stat.kind": "Loại",
"stat.installs": "Lượt cài",
"stat.files": "File",
"stat.size": "Dung lượng",
"stat.updated": "Cập nhật",
"detail.runAs": "Gọi bằng",
"detail.market": "Marketplace",
"detail.added": "Thêm lúc {{date}}"
}
+19
View File
@@ -220,6 +220,15 @@
"clone.remember": "Lưu đăng nhập này cho tài khoản của tôi",
"clone.sshRefused": "{{host}} từ chối đăng nhập SSH. Hãy thêm public key của bạn vào host đó, hoặc tạo key trong Cài đặt → Git.",
"clone.sshSettings": "Mở Cài đặt → Git",
"clone.partial": "Clone một phần (nhanh hơn với repo lớn)",
"clone.partialHint": "Tải toàn bộ commit nhưng chỉ tải nội dung file khi cần checkout (--filter=blob:none). Checkout một phiên bản cũ sau này sẽ tải thêm từ remote.",
"clone.queued": "Đang chờ trong hàng clone — thứ {{position}}/{{waiting}} ({{running}}/{{limit}} clone đang chạy)",
"clone.connecting": "Đang kết nối…",
"clone.phase.remote": "Máy chủ đang chuẩn bị",
"clone.phase.receiving": "Đang tải về",
"clone.phase.resolving": "Đang giải nén",
"clone.phase.checkout": "Đang ghi file",
"clone.sshNoKey": "Chưa clone: tài khoản của bạn chưa có SSH key. Hãy tạo key trong Cài đặt → Git, thêm public key vào {{host}}, rồi thử lại.",
"moreActions": "Thêm hành động",
"pipelinePicker.label": "Mẫu pipeline",
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
@@ -298,6 +307,7 @@
"changes.undoCommitTitle": "Hoàn tác commit cuối?",
"changes.undoCommitBody": "{{short}} \"{{subject}}\" sẽ bị gỡ khỏi nhánh. Thay đổi của nó vẫn được giữ ở trạng thái staged để sửa và commit lại.",
"changes.undoCommitConfirm": "Hoàn tác commit",
"changes.undoCommitPushed": "Commit cuối đã có trên nhánh remote (theo lần fetch gần nhất); hoàn tác sẽ viết lại lịch sử đã chia sẻ. Hãy Revert — hoặc Fetch trước nếu remote không còn commit này.",
"changes.revertConfirm": "Thêm một commit trên {{branch}} để đảo ngược {{short}} \"{{subject}}\"? File trong workspace sẽ thay đổi ngay.",
"changes.cherryPickConfirm": "Áp {{short}} \"{{subject}}\" từ {{from}} vào {{branch}} thành một commit mới? File trong workspace sẽ thay đổi ngay.",
"changes.amendTitle": "Amend commit cuối?",
@@ -421,6 +431,9 @@
"home.cloneRun": "Clone",
"home.cloneRetry": "Thử lại các repo lỗi",
"home.cloneDone": "Đã thêm",
"home.cloneNeedSsh": "Link SSH cần SSH key trước — tài khoản của bạn chưa có. Hãy tạo key trong Cài đặt → Git và thêm public key vào {{hosts}} trước khi clone.",
"home.cloneNeedLogin": "Chưa lưu login cho {{hosts}}. Repo private cần username và access token của bạn (hoặc lưu sẵn trong Cài đặt → Git).",
"home.cloneEnterLogin": "Nhập login",
"home.suggested": "Worktree chưa theo dõi",
"home.remove": "Gỡ",
"home.missing": "không còn",
@@ -518,6 +531,9 @@
"changes.unversionedGroup": "File chưa theo dõi",
"changes.commitTicked": "Commit ({{count}})",
"changes.commitAndPush": "Commit và Push",
"changes.discardAll": "Bỏ tất cả",
"changes.discardAllTitle": "Bỏ tất cả thay đổi?",
"changes.discardAllConfirm": "Mọi thay đổi trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới chưa được git theo dõi sẽ bị xoá. Không thể hoàn tác.",
"changes.amendAndPush": "Amend và Push",
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
"changes.tickFile": "Đưa {{path}} vào commit",
@@ -548,6 +564,9 @@
"fx.refresh": "Làm mới",
"fx.newFolder": "Thư mục mới",
"fx.newFile": "File mới",
"fx.cloneRepo": "Clone repository",
"fx.cloneSubmit": "Clone",
"fx.cloned": "Đã clone vào {{path}} — đã mở thành workspace “{{workspace}}”.",
"fx.uploadFiles": "Tải file lên",
"fx.uploadFolder": "Tải cả thư mục lên",
"fx.download": "Tải về",
+31 -1
View File
@@ -50,6 +50,9 @@
"permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)",
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
"bypass": "Bỏ qua xin quyền cho lần chạy này",
"yolo": "YOLO",
"yoloHint": "Chế độ YOLO cho phiên này: không sandbox, không hỏi phê duyệt (--dangerously-bypass-approvals-and-sandbox)",
"bypassShort": "Bỏ qua quyền",
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
"permissionMode": "Chế độ quyền",
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
@@ -68,6 +71,8 @@
"paste": "Thả để dán vào terminal",
"uploading": "Đang tải lên…",
"failed": "Không đính kèm được file",
"tooBig": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đặt file vào thư mục workspace rồi đính kèm từ Files",
"tooBigLocal": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đính kèm bằng đường dẫn: + → Chọn file trên máy này",
"remove": "Bỏ đính kèm",
"view": "Xem ảnh"
},
@@ -84,6 +89,7 @@
"findClose": "Đóng tìm kiếm"
},
"chat": {
"codexPicker": "Mở bộ chọn của chính Codex — trả lời ngay trong chat",
"placeholder": "Viết tin nhắn…",
"send": "Gửi",
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.",
@@ -94,6 +100,9 @@
"notStopped": "Terminal chưa kết nối — chưa ngưng được.",
"add": "Thêm file, skill…",
"upload": "Tải lên hoặc thêm file",
"pickLocal": "Chọn file trên máy này",
"pickLocalHint": "Đính kèm file trên máy này bằng đường dẫn — không tải lên gì cả",
"pickLocalOpen": "Hộp chọn file đang mở trên màn hình máy này…",
"projectFiles": "File trong dự án",
"contextFiles": "File ngữ cảnh",
"recentUploads": "Đã tải lên gần đây",
@@ -131,7 +140,28 @@
},
"menu": {
"placeholder": "Nhập câu trả lời của bạn…",
"cancel": "Huỷ (Esc)"
"cancel": "Huỷ (Esc)",
"prev": "Câu trước",
"next": "Câu tiếp"
},
"request": {
"notePlaceholder": "Nói cho model biết nên làm gì thay vào đó…",
"send": "Gửi",
"denyWithNote": "Từ chối kèm lời nhắn"
},
"artifact": {
"preview": "Xem trước",
"code": "Mã",
"copy": "Sao chép",
"download": "Tải xuống",
"share": "Lưu vào Artifacts",
"shared": "Đã lưu vào thư viện Artifacts của bạn — chia sẻ từ trang Artifacts.",
"cannotShare": "Không lưu được file này vào Artifacts.",
"expand": "Toàn màn hình",
"collapse": "Thoát toàn màn hình",
"older": "Phiên bản cũ hơn",
"newer": "Phiên bản mới hơn",
"version": "Phiên bản {{n}}/{{total}}"
}
},
"interrupted": {
+30 -1
View File
@@ -210,7 +210,36 @@
"moreSteps_other": "+{{count}} việc nữa",
"quiet": "không có hoạt động mới trong {{time}}",
"askDeclined": "Đã từ chối — không trả lời",
"turnDone": "Xong trong {{time}}"
"turnDone": "Xong trong {{time}}",
"turnReplied": "Đã trả lời trong {{time}}",
"agentsStill_one": "còn {{count}} agent đang chạy",
"agentsStill_other": "còn {{count}} agent đang chạy",
"agentsRunning_one": "{{count}} agent đang chạy nền",
"agentsRunning_other": "{{count}} agent đang chạy nền",
"agentContext": "{{tokens}} token",
"agent": {
"open": "Xem hội thoại",
"prompt": "Nhiệm vụ",
"background": "chạy nền",
"toolUses_one": "{{count}} lần gọi tool",
"toolUses_other": "{{count}} lần gọi tool",
"status": {
"running": "đang chạy",
"done": "xong",
"error": "lỗi",
"stopped": "đã dừng"
},
"hide": "Ẩn hội thoại"
},
"artifact": {
"html": "Trang web",
"svg": "Ảnh · SVG",
"markdown": "Tài liệu",
"mermaid": "Sơ đồ",
"react": "Component React",
"csv": "Bảng",
"open": "Bấm để mở"
}
},
"overview": {
"agent": "Agent",
+16 -2
View File
@@ -1302,11 +1302,25 @@
},
"chatUi": {
"title": "Giao diện chat",
"description": "Cách một session Claude hoặc Codex mới mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
"description": "Cách một session Claude, Codex, omp, Cursor hoặc Antigravity mới mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
"label": "Mở session mới dạng chat",
"labelDesc": "Session Claude hoặc Codex sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal. Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và menu vẫn cần terminal.",
"labelDesc": "Session mới sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal (terminal vẫn chạy bên dưới). Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và câu hỏi được trả lời bằng thẻ ngay trong chat.",
"saveFailed": "Không lưu được: {{message}}"
},
"sessionDefaults": {
"title": "Mặc định cho phiên mới",
"description": "Phiên mới bắt đầu với các lựa chọn này trong ô prompt của workspace. Chỉ lưu cho tài khoản của bạn; chọn khác trong ô prompt chỉ đổi cho phiên đó.",
"claudeMode": "Chế độ quyền của Claude Code",
"claudeModeDesc": "Bỏ qua quyền cho mọi phiên Claude mới chạy mọi tool mà không hỏi.",
"codexSandbox": "Sandbox của Codex",
"codexApproval": "Phê duyệt của Codex",
"codexBypass": "Codex: chế độ YOLO",
"codexBypassDesc": "Mọi phiên Codex mới chạy ở chế độ YOLO — `--dangerously-bypass-approvals-and-sandbox`: không sandbox, không hỏi phê duyệt.",
"confirmTitle": "Đặt bỏ qua quyền làm mặc định cho {{tool}}?",
"confirmBody": "Mọi phiên {{tool}} mới bạn mở sẽ chạy lệnh và sửa file mà không hỏi, cho tới khi bạn đổi lại. Chỉ dùng với thư mục và prompt bạn tin tưởng.",
"confirmLabel": "Đặt làm mặc định",
"codexConfirmTitle": "Đặt YOLO làm mặc định cho Codex?"
},
"agentInstructions": {
"title": "Hướng dẫn agent của tôi",
"description": "Quy tắc riêng của bạn bằng Markdown — style code, ngôn ngữ, quy trình. Mọi session mới trong workspace của bạn đều nhận chúng, trước hướng dẫn riêng của workspace. Chỉ lưu cho tài khoản của bạn; session đang chạy giữ nội dung lúc khởi động.",
+16 -2
View File
@@ -166,7 +166,8 @@
"EACTIVE": "Claude Code của server giữ login này trong macOS Keychain. Hãy chuyển nó sang profile khác trước.",
"ENOTFOUND": "Login đã lưu đó không còn nữa.",
"ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.",
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại."
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại.",
"ENOUSER": "User đó không còn tồn tại."
},
"source": "Nguồn",
"sources": {
@@ -196,7 +197,20 @@
"token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.",
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
},
"refreshFailed": "làm mới lỗi"
"refreshFailed": "làm mới lỗi",
"pinned": "Đang dùng: {{users}}",
"loadingUsers": "Đang tải…",
"pinDone": {
"own-login": "{{user}} đang dùng login riêng của họ, nên không bị ghi đè",
"expired": "{{account}} đã hết hạn — phiên mới của {{user}} quay về tài khoản ít tải nhất"
},
"edit": "Sửa {{name}}",
"editHint": "Tick các user mà phiên của họ chạy trên tài khoản này thay vì tài khoản ít tải nhất — kể cả khi nó đã đầy. User đang ở tài khoản khác sẽ chuyển sang đây. Login Claude được ghi ngay vào home của họ: phiên đang chạy chuyển sang ở lần kiểm tra đăng nhập kế tiếp, không cần khởi động lại. Setup-token hoặc tài khoản Codex áp dụng cho phiên mới. Bỏ tick để trả user về tài khoản ít tải nhất.",
"save": "Lưu",
"users": "User dùng tài khoản này",
"noUsers": "Chưa có user nào.",
"onAccount": "đang ở {{account}}",
"editDone": "Đã lưu {{name}}"
},
"delete": {
"button": "Xoá user",
@@ -0,0 +1,102 @@
/**
* @file artifactView.test.ts
* @description What counts as an artifact and how it is shown: kinds by
* name, a React file only when written whole, a file's versions rebuilt from
* the conversation's Write/Edit/MultiEdit inputs (a failed call skipped, an
* edit to unseen text unknown), CSV rows, and the sandboxed documents.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import {
artifactDocument,
artifactKind,
artifactVersions,
frameSandbox,
isArtifact,
parseCsv,
} from "../artifactView";
import type { TranscriptMessage } from "../types";
const use = (id: string, name: string, input: Record<string, unknown>): TranscriptMessage => ({
type: "assistant",
timestamp: `2026-10-21T00:00:0${id}.000Z`,
content: [{ type: "tool_use", id, name, input } as TranscriptMessage["content"][number]],
});
const result = (id: string, isError = false): TranscriptMessage => ({
type: "user",
sender: "tool",
timestamp: null,
content: [
{
type: "tool_result",
id,
output: "",
is_error: isError,
} as TranscriptMessage["content"][number],
],
});
describe("artifactView", () => {
it("knows an artifact by its name", () => {
expect(artifactKind("/a/page.HTML")).toBe("html");
expect(artifactKind("d.mmd")).toBe("mermaid");
expect(artifactKind("App.tsx")).toBe("react");
expect(artifactKind("x.go")).toBe("code");
expect(isArtifact("report.md", false)).toBe(true);
expect(isArtifact("App.tsx", false)).toBe(false);
expect(isArtifact("App.tsx", true)).toBe(true);
expect(isArtifact("server.js", true)).toBe(false);
});
it("rebuilds a file's versions from Write, Edit and MultiEdit", () => {
const msgs = [
use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }),
result("1"),
use("2", "Edit", { file_path: "/w/a.md", old_string: "old", new_string: "new" }),
result("2"),
use("3", "Edit", { file_path: "/w/a.md", old_string: "missing", new_string: "x" }),
result("3", true),
use("4", "MultiEdit", {
file_path: "/w/a.md",
edits: [{ old_string: "# A", new_string: "# B" }],
}),
result("4"),
use("5", "Write", { file_path: "/w/other.md", content: "no" }),
];
const v = artifactVersions(msgs, "/w/a.md");
expect(v.map((x) => x.content)).toEqual([
"# A\nold line\n",
"# A\nnew line\n",
"# B\nnew line\n",
]);
});
it("says an edit to text it never saw is of unknown content", () => {
const v = artifactVersions(
[use("1", "Edit", { file_path: "a.md", old_string: "a", new_string: "b" })],
"a.md"
);
expect(v).toHaveLength(1);
expect(v[0]!.content).toBeNull();
});
it("reads CSV with quotes and newlines inside them", () => {
expect(parseCsv('a,b\n"x, y","say ""hi""\nthere"\n')).toEqual([
["a", "b"],
["x, y", 'say "hi"\nthere'],
]);
});
it("builds sandboxed documents that keep the source inert inside a script", () => {
const doc = artifactDocument("react", "export default () => <p>a</script>b</p>", false)!;
expect(doc).toContain("babel");
expect(doc).not.toContain("a</script>b");
expect(artifactDocument("mermaid", "graph TD; A-->B<img>", true)).toContain(
"A--&gt;B&lt;img&gt;"
);
expect(artifactDocument("code", "x", false)).toBeNull();
expect(frameSandbox("svg")).toBe("");
expect(frameSandbox("html")).not.toContain("allow-same-origin");
});
});
@@ -21,6 +21,7 @@ import {
chooseRunOption,
parsePaneMenu,
pasteToLane,
readRunBusy,
readRunMenu,
registerPane,
takeRunInput,
@@ -160,6 +161,33 @@ describe("panePaste", () => {
expect(m.typeAt).toBe(2);
});
// Several questions: the unnumbered row is Next until the last one, and the
// tab bar says which questions have an answer.
it("reads a multi-question multiSelect with its Next row and question tabs", () => {
const m = parsePaneMenu(MULTI_Q)!;
expect(m.options.map((o) => [o.n, o.label, o.checked])).toEqual([
[1, "JWT HS256 + default secret", true],
[2, "Product code issue 2 lần", true],
[3, "Type something", false],
[null, "Next", null],
[4, "Chat about this", null],
]);
expect(m.options[2]!.hint).toBe("");
expect(m.cursor).toBe(0);
expect(m.tabs).toEqual([
{ label: "Critical", done: true },
{ label: "High 1/3", done: false },
{ label: "High 2/3", done: false },
{ label: "High 3/3", done: false },
]);
expect(m.tab).toBe(0);
expect(MENU_CURSOR_ROW.test("❯ Next")).toBe(true);
});
it("reads no tabs for a single question", () => {
expect(parsePaneMenu(ASK)!.tabs).toBeUndefined();
});
// The "Type something" row once something is typed in it: its label is the
// text. It is still the row right above "Chat about this".
it("finds Type something by its place once text is typed in it, and replaces that text", () => {
@@ -188,8 +216,11 @@ describe("panePaste", () => {
const c = parsePaneMenu(PERMISSION)!;
expect(c.question).toBe("Do you want to proceed?");
expect(c.options.map((o) => o.label)).toEqual(["Yes", "Yes, and always allow", "No"]);
// The tool and its command, drawn over the dashed rule.
expect(c.detail).toEqual(["Bash command", "touch probe-file.txt"]);
const x = parsePaneMenu(CODEX_APPROVAL)!;
expect(x.question).toBe("Would you like to run the following command?\n$ touch probe.txt");
expect(x.detail).toBeUndefined();
expect(x.options).toHaveLength(3);
expect(x.cursor).toBe(0);
});
@@ -317,6 +348,23 @@ const MULTI = [
"",
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
];
const MULTI_Q = [
RULE,
"← ☒ Critical □ High 1/3 □ High 2/3 □ High 3/3 ✔ Submit →",
"",
"Critical (đã verify trong code). Chọn issue để log BUG:",
"",
"❯ 1. [✔] JWT HS256 + default secret",
" pkg/middleware/auth.go:156,184 — HMAC only",
" 2. [✔] Product code issue 2 lần",
" gift product_code_repository.go:57 — không FOR UPDATE",
" 3. [ ] Type something",
" Next",
RULE,
" 4. Chat about this",
"",
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
];
const PERMISSION = [
" Bash command",
" touch probe-file.txt",
@@ -378,3 +426,51 @@ describe("MENU_CURSOR_ROW", () => {
expect(has(CODEX)).toBe(false);
});
});
describe("readRunBusy", () => {
it("reads each tool's own busy line, and nothing for a tool it does not know", () => {
let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"];
const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows);
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
// Claude's permission footer is not a turn at work.
rows = ["Esc to cancel · Tab to amend"];
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
expect(readRunBusy("agenthub-lane-8", "antigravity")).toBe(false);
rows = ["Thinking… (3s · esc to cancel)"];
expect(readRunBusy("agenthub-lane-8", "antigravity")).toBe(true);
expect(readRunBusy("agenthub-lane-8", "omp")).toBeNull();
off();
expect(readRunBusy("agenthub-lane-8", "claude")).toBeNull();
});
});
describe("Codex's pickers", () => {
it("reads /permissions with each row's description column as its hint", () => {
const menu = parsePaneMenu([
" >_ OpenAI Codex (v0.163.0)",
"",
" Update Model Permissions",
"",
"› 1. Ask for approval (current) Read and edit workspace files and run commands, with approval req",
" access or edits outside the workspace",
" 2. Approve for me Only ask for actions detected as potentially unsafe",
" 3. Full Access Use with caution: Codex can edit files",
" without approval",
" 4. Read Only Read workspace files",
"",
" enter select · esc back",
]);
expect(menu?.question).toBe("Update Model Permissions");
expect(menu?.cursor).toBe(0);
expect(menu?.options.map((o) => [o.n, o.label, o.hint])).toEqual([
[
1,
"Ask for approval (current)",
"Read and edit workspace files and run commands, with approval req access or edits outside the workspace",
],
[2, "Approve for me", "Only ask for actions detected as potentially unsafe"],
[3, "Full Access", "Use with caution: Codex can edit files without approval"],
[4, "Read Only", "Read workspace files"],
]);
});
});
@@ -0,0 +1,20 @@
/**
* @file promptAttach.test.ts — `claudePastes`: how files reach a `claude` pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { claudePastes } from "../promptAttach";
describe("claudePastes", () => {
it("pastes each image as its bare path and the other files as one @ mention paste", () => {
expect(
claudePastes(["/d/drops/a.png", "/d/drops/b-report.pdf", "/d/x.JPG", "/d/c.log"])
).toEqual(["/d/drops/a.png", "/d/x.JPG", "@/d/drops/b-report.pdf @/d/c.log"]);
});
it("keeps a Windows or spaced image path a mention", () => {
expect(claudePastes(["C:\\u\\drops\\a.png", "/home/u/my shot.png"])).toEqual([
'@C:/u/drops/a.png @"/home/u/my shot.png"',
]);
});
});
+172 -37
View File
@@ -676,9 +676,18 @@ export type CodexPoolAccount = {
quotaPercent: number | null;
full: boolean;
};
/** A user an admin pinned to one pool account: their new sessions skip the balancer. */
export type AccountPin = {
userId: number;
username: string;
account: string;
pinnedAt?: string;
};
export type AccountPool = {
accounts: PoolAccount[];
codex: CodexPoolAccount[];
/** Per tool; absent from an older server, empty in local mode. */
pins?: { claude: AccountPin[]; codex: AccountPin[] };
blockPercent: number;
};
export type PoolTool = "claude" | "codex";
@@ -787,6 +796,8 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
runId: body?.error?.run_id as string | undefined,
// EEXISTS from the File Explorer: the names already taken.
names: body?.error?.names as string[] | undefined,
// ETOOBIG from a prompt drop: the server's own limit.
maxBytes: body?.error?.max_bytes as number | undefined,
});
}
// A 200 that is not JSON — the SPA's index.html from a proxy or a dev server
@@ -852,11 +863,52 @@ function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs");
}
/**
* POST `payload` (a file or a batch) as a raw stream to `url`, reporting upload
* progress. Rejects like {@link request}: `code`, and `names` on EEXISTS.
*/
const sendRaw = <T>(url: string, payload: Blob, onProgress?: (sent: number) => void) =>
new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${BASE}${url}`);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
const token = dashboardToken();
if (token) xhr.setRequestHeader("x-dashboard-token", token);
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
xhr.onload = () => {
let body: { error?: { code?: string; message?: string; names?: string[] } } = {};
try {
body = JSON.parse(xhr.responseText);
} catch {
/* not JSON */
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(body as T);
return;
}
if (xhr.status === 401 && body.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
reject(
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
code: body.error?.code,
status: xhr.status,
names: body.error?.names,
})
);
};
xhr.send(payload);
});
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
export interface FsHome {
id: string;
label: string;
readOnly: boolean;
/** The caller's own home only (`me`): its absolute folder — where New →
* Clone repository clones to. Absent from an older server. */
dir?: string;
}
/** One entry of a File Explorer folder listing (`GET /api/lanes/:id/fs/list`). */
@@ -1052,6 +1104,20 @@ export const api = {
method: "POST",
body: JSON.stringify({ ...body, currentPassword }),
}),
/** PUT /api/accounts/:name/users — admin: exactly these users run on this account. */
setUsers: (tool: PoolTool, name: string, userIds: number[], currentPassword: string) =>
request<{
/** Users moved onto it; `switched`: what happened now (a Claude login goes into their home). */
added: {
userId: number;
username: string;
switched: "switched" | "kept" | "own-login" | "expired" | "next-session";
}[];
removed: { userId: number; username: string }[];
}>(`/accounts/${encodeURIComponent(name)}/users`, {
method: "PUT",
body: JSON.stringify({ tool, userIds, currentPassword }),
}),
/** DELETE /api/accounts/:name — admin, with their OWN password again. */
remove: (tool: PoolTool, name: string, currentPassword: string) =>
request<{ ok: true }>(`/accounts/${encodeURIComponent(name)}`, {
@@ -2144,7 +2210,8 @@ export const api = {
* each one contributes (skills/agents/commands/hooks counts).
* @returns {@link CcPluginsResponse} — the manifest path/status plus plugins.
*/
plugins: () => request<CcPluginsResponse>("/cc-config/plugins"),
plugins: (cwd?: string) =>
request<CcPluginsResponse>(`/cc-config/plugins${ccQuery(undefined, cwd)}`),
/**
* GET /api/cc-config/mcp - configured MCP servers, user and project-scoped.
* @returns {@link CcMcpResponse} — servers split into `user`/`projectScoped`.
@@ -2738,40 +2805,45 @@ export const api = {
conflict: FsConflict = "fail",
onProgress?: (sent: number) => void
) =>
new Promise<{ path: string; skipped?: boolean }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${BASE}${fsUrl(t, "/upload", new URLSearchParams({ path, conflict }))}`);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
const token = dashboardToken();
if (token) xhr.setRequestHeader("x-dashboard-token", token);
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
xhr.onload = () => {
let body: { error?: { code?: string; message?: string; names?: string[] } } & {
path?: string;
skipped?: boolean;
} = {};
try {
body = JSON.parse(xhr.responseText);
} catch {
/* not JSON */
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(body as { path: string; skipped?: boolean });
return;
}
if (xhr.status === 401 && body.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
reject(
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
code: body.error?.code,
status: xhr.status,
names: body.error?.names,
})
);
};
xhr.send(file);
sendRaw<{ path: string; skipped?: boolean }>(
fsUrl(t, "/upload", new URLSearchParams({ path, conflict })),
file,
onProgress
),
/**
* Many small files in ONE request, under the folder `dir` (each `rel`
* relative to it): a 4-byte length, the JSON file list, then the bytes.
* The Blob is only composed, never read into memory here.
*/
uploadBatch: (
t: FsPlace,
dir: string,
files: { file: Blob; rel: string }[],
conflict: FsConflict = "fail",
onProgress?: (sent: number) => void
) => {
const head = new TextEncoder().encode(
JSON.stringify({
files: files.map((f) => ({
path: f.rel,
size: f.file.size,
mtime: f.file instanceof File ? f.file.lastModified : undefined,
})),
})
);
const len = new Uint8Array(4);
new DataView(len.buffer).setUint32(0, head.length);
return sendRaw<{ saved: number; skipped: number; paths: string[] }>(
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" }),
onProgress
);
},
/** Before a batch: which of `paths` (under `dir`) are taken, and whether a batch can go there. */
uploadCheck: (t: FsPlace, dir: string, paths: string[]) =>
request<{ taken: string[]; batch: boolean }>(fsUrl(t, "/upload-check"), {
method: "POST",
body: JSON.stringify({ dir, paths }),
}),
},
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
@@ -2919,18 +2991,29 @@ export const api = {
source?: string;
/** plugin only: the marketplace source. */
market?: string;
/** skill only: new contents, written as a new snapshot. */
skill?: { name: string; description: string; content: string };
}
) =>
request<{ item: Artifact }>(`/artifacts/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
remove: (id: number) => request<{ ok: true }>(`/artifacts/${id}`, { method: "DELETE" }),
/** `kept`: a shared one left your library but stays in the Hub. */
remove: (id: number) =>
request<{ ok: true; kept: boolean }>(`/artifacts/${id}`, { method: "DELETE" }),
install: (id: number, replace = false) =>
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
method: "POST",
body: JSON.stringify({ replace }),
}),
/** POST /api/artifacts/:id/uninstall — a skill moved aside, a plugin `claude plugin uninstall`. */
uninstall: (id: number) =>
request<{ uninstalled: string; paths: string[] }>(`/artifacts/${id}/uninstall`, {
method: "POST",
}),
/** GET /api/artifacts/:id/installs — who installed it, when, and where. */
installs: (id: number) => request<ArtifactInstalls>(`/artifacts/${id}/installs`),
/** One of the snapshot's own files, served sandboxed. */
rawUrl: (id: number, path?: string, download = false) => {
const qs = new URLSearchParams();
@@ -3108,6 +3191,14 @@ export const api = {
headers: { "Content-Type": "application/octet-stream" },
});
},
/** POST /api/run/local-paths — each absolute path's size when it is a
* regular file on the dashboard's machine, else null; 403 EREMOTE unless
* the browser is on that machine (and never in server mode). */
localPaths: (paths: string[]) =>
request<{ sizes: (number | null)[] }>("/run/local-paths", {
method: "POST",
body: JSON.stringify({ paths }),
}),
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
start: (args: RunStartArgs) =>
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
@@ -3550,11 +3641,22 @@ export const api = {
title?: string;
tool?: LaneTool;
credentials?: { username: string; password: string; remember?: boolean };
/** Blobless clone (`--filter=blob:none`): file contents fetched on checkout. */
partial?: boolean;
/** This clone's name for {@link lanes.cloneProgress}. */
progressToken?: string;
}) =>
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
method: "POST",
body: JSON.stringify(body),
}),
/**
* GET /api/lanes/clone/progress/:token — a clone's place in the
* machine-wide queue, then git's own progress; 404 until the POST
* carrying `progressToken` arrives, and a minute after it ends.
*/
cloneProgress: (token: string) =>
request<CloneProgressView>(`/lanes/clone/progress/${encodeURIComponent(token)}`),
/**
* GET /api/lanes/pipelines — every pipeline template the server can render
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
@@ -3951,6 +4053,9 @@ export const api = {
url: string;
name?: string;
credentials?: { username: string; password: string; remember?: boolean };
/** Blobless clone, like {@link lanes.clone}'s `partial`. */
partial?: boolean;
progressToken?: string;
}
) =>
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
@@ -4163,10 +4268,21 @@ export interface Artifact {
shared: boolean;
sharedAt: string | null;
installs: number;
/** False once its owner removed it from their library while shared: Hub only. */
inLibrary: boolean;
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
installed?: boolean;
createdAt: string;
updatedAt: string;
}
/** GET /api/artifacts/:id/installs. `paths` only for your own installs (or an admin). */
export interface ArtifactInstalls {
items: { user: string | null; mine: boolean; at: string; replaced: boolean; paths?: string[] }[];
/** Installs counted before the list was kept. */
unrecorded: number;
}
/** POST /api/artifacts/plugin/inspect — a marketplace and the plugins it lists. */
export interface MarketplaceInfo {
/** The source as stored (owner/repo, a git URL or a marketplace.json link). */
@@ -4406,6 +4522,8 @@ export interface AiTool {
prompt: { mode: "argv" | "flag" | "after-start" } | null;
/** Offers "Resume last session". */
resume: boolean;
/** Can resume a NAMED session (Claude, Codex, a catalog `resumeById`). */
resumeById?: boolean;
/** Offers the per-run "Bypass permissions" switch. */
bypass: boolean;
/** Can use an AI Proxy profile (Claude, Codex); others are native only. */
@@ -5189,7 +5307,7 @@ export interface RunStartArgs {
cwd?: string;
model?: string;
permissionMode?: PermissionMode;
/** Resume an existing Claude Code session id (`--resume`) instead of starting fresh. */
/** Resume an existing session id (Claude/Codex, or a catalog `resumeById` tool) instead of starting fresh. */
resumeSessionId?: string;
effort?: EffortLevel;
/** Sent as `claude`'s first positional message once the pane boots; omit
@@ -5262,6 +5380,23 @@ export interface LaneSource {
}
/** Response shape of GET /api/lanes/:id/sources (and of every sources write). */
/** `GET /api/lanes/clone/progress/:token` — see server/lib/clone-queue.js. */
export interface CloneProgressView {
state: "queued" | "running" | "done" | "failed";
/** 1-based place among the waiting clones while `queued`. */
position: number | null;
/** Clones running / waiting machine-wide, and how many may run at once. */
running: number;
waiting: number;
limit: number;
phase: "remote" | "receiving" | "resolving" | "checkout" | "other" | null;
/** git's own words for the phase (shown for `other`). */
label: string | null;
percent: number | null;
/** e.g. "450/1000, 120.50 MiB | 5.20 MiB/s". */
detail: string | null;
}
export interface LaneSourcesView {
instructions: string;
/** The workspace's tool can take folders and instructions (claude, codex). */
+227
View File
@@ -0,0 +1,227 @@
/**
* @file artifactView.ts
* @description What makes a file an artifact, Claude Desktop's way, and how
* one is shown: its kind from its name (a page, an image, a diagram, a React
* component, a document, a table, code), the sandboxed document an HTML,
* SVG, Mermaid or React artifact runs in (an iframe `srcdoc` with no
* same-origin access, its libraries from a pinned CDN build, as Claude's own
* artifacts load theirs), a CSV's rows, and the versions a conversation's
* Write/Edit calls left of one file — rebuilt from their own inputs, so no
* server keeps copies.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { TranscriptMessage } from "./types";
export type ArtifactKind =
"html" | "svg" | "markdown" | "mermaid" | "react" | "csv" | "image" | "code";
const EXT = (p: string) =>
(/\.([A-Za-z0-9]+)$/.exec(p.split(/[\\/]/).pop() || "")?.[1] || "").toLowerCase();
/** The kind a file shows as. */
export function artifactKind(path: string): ArtifactKind {
const e = EXT(path);
if (e === "html" || e === "htm") return "html";
if (e === "svg") return "svg";
if (e === "md" || e === "markdown" || e === "mdx") return "markdown";
if (e === "mmd" || e === "mermaid") return "mermaid";
if (e === "jsx" || e === "tsx") return "react";
if (e === "csv" || e === "tsv") return "csv";
if (["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico"].includes(e)) return "image";
return "code";
}
/** Kinds with a rendered Preview (the rest show their code only). */
export const RENDERED: ReadonlySet<ArtifactKind> = new Set([
"html",
"svg",
"markdown",
"mermaid",
"react",
"csv",
"image",
]);
/** An edit worth a card in the conversation: a page, a document, a diagram, a
* table — and a React component the model wrote whole (a `.tsx` it merely
* edited is project code, not an artifact). */
export function isArtifact(path: string, created: boolean): boolean {
const k = artifactKind(path);
if (k === "react") return created;
return k === "html" || k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
}
/** A code fence language for the Code tab. */
export function artifactLang(path: string): string {
const e = EXT(path);
const map: Record<string, string> = {
htm: "html",
mmd: "text",
mermaid: "text",
md: "markdown",
mdx: "markdown",
jsx: "jsx",
tsx: "tsx",
js: "javascript",
mjs: "javascript",
cjs: "javascript",
ts: "typescript",
py: "python",
sh: "bash",
yml: "yaml",
};
return map[e] || e;
}
// Pinned builds (all on npm for well over two weeks), as Claude's own
// artifacts load React, Tailwind and Mermaid from a CDN.
const CDN = {
react: "https://cdn.jsdelivr.net/npm/react@18.3.1/umd/react.production.min.js",
reactDom: "https://cdn.jsdelivr.net/npm/react-dom@18.3.1/umd/react-dom.production.min.js",
babel: "https://cdn.jsdelivr.net/npm/@babel/standalone@7.26.4/babel.min.js",
tailwind: "https://cdn.tailwindcss.com/3.4.16",
mermaid: "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs",
};
/** Text safe inside a `<script>` element. */
const scriptSafe = (s: string) => s.replace(/<\/(script)/gi, "<\\/$1");
const htmlEscape = (s: string) =>
s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
/** Shown inside the frame when the artifact itself fails. */
const ERROR_BOX = `<pre id="__err" style="display:none;margin:16px;padding:12px;border-radius:8px;background:#fee2e2;color:#991b1b;font:12px/1.5 ui-monospace,monospace;white-space:pre-wrap"></pre>
<script>window.addEventListener("error",function(e){var b=document.getElementById("__err");if(b){b.style.display="block";b.textContent=String(e.message||e.error||e);}});</script>`;
/** The document a rendered artifact runs in (null: it is not a frame kind). */
export function artifactDocument(kind: ArtifactKind, source: string, dark: boolean): string | null {
switch (kind) {
case "html":
return /<html[\s>]|<!doctype/i.test(source)
? source
: `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body>${source}</body></html>`;
case "svg":
return `<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%}body{display:flex;align-items:center;justify-content:center;background:${dark ? "#1f1f1f" : "#fff"}}svg{max-width:100%;max-height:100vh;height:auto}</style></head><body>${source}</body></html>`;
case "mermaid":
return `<!doctype html><html><head><meta charset="utf-8"><style>body{margin:0;padding:16px;background:${dark ? "#1f1f1f" : "#fff"};display:flex;justify-content:center}</style></head><body>${ERROR_BOX}<pre class="mermaid">${htmlEscape(source)}</pre>
<script type="module">import mermaid from "${CDN.mermaid}";mermaid.initialize({startOnLoad:false,theme:${dark ? '"dark"' : '"default"'},securityLevel:"strict"});mermaid.run().catch(function(e){var b=document.getElementById("__err");b.style.display="block";b.textContent=String(e&&e.message||e);});</script></body></html>`;
case "react":
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<script src="${CDN.react}"></script><script src="${CDN.reactDom}"></script><script src="${CDN.babel}"></script><script src="${CDN.tailwind}"></script></head>
<body><div id="root"></div>${ERROR_BOX}
<script>
(function(){
var src = ${JSON.stringify(scriptSafe(source))};
var mods = { react: window.React, "react-dom": window.ReactDOM, "react-dom/client": window.ReactDOM };
function req(name){ if (mods[name]) return mods[name]; throw new Error("This preview does not provide \\"" + name + "\\" — only react and react-dom are available."); }
try {
var out = Babel.transform(src, { filename: "Artifact.tsx", presets: [["typescript", { isTSX: true, allExtensions: true }], "react"], plugins: ["transform-modules-commonjs"] }).code;
var module = { exports: {} };
new Function("require", "module", "exports", "React", out)(req, module, module.exports, window.React);
var C = module.exports.default || module.exports.App || Object.values(module.exports).find(function(v){ return typeof v === "function"; });
if (!C) throw new Error("No component exported: export a default React component.");
ReactDOM.createRoot(document.getElementById("root")).render(React.createElement(C));
} catch (e) { var b = document.getElementById("__err"); b.style.display = "block"; b.textContent = String(e && e.message || e); }
})();
</script></body></html>`;
default:
return null;
}
}
/** The sandbox a frame kind gets: scripts but never the dashboard's origin
* (no `allow-same-origin`), and none at all for an SVG. */
export const frameSandbox = (kind: ArtifactKind) =>
kind === "svg" ? "" : "allow-scripts allow-forms allow-modals allow-popups allow-downloads";
/** A CSV/TSV's rows (quoted fields, doubled quotes, newlines inside quotes). */
export function parseCsv(text: string, sep = ","): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let field = "";
let quoted = false;
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
if (quoted) {
if (c === '"' && text[i + 1] === '"') {
field += '"';
i++;
} else if (c === '"') quoted = false;
else field += c;
} else if (c === '"' && field === "") quoted = true;
else if (c === sep) {
row.push(field);
field = "";
} else if (c === "\n" || c === "\r") {
if (c === "\r" && text[i + 1] === "\n") i++;
row.push(field);
rows.push(row);
row = [];
field = "";
} else field += c;
}
if (field !== "" || row.length) {
row.push(field);
rows.push(row);
}
return rows;
}
/** One version of a file, as a Write/Edit call left it. */
export interface ArtifactVersion {
/** The call that made it. */
toolUseId: string | null;
timestamp: string | null;
/** null: the call is known, its result text is not (an edit to a file
* whose earlier text the conversation never showed). */
content: string | null;
}
const samePath = (a: unknown, b: string) =>
typeof a === "string" && a.replace(/\\/g, "/") === b.replace(/\\/g, "/");
/** Applies one Edit (old → new, once or everywhere) to known text. */
function applyEdit(text: string, oldS: unknown, newS: unknown, all: unknown): string | null {
if (typeof oldS !== "string" || typeof newS !== "string") return null;
if (!text.includes(oldS)) return null;
return all ? text.split(oldS).join(newS) : text.replace(oldS, () => newS);
}
/**
* The versions of `path` in a conversation, oldest first: each successful
* Write gives its whole text, and each Edit/MultiEdit after a known text gives
* that text with its edits applied. A call whose input the transcript cut, or
* an edit to text never shown, is a version of unknown content (the current
* file on disk stands in for the newest one).
*/
export function artifactVersions(messages: TranscriptMessage[], path: string): ArtifactVersion[] {
const failed = new Set<string>();
for (const m of messages)
for (const c of m.content) if (c.type === "tool_result" && c.is_error && c.id) failed.add(c.id);
const out: ArtifactVersion[] = [];
let text: string | null = null;
for (const m of messages) {
if (m.type !== "assistant") continue;
for (const c of m.content) {
if (c.type !== "tool_use" || (c.id && failed.has(c.id))) continue;
const input = (c.input || {}) as Record<string, unknown>;
if (!samePath(input.file_path, path)) continue;
if (c.name === "Write") {
text = typeof input.content === "string" ? input.content : null;
} else if (c.name === "Edit") {
text =
text === null
? null
: applyEdit(text, input.old_string, input.new_string, input.replace_all);
} else if (c.name === "MultiEdit") {
const edits = Array.isArray(input.edits) ? (input.edits as Record<string, unknown>[]) : [];
for (const e of edits) {
if (text === null) break;
text = applyEdit(text, e.old_string, e.new_string, e.replace_all);
}
} else continue;
out.push({ toolUseId: c.id ?? null, timestamp: m.timestamp, content: text });
}
}
return out;
}
+16 -2
View File
@@ -1,6 +1,7 @@
/**
* @file chatUi.ts — whether a Claude/Codex session shows as a chat
* (`PaneChat`, like Claude Desktop) or as its terminal. Each SESSION has its
* @file chatUi.ts — which tools' terminal panes have a Chat view
* (`CHAT_TOOLS`), and whether such a session shows as a chat (`PaneChat`,
* like Claude Desktop) or as its terminal. Each SESSION has its
* own view (`useSessionChat`, toggled by `setSessionChat` from the pane's
* chat/terminal button), kept in this browser's localStorage
* (`agenthub.chatUi.sessions`, the newest `MAX_VIEWS`). The account's Chat UI
@@ -18,6 +19,19 @@ import { api } from "./api";
export const CHAT_UI_KEY = "agenthub.chatUi";
/** The tools whose terminal pane can show a chat: each one's pane learns its
* session id (Claude's `--session-id`, the others' pollers through
* `claimToolSessions`) and the server reads that session's conversation
* (`GET /api/sessions/:id/transcript`). omp and Cursor send no hooks, so
* their chat has no Stop button and follows at the pollers' pace. */
export const CHAT_TOOLS: ReadonlySet<string> = new Set([
"claude",
"codex",
"omp",
"cursor",
"antigravity",
]);
export const VIEWS_KEY = "agenthub.chatUi.sessions";
const MAX_VIEWS = 200;
+119 -14
View File
@@ -14,9 +14,10 @@
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
* input box off the screen and empties it; the chat's draft is pasted back
* without Enter), so the half-typed text is never left behind on the other side.
* Attached images' paths go after the text, each in a paste of its own: `claude`
* turns a paste that is only an image's path into `[Image #n]`, but left a
* Windows path (`C:\…\drops\…png`) inside a paste of text as a plain path.
* Attached images' paths go after the text, each in a paste of its own — how
* `codex` attaches one; a `claude` pane gets every file as an `@` mention in
* the text instead (`promptForPane`), since a pasted path stayed a plain
* `C:\…\drops\…png` there on Windows.
* The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
* arrows and Enter, or type into its "Type something" row).
@@ -33,20 +34,24 @@ interface Pane {
key?: (data: string) => void;
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */
screen?: (menuOnly?: boolean) => PaneScreen | null;
/** The rows on screen as plain text, dim cells kept. */
lines?: () => string[];
}
const panes = new Map<string, Pane>();
let lastFocused: string | null = null;
/** Registers a mounted terminal (`visible`: is it on screen now; `key`: send
* raw input, as a key press; `screen`: its rows now); returns its unregister. */
* raw input, as a key press; `screen`: its rows now; `lines`: the same as
* plain text); returns its unregister. */
export function registerPane(
runId: string,
paste: (text: string, submit?: boolean) => void,
visible: () => boolean = () => true,
key?: (data: string) => void,
screen?: (menuOnly?: boolean) => PaneScreen | null
screen?: (menuOnly?: boolean) => PaneScreen | null,
lines?: () => string[]
): () => void {
const entry = { paste, visible, key, screen };
const entry = { paste, visible, key, screen, lines };
panes.set(runId, entry);
return () => {
if (panes.get(runId) === entry) panes.delete(runId);
@@ -58,6 +63,26 @@ export function notePaneFocus(runId: string): void {
lastFocused = runId;
}
/** Who wants to hear that a pane drew something (its chat: the CLI is doing
* something, so its transcript may have grown). */
const outputListeners = new Map<string, Set<() => void>>();
/** A terminal received output for `runId` (TerminalView, every PTY frame). */
export function notePaneOutput(runId: string): void {
outputListeners.get(runId)?.forEach((fn) => fn());
}
/** Calls `fn` on every output `runId`'s terminal receives; returns its unregister. */
export function onPaneOutput(runId: string, fn: () => void): () => void {
let set = outputListeners.get(runId);
if (!set) outputListeners.set(runId, (set = new Set()));
set.add(fn);
return () => {
set!.delete(fn);
if (!set!.size) outputListeners.delete(runId);
};
}
/** The tmux names of workspace `laneId`'s panes: `agenthub-lane-<id>[-slot]`. */
function ofLane(runId: string, laneId: number): boolean {
const base = `agenthub-lane-${laneId}`;
@@ -161,32 +186,47 @@ export function takeRunInput(runId: string): string {
}
/** One choice of a menu the CLI shows: `n` its number (null for Claude's
* unnumbered Submit row), `checked` a multiSelect box's state. */
* unnumbered Submit / Next row), `checked` a multiSelect box's state. */
export type PaneMenuOption = {
n: number | null;
label: string;
hint: string;
checked: boolean | null;
};
/** One question tab of an AskUserQuestion that asks several: `done` once it
* has an answer (`☒`). */
export type PaneMenuTab = { label: string; done: boolean };
/** A menu the CLI asks with (AskUserQuestion, a permission or trust prompt,
* Codex's approval): `cursor` the row its `❯`/`›` is on, `typeAt` the
* "Type something" row (-1: none). */
* "Type something" row (-1: none). `tabs` only when it asks several
* questions (`← ☒ A □ B ✔ Submit →`), `tab` the one shown (-1: unknown).
* `detail` what a permission prompt asks about (its tool, then the command),
* when the screen shows it apart from the question. */
export type PaneMenu = {
question: string;
options: PaneMenuOption[];
cursor: number;
multi: boolean;
typeAt: number;
tabs?: PaneMenuTab[];
tab?: number;
detail?: string[];
};
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
/** A row only a menu shows: its cursor on a numbered option or Submit, or the
* "Enter to confirm" footer of Claude's unnumbered menus (the trust prompt). */
export const MENU_CURSOR_ROW = /^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)|Enter to confirm)/;
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
export const MENU_CURSOR_ROW =
/^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |(?:Submit|Next)\s*$)|Enter to confirm)/;
// The unnumbered button under a multiSelect's rows: Next while more questions
// follow, Submit on the last one.
const SUBMIT = /^ *([❯›])?[ \u00a0]*(Submit|Next)$/;
const TAB_BAR = /^ *← +(.+?) +→ *$/;
const RULE_ROW = /^ *[─╌]{3,} *$/;
const DASHED_ROW = /^ *╌{3,} *$/;
const CHECK = /^\[([ ✔])\] *(.*)$/;
const PLACEHOLDER = /^Type something\.?$/;
const COLUMN = /^(\S.*?\S) {2,}(\S.*)$/;
/** The numbered menu at the bottom of `rows`, or null. A menu has two
* numbered rows or more from 1, the cursor on one of them, and no rule right
@@ -206,10 +246,13 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
const m = OPTION.exec(row);
const s = m ? null : SUBMIT.exec(row);
if (m || s) {
const text = m ? (m[3] ?? "") : "Submit";
const text = m ? (m[3] ?? "") : (s?.[2] ?? "Submit");
const c = m ? CHECK.exec(text) : null;
const label = c ? (c[2] ?? "") : text;
const h = hint.join(" ");
// Codex's pickers (`/model`, `/permissions`) draw each row's
// description in a column after the name: split it off as the hint.
const col = COLUMN.exec(c ? (c[2] ?? "") : text);
const label = col ? col[1]! : c ? (c[2] ?? "") : text;
const h = [col?.[2], ...hint].filter(Boolean).join(" ");
options.unshift({
n: m ? Number(m[2]) : null,
label,
@@ -238,15 +281,47 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
const lines: string[] = [];
for (; q >= 0 && r[q] && !RULE_ROW.test(r[q]!) && lines.length < 4; q--)
lines.unshift(r[q]!.trim());
const question = lines.join("\n");
const tabs = tabBar(r.slice(Math.max(0, q - 4), Math.max(0, q + 1)));
const low = question.toLowerCase();
// What a permission prompt asks about: Claude draws the tool and its
// command over a dashed rule above the question, up to the dialog's top rule.
const detail: string[] = [];
let d = q;
while (d >= 0 && !r[d]) d--;
if (d >= 0 && DASHED_ROW.test(r[d]!))
for (d--; d >= 0 && !RULE_ROW.test(r[d]!) && detail.length < 12; d--)
if (r[d]) detail.unshift(r[d]!.trim());
return {
question: lines.join("\n"),
question,
options,
cursor: options.length - cursor,
multi: options.some((o) => o.checked !== null),
typeAt: typeRow(options),
...(tabs.length > 1
? { tabs, tab: tabs.findIndex((t) => low.startsWith(t.label.toLowerCase())) }
: {}),
...(detail.length ? { detail } : {}),
};
}
/** The question tabs of a multi-question AskUserQuestion, read off its bar
* (`← ☒ Critical □ High 1/3 ✔ Submit →`) among `rows`; the Submit tab is
* no question. [] when there is no bar. */
function tabBar(rows: string[]): PaneMenuTab[] {
for (let i = rows.length - 1; i >= 0; i--) {
const m = TAB_BAR.exec(rows[i] ?? "");
if (!m) continue;
return (m[1] ?? "")
.split(/(?=[☒☐□✔☑■] )/)
.map((x) => x.trim())
.filter(Boolean)
.map((x) => ({ label: x.slice(1).trim(), done: /^[☒✔☑■]/.test(x) }))
.filter((t) => t.label && t.label !== "Submit");
}
return [];
}
const BARE_CURSOR = /^( {0,4})❯[ \u00a0]+(\S.*)$/;
/** Claude's UNNUMBERED menu at the bottom of `r` (the folder-trust prompt:
@@ -290,6 +365,26 @@ function typeRow(options: PaneMenuOption[]): number {
return -1;
}
/** The status line a tool's TUI shows only while a turn runs, read off its
* binary's own strings: Claude's and Codex's `esc to interrupt`, agy's
* `Thinking… (3s · esc to cancel)` — in parentheses after an ellipsis, so
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it. A
* tool missing here (omp, Cursor) has no line we know. */
export const BUSY_LINE: Readonly<Record<string, RegExp>> = {
claude: /\besc to interrupt\b/i,
codex: /\besc to interrupt\b/i,
antigravity: /…\s*\([^)]*\besc to cancel\)/i,
};
/** Whether `runId`'s pane shows `tool` at work now (its BUSY_LINE), or null
* when the tool's line is unknown or the pane is not mounted. */
export function readRunBusy(runId: string, tool: string): boolean | null {
const re = BUSY_LINE[tool];
const p = panes.get(runId);
if (!re || !p?.lines) return null;
return p.lines().some((l) => re.test(l));
}
/** The menu `runId`'s visible pane shows now, or null. */
export function readRunMenu(runId: string): PaneMenu | null {
const p = panes.get(runId);
@@ -321,6 +416,16 @@ export function chooseRunOption(runId: string, menu: PaneMenu, index: number): b
return true;
}
/** Moves a multi-question AskUserQuestion to its previous (-1) or next (1)
* question tab, the arrows Claude's footer names. False when the pane is not
* visible or not attached. */
export function stepRunMenu(runId: string, dir: -1 | 1): boolean {
const p = panes.get(runId);
if (!p?.visible() || !p.key) return false;
p.key(dir < 0 ? "\x1b[D" : "\x1b[C");
return true;
}
/** Answers `menu` with typed text: the arrows to its "Type something" row,
* Ctrl+U over what is typed there already (one key clears it, however long
* it is), the text as a bracketed
+108 -24
View File
@@ -4,12 +4,17 @@
* file or folder dragged from the Files panel (`ATTACH_MIME`, read back by
* `PromptEditor`'s drop) or from a Chat pane's activity panel
* (`dragAttachments`, `dragSkill`: a skill becomes the prompt's `/command`) and the files uploaded through a prompt box in the
* last day (`recentUploads`, kept in this browser). An attachment is a path
* sent with the prompt, so nothing here touches the server.
* last day (`recentUploads`, kept in this browser), and `claudeMention`, the
* `@` form a file is handed to `claude` in, and what a browser on the
* dashboard's own machine attaches by path instead of uploading
* (`localCandidates`, checked by the server in `sameFilesHere` —
* `POST /api/run/local-paths` —, `localAttachment`). An attachment is a path
* sent with the prompt; that check is the one call here to the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { DragEvent } from "react";
import { api } from "./api";
/** One attachment, shaped like `PromptImage`: what is sent, and its label. */
export interface Attachment {
@@ -64,6 +69,76 @@ export function droppedAttachments(dt: DataTransfer | null | undefined): Attachm
}
}
/** Whether this browser reaches the dashboard by a loopback address — a
* hint only: an SSH tunnel's browser does too, from another machine, so a
* dropped path is used only once `sameFilesHere` has checked it. */
export function isLocalBrowser(): boolean {
return /^(localhost|127(\.\d{1,3}){3}|\[::1\])$/.test(window.location.hostname);
}
/** The `file://` paths a drop or paste carries beside its `files`, when this
* may be a browser on the dashboard's machine — read now: a DataTransfer is
* empty once its event is over. Null when there is nothing to check. */
export function localCandidates(
dt: DataTransfer | null | undefined,
files: File[]
): string[] | null {
if (!isLocalBrowser()) return null;
const paths = droppedFilePaths(dt);
return paths && paths.length === files.length ? paths : null;
}
/** Whether `paths` are the dropped `files` as the dashboard's machine sees
* them: every one a regular file there, and the sizes the same. Anything
* else (an SSH tunnel's laptop paths, server mode, an error) is false, and
* the caller uploads the bytes as before. */
export async function sameFilesHere(paths: string[], files: File[]): Promise<boolean> {
try {
const { sizes } = await api.run.localPaths(paths);
const want = files.map((f) => f.size).sort((a, b) => a - b);
const have = sizes.slice().sort((a, b) => (a ?? -1) - (b ?? -1));
return have.length === want.length && have.every((s, i) => s === want[i]);
} catch {
return false;
}
}
/** The local files a drop or a paste names as `file://` URIs (a file
* manager's, an editor's), as absolute paths — or null when it names none,
* or anything that is not one. `file:///C:/x` is `C:/x`. */
export function droppedFilePaths(dt: DataTransfer | null | undefined): string[] | null {
try {
const paths = (dt?.getData("text/uri-list") ?? "")
.split(/\r?\n/)
.filter((l) => l && !l.startsWith("#"))
.map((l) => {
try {
const u = new URL(l.trim());
if (u.protocol !== "file:" || (u.host && u.host !== "localhost")) return null;
return decodeURIComponent(u.pathname).replace(/^\/([A-Za-z]:\/)/, "$1");
} catch {
return null;
}
});
return paths.length && paths.every(Boolean) ? (paths as string[]) : null;
} catch {
return null;
}
}
/** A file on this machine, by its absolute path, as an attachment. */
export function localAttachment(path: string): Attachment {
return {
// A space would end the mention: Claude Code reads `@"…"` whole.
path: /\s/.test(path) ? `@"${path}"` : `@${path}`,
name:
path
.replace(/[\\/]$/, "")
.split(/[\\/]/)
.pop() || path,
};
}
/** A drop that carries no file and none of ours, only text — a file manager
* or an editor (VS Code) that hands over `file://` URIs, a selection, a
* link: the local `file://` URIs as `@path` attachments, else the text to
@@ -72,28 +147,8 @@ export function droppedText(
dt: DataTransfer | null | undefined
): { attachments: Attachment[] } | { text: string } | null {
try {
const paths = (dt?.getData("text/uri-list") ?? "")
.split(/\r?\n/)
.filter((l) => l && !l.startsWith("#"))
.map((l) => {
try {
const u = new URL(l.trim());
return u.protocol === "file:" && (!u.host || u.host === "localhost")
? decodeURIComponent(u.pathname)
: null;
} catch {
return null;
}
});
if (paths.length && paths.every(Boolean)) {
return {
attachments: (paths as string[]).map((p) => ({
// A space would end the mention: Claude Code reads `@"…"` whole.
path: /\s/.test(p) ? `@"${p}"` : `@${p}`,
name: p.replace(/\/$/, "").split("/").pop() || p,
})),
};
}
const paths = droppedFilePaths(dt);
if (paths) return { attachments: paths.map(localAttachment) };
const text = dt?.getData("text/plain") ?? "";
return text.trim() ? { text } : null;
} catch {
@@ -101,6 +156,35 @@ export function droppedText(
}
}
/** An attached file as Claude Code's `@` mention, which `claude` reads at
* submit — an image as an image — whether it came on the command line or in
* a paste. A bare pasted PATH becomes `[Image #n]` only when the TUI catches
* the paste on its own and finishes before Enter: on Windows (psmux) it
* stayed a plain `C:\…\drops\…png` in the prompt, and on the command line
* a path is never an image at all. A Windows path's backslashes become `/`
* (Windows takes either); a path with a space is quoted. */
export function claudeMention(path: string): string {
if (path.startsWith("@")) return path;
const p = /^[A-Za-z]:\\/.test(path) ? path.replace(/\\/g, "/") : path;
return /\s/.test(p) ? `@"${p}"` : `@${p}`;
}
const IMAGE_PATH = /\.(png|jpe?g|gif|webp)$/i;
/** What a terminal pastes for files dropped or pasted on a `claude` pane, one
* paste per entry, as a local terminal hands them over: the images as their
* bare paths, each a paste of its own — the shape Claude Code turns into
* `[Image #n]`, as it does a native drop — and every other file as an `@`
* mention, together in one. A Windows path, or
* one with a space, stays a mention — the bare path stayed plain text there
* (see `claudeMention`). */
export function claudePastes(paths: string[]): string[] {
const bare = (p: string) => IMAGE_PATH.test(p) && p.startsWith("/") && !/\s/.test(p);
const images = paths.filter(bare);
const others = paths.filter((p) => !bare(p)).map(claudeMention);
return [...images, others.join(" ")].filter(Boolean);
}
export const SKILL_MIME = "application/x-agenthub-skill";
// A skill's name as Claude Code types it: `name` or `plugin:name`.
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
+49 -9
View File
@@ -1,22 +1,37 @@
/**
* @file sessionDefaults.ts
* @description The account's own starting choices for new work, picked in the
* first-run setup (docs/plans/onboarding) and synced per account like every
* other preference (`lib/userPrefs.ts`): the AI a new workspace starts on, and
* the permission mode a new Claude session starts in. Bypass is NEVER one of
* them — it stays the per-session switch (CLAUDE.md), so `bypassPermissions`
* is refused on read and on write.
* first-run setup (docs/plans/onboarding) or Settings → AI tools → Session
* defaults, and synced per account like every other preference
* (`lib/userPrefs.ts`): the AI a new workspace starts on, the permission mode
* a new Claude session starts in, and a new Codex session's sandbox, approval
* and bypass. Bypass (Claude's `bypassPermissions`, Codex's bypass switch) may
* be a default only because the user turned it on in Settings behind a
* confirm — the first-run setup never offers it, and the pet chat never
* starts in it (`readDefaultMode({ safe: true })`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { PermissionMode } from "./api";
import type { CodexApproval, CodexSandbox, PermissionMode } from "./api";
const TOOL_KEY = "agenthub.defaultTool";
const MODE_KEY = "agenthub.permissionMode";
const CODEX_SANDBOX_KEY = "agenthub.codex.sandbox";
const CODEX_APPROVAL_KEY = "agenthub.codex.approval";
const CODEX_BYPASS_KEY = "agenthub.codex.bypass";
/** The permission modes a default may be. */
export const DEFAULT_MODES = ["auto", "acceptEdits", "default", "plan"] as const;
export const DEFAULT_MODES = ["auto", "acceptEdits", "default", "plan", "bypassPermissions"] as const;
export type DefaultMode = (typeof DEFAULT_MODES)[number];
/** The ones the first-run setup offers: never bypass. */
export const SAFE_MODES = DEFAULT_MODES.filter((m) => m !== "bypassPermissions");
export const CODEX_SANDBOXES: readonly CodexSandbox[] = [
"read-only",
"workspace-write",
"danger-full-access",
];
export const CODEX_APPROVALS: readonly CodexApproval[] = ["on-request", "never"];
function read(key: string): string | null {
try {
@@ -37,11 +52,36 @@ function write(key: string, value: string): void {
export const readDefaultTool = (): string | null => read(TOOL_KEY);
export const writeDefaultTool = (id: string): void => write(TOOL_KEY, id);
/** The permission mode a new Claude session starts in ("auto" when none was picked). */
export function readDefaultMode(): PermissionMode {
/** The permission mode a new Claude session starts in ("auto" when none was
* picked); `safe` turns a bypass default into "auto". */
export function readDefaultMode({ safe = false }: { safe?: boolean } = {}): PermissionMode {
const v = read(MODE_KEY);
if (safe && v === "bypassPermissions") return "auto";
return (DEFAULT_MODES as readonly string[]).includes(v ?? "") ? (v as DefaultMode) : "auto";
}
export function writeDefaultMode(mode: DefaultMode): void {
if ((DEFAULT_MODES as readonly string[]).includes(mode)) write(MODE_KEY, mode);
}
export interface CodexDefaults {
sandbox: CodexSandbox;
approval: CodexApproval;
bypass: boolean;
}
/** A new Codex session's sandbox, approval and bypass (Codex's own defaults when none was picked). */
export function readCodexDefaults(): CodexDefaults {
const sandbox = read(CODEX_SANDBOX_KEY) as CodexSandbox | null;
const approval = read(CODEX_APPROVAL_KEY) as CodexApproval | null;
return {
sandbox: sandbox && CODEX_SANDBOXES.includes(sandbox) ? sandbox : "workspace-write",
approval: approval && CODEX_APPROVALS.includes(approval) ? approval : "on-request",
bypass: read(CODEX_BYPASS_KEY) === "true",
};
}
export function writeCodexDefaults(patch: Partial<CodexDefaults>): void {
if (patch.sandbox && CODEX_SANDBOXES.includes(patch.sandbox)) write(CODEX_SANDBOX_KEY, patch.sandbox);
if (patch.approval && CODEX_APPROVALS.includes(patch.approval))
write(CODEX_APPROVAL_KEY, patch.approval);
if (patch.bypass !== undefined) write(CODEX_BYPASS_KEY, String(patch.bypass === true));
}
+22 -2
View File
@@ -732,7 +732,10 @@ export interface Session {
/** The AI coding CLI that produced the session: `"claude"` (the default —
* Claude Code's hooks and imported transcripts), `"codex"` (Codex's rollout
* journals and hooks), `"antigravity"` (agy's conversation files and
* hooks) or `"cursor"` (Cursor's agent transcripts, no tokens). Attach/resume offer `claude` rows only. Maps to `sessions.tool`. */
* hooks), `"cursor"` (Cursor's agent transcripts, no tokens), `"omp"`,
* `"opencode"` (their own session stores, with tokens and subagents) or
* `"grok"` (Grok running the Claude hooks, no tokens). Maps to
* `sessions.tool`. */
tool?: string;
/** The workspace (lane id) that owns the session — the one whose pane held
* it; null for a session no workspace started.
@@ -1927,7 +1930,10 @@ export interface WSMessage {
/** A pane ended; refetch GET /api/run/interrupted (no payload). */
| "runs_interrupted"
/** An artifact was added, changed, shared or deleted; refetch the list. */
| "artifact_changed";
| "artifact_changed"
/** A session's transcript grew on disk (`{session_id}`); a chat showing
* it reads the new lines now. */
| "transcript_changed";
/** The message body, whose concrete shape is selected by `type` above. */
data:
| Session
@@ -3225,6 +3231,17 @@ export interface TranscriptContent {
/** Present on an AskUserQuestion "tool_result": question text → the option
* the user picked, or what they typed. */
answers?: Record<string, string>;
/** Present on an Agent/Task "tool_result": the subagent it ran — its id
* (the transcript's `agent_id`), model and, for a foreground run, its time,
* tokens and tool calls (null when the result does not say). */
agent?: {
id: string;
model: string | null;
status: string | null;
duration_ms: number | null;
tokens: number | null;
tool_uses: number | null;
};
}
/** Who actually sent a transcript message. A JSONL `type:"user"` line can be the
@@ -3310,6 +3327,9 @@ export interface TranscriptInfo {
/** The subagent type for subagent entries; null/absent otherwise (e.g. the
* main or compaction entries). */
subagent_type?: string | null;
/** The Agent call that launched this subagent (its `tool_use` id), when its
* meta file names it. */
tool_use_id?: string | null;
/** Whether a JSONL transcript file was actually found on disk for this entry -
* false means the entry exists in the DB but its transcript isn't available
* (e.g. the raw file was pruned), so the UI disables that picker option. */
+3
View File
@@ -38,6 +38,9 @@ export const PREF_KEYS = [
"agent-monitor-notifications",
"agenthub.defaultTool",
"agenthub.permissionMode",
"agenthub.codex.sandbox",
"agenthub.codex.approval",
"agenthub.codex.bypass",
"agenthub.tour",
"agenthub.workspace.allTabs",
] as const;
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -107,7 +107,7 @@ export function GitCredentialsConfig() {
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center justify-between gap-4">
{/* The page header above already names the section. */}
<p className="max-w-2xl text-xs text-fg-muted">{t("gitCreds.description")}</p>
<button
@@ -128,7 +128,7 @@ export function GitCredentialsConfig() {
{form && (
<form
className="grid max-w-2xl gap-2 rounded-lg border border-border p-3 sm:grid-cols-3"
className="grid gap-2 rounded-lg border border-border bg-surface-1 p-3 sm:grid-cols-3"
onSubmit={(e) => {
e.preventDefault();
void save();
@@ -159,18 +159,18 @@ export function GitCredentialsConfig() {
onChange={(e) => setForm({ ...form, token: e.target.value })}
className={INPUT}
/>
<div className="flex gap-2 sm:col-span-3">
<div className="flex justify-end gap-2 sm:col-span-3">
<button
type="submit"
disabled={saving || !form.url.trim() || !form.username.trim() || !form.token}
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
className="rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitCreds.save")}
</button>
<button
type="button"
onClick={() => setForm(null)}
className="rounded border border-border-light px-3 py-1 text-xs text-fg-secondary hover:bg-surface-3"
className="rounded-lg border border-border-light px-3 py-1.5 text-xs text-fg-secondary hover:bg-surface-3"
>
{t("gitCreds.cancel")}
</button>
+2
View File
@@ -67,6 +67,7 @@ import { SnapshotSettings } from "../components/settings/SnapshotSettings";
import { ReportSettings } from "../components/settings/ReportSettings";
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
import { SessionDefaultsSettings } from "../components/settings/SessionDefaultsSettings";
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
@@ -292,6 +293,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
render: () => (
<div className="space-y-6">
<AgentInstructionsSettings />
<SessionDefaultsSettings />
<AiToolsSettings settingsHref={toolHref} />
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
<ArtifactsHubSettings />
+197 -6
View File
@@ -1,14 +1,18 @@
/**
* @file Tests for the `/artifacts` page: the library lists what the server
* answers, the Hub tab and Share appear only in server mode, a skill is
* answers (one merged list; All/My library/Hub filter and Share only in
* server mode), an installed skill shows Installed, its install count lists
* who installed it and where, a skill is
* installed only after a confirmation (and a taken name asks to replace),
* and a file preview is a sandboxed iframe with no `allow-*` token. Adding:
* an HTML preview is a sandboxed iframe with no `allow-*` token, and a
* skill's SKILL.md is rendered by the page itself. Adding:
* a plugin is picked from the list its marketplace link answers (a link the
* server cannot read opens the by-hand form), a skill is uploaded from one
* Markdown file under the name the user types, or written in the dialog.
* A card's right-click menu runs the same actions (Preview, Delete). A file
* the sandbox cannot show (Office, zip, PDF) offers Download only, and the
* Add menu closes on Escape or a click outside it.
* the sandbox cannot show (Office, zip, PDF) offers Download only, the
* Add menu closes on Escape or a click outside it, the front page is shelved
* by kind until a category is picked, and a card opens its detail sheet.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -29,6 +33,8 @@ vi.mock("../../lib/api", () => ({
update: vi.fn(),
remove: vi.fn(),
install: vi.fn(),
uninstall: vi.fn(),
installs: vi.fn(),
setInstallsEnabled: vi.fn(),
skills: vi.fn(),
addSkill: vi.fn(),
@@ -58,6 +64,7 @@ const item = (over: Partial<Artifact>): Artifact => ({
market: null,
url: null,
files: [{ path: "report.html", size: 10 }],
inLibrary: true,
size: 10,
shared: false,
sharedAt: null,
@@ -208,9 +215,109 @@ describe("Artifacts", () => {
installsEnabled: true,
canManage: false,
});
const user = userEvent.setup();
open();
expect(await screen.findByRole("button", { name: "Hub" })).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /^Share$/ }).length).toBe(2);
// One merged list by default; Hub is a filter over it.
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" }));
await user.click(screen.getByRole("button", { name: "Hub" }));
await waitFor(() =>
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "hub" }))
);
});
it("lists who installed an artifact, when and where, from its install count", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installs: 3 })],
});
vi.mocked(api.artifacts.installs).mockResolvedValue({
items: [
{
user: "vi",
mine: true,
at: "2026-10-19T10:00:00Z",
replaced: false,
paths: ["/h/.claude/skills/deploy"],
},
{ user: "khoa", mine: false, at: "2026-10-18T10:00:00Z", replaced: true },
],
unrecorded: 1,
});
const user = userEvent.setup();
open();
await user.click(await screen.findByRole("button", { name: "3 installs" }));
const box = await screen.findByTestId("artifact-installs");
expect(api.artifacts.installs).toHaveBeenCalledWith(2);
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
expect(within(box).getByText(/khoa/)).toBeInTheDocument();
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByTestId("artifact-installs")).toBeNull();
});
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,
canShare: true,
installsEnabled: true,
canManage: false,
});
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [item({ id: 3, title: "hub-only", shared: true, inLibrary: false })],
});
vi.mocked(api.artifacts.update).mockResolvedValue({ item: item({ id: 3 }) } as never);
const user = userEvent.setup();
open();
const card = await screen.findByTestId("artifact-3");
expect(within(card).getByText("Hub only")).toBeInTheDocument();
expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(within(card).getByRole("button", { name: "Remove from Hub" }));
await waitFor(() => expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false }));
const calls = vi.mocked(confirmDialog).mock.calls;
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
});
it("shows Installed instead of Install for a skill already installed", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installed: true })],
});
open();
const card = await screen.findByTestId("artifact-2");
expect(within(card).getByTestId("artifact-installed")).toHaveTextContent("Installed");
expect(within(card).queryByRole("button", { name: "Install" })).toBeNull();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
vi.mocked(api.artifacts.install).mockResolvedValueOnce({
installed: "/h/.claude/skills/deploy",
});
await userEvent.setup().click(within(card).getByRole("button", { name: "Reinstall" }));
await waitFor(() => expect(api.artifacts.install).toHaveBeenLastCalledWith(2, true));
});
it("uninstalls an installed plugin only after a confirmation", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({ id: 4, kind: "plugin", title: "tool", source: "tool@market", installed: true }),
],
});
vi.mocked(api.artifacts.uninstall).mockReset();
open();
const card = await screen.findByTestId("artifact-4");
const user = userEvent.setup();
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(within(card).getByRole("button", { name: "Uninstall" }));
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
expect(api.artifacts.uninstall).not.toHaveBeenCalled();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
vi.mocked(api.artifacts.uninstall).mockResolvedValueOnce({
uninstalled: "tool@market",
paths: [],
});
await user.click(within(card).getByRole("button", { name: "Uninstall" }));
await waitFor(() => expect(api.artifacts.uninstall).toHaveBeenCalledWith(4));
});
it("installs a skill only after a confirmation, and asks before replacing", async () => {
@@ -234,13 +341,79 @@ describe("Artifacts", () => {
it("previews a file in a sandboxed iframe that may run nothing", async () => {
const user = userEvent.setup();
open();
await screen.findByText("report.html");
await user.click(screen.getAllByRole("button", { name: "Preview" })[0]!);
const card = await screen.findByTestId("artifact-1");
await user.click(within(card).getByRole("button", { name: "Preview" }));
const frame = await screen.findByTestId("artifact-preview");
expect(frame.getAttribute("sandbox")).toBe("");
expect(frame.getAttribute("src")).toBe("/api/artifacts/1/raw?path=report.html");
});
it("renders a skill's SKILL.md itself instead of an iframe", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({
kind: "skill",
title: "standup",
files: [
{ path: "SKILL.md", size: 10 },
{ path: "notes.txt", size: 5 },
],
}),
],
});
const fetchMock = vi.fn(async (url: string) =>
url.includes("SKILL.md")
? new Response("---\nname: standup\n---\n# Daily standup\n\nSummarise.")
: new Response("plain notes")
);
vi.stubGlobal("fetch", fetchMock);
const user = userEvent.setup();
open();
await screen.findByText("standup");
await user.click(screen.getByRole("button", { name: "Preview" }));
expect(await screen.findByRole("heading", { name: "Daily standup" })).toBeInTheDocument();
expect(screen.getByTestId("skill-summary")).toHaveTextContent("/out/report.html");
expect(screen.queryByTestId("artifact-preview")).toBeNull();
await user.click(screen.getByRole("button", { name: "notes.txt" }));
expect(await screen.findByText("plain notes")).toBeInTheDocument();
vi.unstubAllGlobals();
});
it("edits a skill's name, description and instructions from its SKILL.md", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({
kind: "skill",
title: "standup",
source: "standup",
files: [{ path: "SKILL.md", size: 10 }],
}),
],
});
vi.mocked(api.artifacts.update).mockResolvedValue({ item: item({}) } as never);
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("---\nname: standup\ndescription: Old\n---\n\nOld body"))
);
const user = userEvent.setup();
open();
await screen.findByText("standup");
await user.click(screen.getByRole("button", { name: "Edit" }));
const name = await screen.findByLabelText(/Skill name/);
expect(name).toHaveValue("standup");
expect(screen.getByLabelText(/Instructions/)).toHaveValue("Old body");
await user.clear(screen.getByLabelText(/Description/));
await user.type(screen.getByLabelText(/Description/), "New");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(api.artifacts.update).toHaveBeenCalledWith(1, {
title: "standup",
skill: { name: "standup", description: "New", content: "Old body" },
})
);
vi.unstubAllGlobals();
});
it("opens a card's actions on right-click", async () => {
const user = userEvent.setup();
open();
@@ -278,4 +451,22 @@ describe("Artifacts", () => {
await user.click(screen.getByText("report.html"));
expect(screen.queryByRole("button", { name: "Skill" })).toBeNull();
});
it("shelves the front page by kind, and a card opens its detail sheet", async () => {
const user = userEvent.setup();
open();
await screen.findByText("deploy");
const shelves = screen.getAllByRole("heading", { level: 2 }).map((h) => h.textContent);
expect(shelves).toEqual(["Skills1", "Files1"]);
await user.click(screen.getAllByRole("button", { name: "See all" })[0]!);
await waitFor(() =>
expect(api.artifacts.list).toHaveBeenLastCalledWith(
expect.objectContaining({ kind: "skill" })
)
);
await user.click(screen.getByText("deploy"));
const sheet = await screen.findByTestId("artifact-detail");
expect(within(sheet).getByText("/deploy")).toBeInTheDocument();
expect(within(sheet).getByRole("button", { name: "Install" })).toBeInTheDocument();
});
});
@@ -7239,7 +7239,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.10 (rebuild client to match)
UI build v2.8.14 (rebuild client to match)
</span>
</p>
</div>
+74 -16
View File
@@ -89,9 +89,9 @@ In server mode a **developer** sees only rows of the workspaces they own (`lanes
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], blockPercent}` — `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name.
An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude"|"codex", name, currentPassword, …}` → `201 {account}`. For Claude the body carries one of: `profile` (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — `409 EACTIVE` only for a macOS Keychain login; `name` defaults to it), `credentials` (the text of a `.credentials.json`; Anthropic is asked whose account it is, `502 EUPSTREAM` when it cannot be reached), or `token` (a `claude setup-token` token). For Codex, `token` is an OpenAI API key or the text of an `auth.json`. `GET /api/accounts/profiles` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email` and `refreshError`; a login's `expiresAt` is null and removes one with `DELETE /api/accounts/:name` `{tool, currentPassword}` → `{ok: true}`. No answer carries the token. `403 EREAUTH` wrong password (`429 ETOOMANY` after 10), `403 EADMIN` for a non-admin, `403 ELOCALMODE` in local mode, `400 EBADTOOL`/`EBADNAME`/`EBADTOKEN`, `409 EEXISTS`, `404 ENOACCOUNT`; each change is audited by account name. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins; deleting a user drops theirs.
**The account's own preferences and first-run setup** (`server/routes/me.js`; local mode is the one account `local`, server mode one row per account in `user_prefs`):
@@ -110,7 +110,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
| `PUT /api/me/avatar` (raw image bytes) | the account's picture (PNG, JPEG, GIF or WebP by magic bytes — never SVG), stored as `<data dir>/avatars/u<id>.<ext>`; the `/api/me/account` shape back with `user.avatarAt` set. `400 EBADIMAGE`, `413 ETOOBIG` over 1 MB, `403 ELOCALMODE`. Login cookie only, audited |
| `DELETE /api/me/avatar` | removes it (`avatarAt` back to `null`); deleting the user removes it too |
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (never `bypassPermissions` — bypass stays the per-session switch), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (`bypassPermissions` allowed — set only from Settings → AI tools → Session defaults, behind a confirm), `agenthub.codex.sandbox`, `agenthub.codex.approval`, `agenthub.codex.bypass` (`"true"`/`"false"`: a new Codex session's defaults), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
@@ -241,6 +241,11 @@ already open, `500 EDIALOG` when the program failed. The dialog is closed when
the request goes away (a closed tab, an SSH tunnel's browser leaving). A
**Browse** button in the UI (`client/src/components/PathPickButton.tsx`) falls
back on ANY of those to the in-app browser `GET /api/lanes/browse?path=<abs>`.
A prompt box's `+` menu offers **Choose a file on this computer** (`kind:
"file"`, `start` = the box's folder) to a browser on a loopback host only; the
chosen file is attached as an `@path` mention by its own path — any size,
nothing uploaded. A toast says the dialog is open (it may open behind the
browser); a failure is a toast too, with no in-app fallback.
A file field asks it with `files=1`: entries then include the folder's files
(`file: true`, listed after the folders), and `path` opens the way the native
picker's `start` does (`~` expanded, a file's folder, home when missing) —
@@ -275,6 +280,8 @@ GET /api/lanes/:id/fs/trash
POST /api/lanes/:id/fs/trash/restore { id } → { path, laneId }
POST /api/lanes/:id/fs/trash/purge { id | "all" } → { purged }
POST /api/lanes/:id/fs/upload?path=&conflict= raw body, streamed → 201 { path } | 200 { skipped: true }
POST /api/lanes/:id/fs/upload-check { dir?, paths } → { taken: [paths], batch }
POST /api/lanes/:id/fs/upload-batch?dir=&conflict= length + JSON list + bytes → 201 { saved, skipped, paths }
GET /api/lanes/:id/fs/zip?p=<path>&p=… application/zip, streamed
```
@@ -295,10 +302,28 @@ Under S3 a move/copy/delete needs the installed `write-as` to know
`move`/`copy`: `409 EUPGRADE` until `agenthub server install-system` is
re-run.
Many small files cost per FILE (a request, a path walk, under S3 a sudo
`write-as` each), so the Files page sends files up to 4 MB in **batches**
(up to 1,000 files / 32 MB per request; bigger files still go one by one
through `upload`). A batch body is a 4-byte big-endian length, that many
bytes of JSON `{files: [{path, size, mtime?}]}` (paths relative to `?dir=`),
then each file's bytes in that order — at most 5,000 files, 16 MB each,
256 MB in all (`413 ETOOBIG`), `400 EBADBODY` when malformed or short. Every
name passes `upload`'s guards and the whole batch is planned (folders,
`conflict`) before the first write, so a taken name under `conflict=fail` is
ONE `409 EEXISTS` naming them all; `upload-check` asks the same question
before the bytes are sent. A member's batch is written by ONE
`write-as <dir> extract` from a tar the server writes itself (folders and
regular files only, `tar -k`: never over an existing file) — `409 EUPGRADE`
(and `batch: false` from `upload-check`) until `agenthub server
install-system` is re-run, and the page then falls back to one request per
file. Measured on loopback (host folder): 1,000 files of 2 KB took 4.3 s one
by one, 0.5 s as one batch.
#### File Explorer on a home
```
GET /api/homes { homes: [{ id: "me" | <username>, label, readOnly }] }
GET /api/homes { homes: [{ id: "me" | <username>, label, readOnly, dir? }] }
GET /api/homes/:who/fs/list|trash|zip|download as the workspace routes above
POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
```
@@ -341,14 +366,43 @@ A copy goes home ↔ workspace
one Linux account; each side is checked under its own rule. A home is never a
workspace. The **Files** page opens on `?home=me` by default.
`me` alone carries `dir`, its absolute folder: the File Explorer's **New →
Clone repository** (`client/src/components/lanes/CloneHereDialog.tsx`) clones
into the folder it has open — of the user's own home or of a workspace's own
folder, never a session worktree, an extra folder or another user's home —
through `POST /api/lanes/clone` below with that folder as `parent`, so every
check of that route applies, and opens a workspace on the clone.
#### Clone a repository into a new workspace
```
POST /api/lanes/clone Body: { url, parent, name?, title?, tool?, credentials?: { username, password, remember? } }
POST /api/lanes/clone Body: { url, parent, name?, title?, tool?, credentials?: { username, password, remember? }, partial?, progressToken? }
→ 201 { lane, created: true, path }
GET /api/lanes/clone/progress/:token
→ 200 { state: queued|running|done|failed, position, running, waiting, limit,
phase: remote|receiving|resolving|checkout|other|null, label, percent, detail }
```
Add workspace → **Clone**. `url` passes the same allowlist as the git bar's
Add workspace → **Clone**. Clones are queued machine-wide
(`server/lib/clone-queue.js`): at most `AGENTHUB_CLONE_CONCURRENCY` run at
once (default half the CPU cores, at least 1), the rest wait in arrival order
with the request held open. Each runs under `nice -n 10` / `ionice -c2 -n7`
with `pack.threads` and `checkout.workers` = its share of the cores (not
persisted; parallel checkout writes a tree of many small files ~4× faster on
4 cores), and is killed
after 30 minutes of running — so several ~1 GB clones no longer starve the
dashboard and the panes. `partial: true` (the form's **Partial clone** box;
`400 EBADFIELD` when not a boolean) makes it blobless (`--filter=blob:none`):
every commit and tree, but a file's contents only when a checkout needs them —
far less to download and unpack for a big repo; checking out an old revision
later fetches from the remote. `progressToken` (16–64 of `[A-Za-z0-9_-]`, unused;
else `400 EBADFIELD`) is the browser's own name for the clone: while the POST is
held open, `GET /api/lanes/clone/progress/:token` answers its 1-based
`position` among the waiting clones, then git's `--progress` line parsed into
`phase`, `percent` and `detail` (`"450/1000, 120.50 MiB | 5.20 MiB/s"`) — the
form polls it every second (`CloneProgress.tsx`). Only the user who started
the clone sees it (404 otherwise), and it is dropped a minute after the clone
ends. `url` passes the same allowlist as the git bar's
`publish` (`https://` with no userinfo, `ssh://`, scp-like `user@host:path` —
else `400 EBADURL`); `name` (default: the URL's last segment without `.git`)
is one plain folder name that must not exist (`400 EBADNAME`, `409 EEXISTS`);
@@ -1593,7 +1647,7 @@ What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes
- **Instructions** — the `instructions` key is required (`400 EBADFIELD` without it), a string or `null`; blank clears; over 16 000 characters is `413 ETOOBIG`. The lane is re-broadcast (`lane_update`). The text is stored in `lanes.instructions` but is NOT part of the lane payload (`GET /api/lanes`, `lane_update`) — read it here.
- **Folder** — must be absolute, exist and be a directory (`400 EBADPATH`), must not be or contain the OS home (`400 EBADHOME`), and must not be the workspace's own folder or one it already has (`400 EDUP`). Stored by real path. Removing it only drops the row.
- **Clone** — clones a related repo BESIDE the workspace folder (into its parent; no path is taken from the request) and adds it as a Folder. The clone is `POST /api/lanes/clone`'s own: the same URL allowlist (`400 EBADURL`), one plain `name` (default: the repo's), `409 EEXISTS` for a taken name, run as the folder's account with that account's git identity, `409 ECREDENTIALS` (with `auth: {protocol, host}`) / `ESSHAUTH` / `ENOREPO`; a tool that takes no folders is `400 EUNSUPPORTED`. Answers the view plus `path`. Removing the folder later never deletes the clone. The workspace home's **Add folder** form takes several URLs, one per line, and clones them in turn.
- **Clone** — clones a related repo BESIDE the workspace folder (into its parent; no path is taken from the request) and adds it as a Folder. The clone is `POST /api/lanes/clone`'s own: the same URL allowlist (`400 EBADURL`), one plain `name` (default: the repo's), the same `partial` and `progressToken`, `409 EEXISTS` for a taken name, run as the folder's account with that account's git identity, `409 ECREDENTIALS` (with `auth: {protocol, host}`) / `ESSHAUTH` / `ENOREPO`; a tool that takes no folders is `400 EUNSUPPORTED`. Answers the view plus `path`. Removing the folder later never deletes the clone. The workspace home's **Add folder** form takes several URLs, one per line, and clones them in turn. Before cloning it checks each URL against `GET /api/me/git`: an SSH link (`ssh://…`, `user@host:path`) while the account has no SSH key is not sent at all and points to Settings → Git (make a key, add its public key to the host), and an HTTPS host with no saved login is flagged with an optional login row — a public repo needs none. The server's own `ECREDENTIALS`/`ESSHAUTH` answers still decide.
- **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `<data dir>/workspace-context/<lane id>/` with a name it derives from `X-File-Name` (directory parts and path characters dropped, `name (2).ext` on a clash), so a request never names a path. It is a COPY taken at upload: later edits to the original are not seen — remove and upload it again, or add its directory as a Folder (read live from disk). Removing it deletes that copy. At most 50 sources per workspace (`400 ETOOMANY`).
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content.
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
@@ -2733,7 +2787,7 @@ POST /api/cc-config/mcp/add Body: { name, scope, transport?, url?,
POST /api/cc-config/mcp/remove Body: { name, scope? }
```
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered. In server mode a non-admin's `cwd` (on `/api/cc-config/*` and `/api/codex-config/*`) must lie inside one of their own workspaces, else `403 EFOLDER`. `GET /api/cc-config/file` and `GET /api/codex-config/file` never serve a credential file (`.credentials.json`, `auth.json`, any non-Markdown name matching the AI Tools secret pattern, anything under `<CLAUDE_HOME>/agenthub-*`): `400 READ_DENIED`.
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered. `skills`, `commands` and `plugins` read their USER scope from the account the pane will run as: under S3 the `cwd` folder's account (a member's `~/.claude`, where a Hub install puts a skill), a non-admin with no `cwd` their own, an admin otherwise the host's Claude home, and a non-admin with no account of their own NOTHING (never the host's or another member's) — so a skill a developer installed shows up in their `/` list, and an admin's install into the host's home no longer shows up in a member's. In server mode a non-admin's `cwd` (on `/api/cc-config/*` and `/api/codex-config/*`) must lie inside one of their own workspaces, else `403 EFOLDER`. `GET /api/cc-config/file` and `GET /api/codex-config/file` never serve a credential file (`.credentials.json`, `auth.json`, any non-Markdown name matching the AI Tools secret pattern, anything under `<CLAUDE_HOME>/agenthub-*`): `400 READ_DENIED`.
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
@@ -2830,8 +2884,9 @@ GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narr
GET /api/run/interrupted {items} — sessions whose pane died under them (see below), newest per session, max 50
POST /api/run/interrupted/dismiss {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise
GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path (a `claude` pane: an image as its bare path, each in a paste of its own, which Claude Code shows as `[Image #n]`; any other file as an `@` mention)
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost
POST /api/run/local-paths {paths:[<absolute>…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD otherwise
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
POST /api/run/:id/permission-mode {mode} → Shift+Tab steps, each read back, until the pane shows it; 409 EMODEUNKNOWN/EMODEUNAVAILABLE/EBUSY
@@ -2878,7 +2933,7 @@ GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the se
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, proxyCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `<data dir>/ai-tool-config-backups/<id>/` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`).
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is not under `npm prefix -g`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`, plus `--prefix ~/.local` when npm's global prefix is not writable by the server's account — a system-wide Node; a tool under `~/.local/lib/node_modules` is updated the same way); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
### AI Proxy profiles
@@ -2977,7 +3032,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session
### Artifacts hub
```http
GET /api/artifacts?scope=mine|hub&kind=page|file|skill|plugin&q= {items}
GET /api/artifacts?scope=all|mine|hub&kind=page|file|skill|plugin&q= {items} — each with `installed` (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/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
@@ -2991,10 +3046,12 @@ 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?} (source/market: plugin only)
DELETE /api/artifacts/:id
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)
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?} → {installed, replaced?}
POST /api/artifacts/:id/uninstall → {uninstalled, paths}
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded}
```
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
@@ -3005,9 +3062,10 @@ What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/ar
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**.
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**. The page shows one list (`scope=all`: own rows plus shared ones) with an **All · My library · Hub** filter. Deleting a SHARED row of your own only takes it out of your library (`in_library = 0`, `inLibrary: false`, `{kept: true}`; it leaves `scope=mine` and shows as **Hub only**); unsharing such a row then deletes it (`PATCH` answers `{item, removed: true}`). Deleting an unshared row, or an admin deleting someone else's, deletes it outright.
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` (as the member through `write-as` under S3); a taken name is `409 EEXISTS` unless `replace: true`, which moves the old one to `~/.claude/agenthub-replaced-skills/<name>-<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. Share, unshare, a shared row's delete and every install by someone else are written to `audit_log`.
- **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. 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 shows **Uninstall** (card icon, ⋯ menu, detail sheet) beside **Installed**.
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared}`) reaches everyone for a shared row, else its owner and staff.
### Query Explorer
+1 -1
View File
@@ -108,7 +108,7 @@ The CLI talks to the local dashboard server — **API-backed commands require it
| `agenthub server install-system [--dry-run\|--uninstall]` | **One Linux user per account** (S3). Run as the account that runs the dashboard, never as root: it runs `scripts/server/install-system.sh` through one `sudo`. That script creates the groups, `/srv/agenthub/users`, `/opt/agenthub`, the sudoers file, the member hooks and `hidepid`, and installs the system unit `agenthub.service`. The command then points the unit at this install, writes `AGENTHUB_USERS_ROOT`, and moves the dashboard from the `systemd --user` unit to the system one (panes stay). `--dry-run` prints the sudo command; `--uninstall` goes back to the user unit. See [SERVER-MODE.md](SERVER-MODE.md#one-linux-user-per-account) |
| `agenthub server tools` | One Linux user per account: publish this install's scripts and every installed AI tool under `/opt/agenthub` (`tools/bin/<command>`), where every account's pane runs them. The server also does this at start and after each install or update. Prints each tool's shared copy, or why it could not be shared |
| `agenthub server off` / `agenthub server status` | Back to local mode (accounts kept) / show the mode and account count |
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ and tokens in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
| `agenthub accounts list\|add <name>\|remove <name>` | The **Claude account pool**: new Claude sessions spread over these accounts (each a `claude setup-token` token, typed hidden or read from stdin). `list` shows live panes, $ and tokens in the last 5 h / 7 d, and quota. With `--tool codex` the same for the **Codex account pool**: `add <name> --tool codex --api-key` (key typed hidden or on stdin) or `--from <dir>` (a `codex login` home or its `auth.json`). `pin <account> <user>` / `unpin <user>` (add `--tool codex` for Codex) switches one user's sessions to that account (a Claude login goes into their home at once, running sessions follow without a restart) (server mode; `list` shows a `pinned` column). See [SERVER-MODE.md](SERVER-MODE.md#claude-account-pool) |
| `agenthub users list\|add <name> [--role admin\|analyst\|member]\|passwd <name>\|role <name> <role>\|disable <name>\|enable <name>\|provision <name>\|--all\|assign <workspace id> <name>\|audit [--limit N]` | Server mode's accounts, written straight to the database. Once `install-system` ran, `add` first creates the Linux user `ah-<name>`; a name must then be lower case: a letter, then up to 28 letters, digits, `_` or `-`. `disable`/`enable` lock or unlock that Linux user too, and `provision` gives users made earlier their Linux user (idempotent); `assign` gives a workspace made before server mode an owner; `audit` lists admin/analyst reads of members' sessions. Inside a dashboard pane in server mode, every command acts as the workspace's owner through the pane's `AGENTHUB_PANE_TOKEN`. A password is typed hidden twice, or read from the first line of stdin |
### Interactive REPL
+2 -1
View File
@@ -213,7 +213,7 @@ CREATE TABLE sessions (
| `awaiting_reason` | TEXT | YES | Why the row is waiting: `notification`, `stop`, `session_start`, or `interrupted`. Set/cleared in lock-step with `awaiting_input_since` (SessionStart→`session_start`, Stop→`stop`, permission/input Notification→`notification`, watchdog/Esc recovery→`interrupted`). NULL otherwise. Exception: a `compact`-source SessionStart preserves the existing value (neither stamps `session_start` nor clears it) |
| `transcript_path` | TEXT | YES | Absolute path to the session's JSONL transcript. Written by `routes/hooks.js` on the first event that carries it (subsequent events no-op via a SQL guard) and read by the periodic compaction sweep — so the sweep touches only active session rows instead of scanning the entire `events` table for `json_extract(data,'$.transcript_path')`. Backfilled once from `events` by the `db.js` migration |
| `source` | TEXT | NO | Data source this session was captured from. `'local'` for this machine's own Claude Code history (the default); otherwise the `remote_sources.id` of the remote SSH machine it was pulled from, or `push:<name>` for a session another machine pushed to `POST /api/hooks/ingest-batch` (a pushed hook may only extend a session its own source created). Powers the `sources` query filter on `/api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics`, and the `sources` facet on `/api/sessions/facets`. Indexed by `idx_sessions_source` |
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. `'cursor'` for a Cursor agent transcript, ingested by `server/lib/cursor-usage.js` (see `cursor_transcripts`): no `transcript_path`, no `model` and no `token_usage` (the transcripts record none), `active` while its transcript was written in the last two minutes, else `completed`. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
| `tool` | TEXT | NO | The AI coding CLI that produced the session. `'claude'` for every hook-created and imported row (the default); `'codex'` for a session ingested from Codex's rollout journals by `server/lib/codex-usage.js` (see `codex_rollouts`) or created by a Codex hook (`tool: "codex"`, `server/lib/codex-hooks.js`) — a Codex session keeps the status its hooks give it while they are current (its newest event at or after its newest journal write minus two minutes); once they stop (removed, untrusted) the journal decides again. A codex row has no `transcript_path`, and, going by the journal, its `status` is `active` only while one of its journals was written in the last two minutes. `'antigravity'` for an agy CLI conversation, ingested by `server/lib/antigravity-usage.js` (see `antigravity_conversations`) or created by an Antigravity hook (`server/lib/antigravity-hooks.js`); no `transcript_path`, and `active` until it has been quiet — no write to its conversation file (or a non-empty `-wal`) or transcript, no hook event — for ten minutes when it has hook events, two minutes when it has none; it then settles to `completed`, its agents too. `'cursor'` for a Cursor agent transcript, ingested by `server/lib/cursor-usage.js` (see `cursor_transcripts`): no `transcript_path`, no `model` and no `token_usage` (the transcripts record none); its conversation is read from the file `cursor_transcripts` recorded for it, `active` while its transcript was written in the last two minutes, else `completed`. The startup cleanup of stale `active` sessions and a workspace purge touch only the matching tool's rows (a purge takes the sessions of the workspace's own `tool`). The Workspace's attach/resume list offers `'claude'` rows only |
| `lane_id` | INTEGER | YES | The workspace that OWNS the session — the only sessions a workspace lists: the lane whose dashboard pane held it, stamped by `bindRunSession` (`server/routes/hooks.js`) from the hook's `run_id` — first writer wins, a nested `claude -p` excluded — or, for a Codex/Antigravity session, by `claimToolSessions` (`server/lib/dashboard-runs.js`) from the same-tool run that was live in its folder when it started. NULL for a session no workspace started (a terminal, an import, a tool with no hooks). No FK: removing a workspace leaves its sessions. Backfilled once from `dashboard_runs`. Indexed by `idx_sessions_lane` |
| `archived_at` | TEXT | YES | When the user archived the session out of its workspace's lists (`PATCH /api/sessions/:id {archived}`). Hides only — nothing is deleted |
| `pinned_at` | TEXT | YES | When the user pinned the session to the top of its workspace's sidebar list (`PATCH /api/sessions/:id {pinned}`; `GET /api/sessions?pinned_first=1` orders by it). Order only; NULL = not pinned |
@@ -621,6 +621,7 @@ Server mode's accounts ([SERVER-MODE.md](SERVER-MODE.md), `server/lib/auth.js`).
| --- | --- |
| `users` | `id`, `username` (unique, case-insensitive), `role` (`admin`\|`analyst`\|`member`), `password_hash` (`scrypt$<salt>$<hash>`), `disabled`, `created_at` |
| `auth_sessions` | `token_hash` (SHA-256 of the cookie token, PK), `user_id` → `users.id` (cascade), `created_at`, `expires_at` (30 days) |
| `account_pins` | `user_id` → `users.id` (cascade), `tool` (`claude`\|`codex`), `account` (a pool account's name), `pinned_at`; PK (`user_id`, `tool`) — an admin's pin of a user to one pool account (`server/lib/account-pins.js`) |
`dashboard_runs.account` names the Claude pool account a run's pane was started on (`server/lib/claude-pool.js`; `NULL` = the machine's own login). The balancer sums each account's spend from the `token_usage_hourly` rows of those runs' sessions. The pool's tokens live in files under `<CLAUDE_HOME>/agenthub-accounts/`, never in the database.
+2
View File
@@ -259,6 +259,8 @@ CODEX_HOME= # where Codex keeps sessions/ (default ~
DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity sessions/tokens (60 s while idle)
AGENTHUB_GEMINI_DIR= # where agy keeps its data (default ~/.gemini)
DASHBOARD_CURSOR_POLL_MS=15000 # Cursor agent transcripts → Cursor sessions (no tokens; 60 s while idle)
DASHBOARD_OMP_POLL_MS=15000 # omp session journals → Oh My Pi sessions/tokens/subagents (60 s while idle)
DASHBOARD_OPENCODE_POLL_MS=15000 # opencode.db → OpenCode sessions/tokens/subagents (60 s while idle)
AGENTHUB_CURSOR_DIR= # where Cursor keeps its data (default ~/.cursor)
AGENTHUB_AGY_USAGE= # 0 never runs `agy --print /usage` for the Usage panel's Antigravity quota (default on; running agy may update it)
+7 -4
View File
File diff suppressed because one or more lines are too long
+16
View File
@@ -302,6 +302,22 @@ default 15000) and appear in Sessions with a **Cursor CLI** badge. Cursor writes
no token counts or model ids there, so a Cursor session shows no tokens and no
cost.
**Oh My Pi (`omp`)** sessions are read from omp's session journals
(`~/.omp/agent/sessions/*/<timestamp>_<id>.jsonl`, every `DASHBOARD_OMP_POLL_MS`
ms, default 15000) — each member's own `~/.omp` too on a multi-user server — and
appear in Sessions with an **Oh My Pi (OMP)** badge, with their tokens and cost
per model and one agent per subagent the `task` tool spawned (its journal sits in
the folder named like the session's). An omp session can be resumed by id from
its workspace (`omp --resume <id>`), like a Claude or Codex one.
**OpenCode** sessions are read from its SQLite store
(`~/.local/share/opencode/opencode.db`, every `DASHBOARD_OPENCODE_POLL_MS` ms),
with their tokens and cost per model and one agent per subagent, and are
resumable by id (`opencode --session <id>`). **Grok Build** runs the Claude hooks in
`~/.claude/settings.json` itself, so its sessions arrive through AgentHub's own
hooks, recorded as Grok sessions (live status, no tokens); they resume with
`grok --resume <id>`. Cursor sessions resume by id too (`--resume <id>`).
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
[`docs/LANES.md`](LANES.md#custom-pipeline-templates)); until then
+30 -2
View File
@@ -63,7 +63,10 @@ The proxy has to:
- pass the original `Host` (the dashboard checks it against
`DASHBOARD_ALLOWED_HOSTS` and compares it with the browser's `Origin`);
- forward WebSocket upgrades (`/ws` for live updates, `/ws-pty/*` for terminals);
- set `X-Forwarded-For` and `X-Forwarded-Proto`.
- set `X-Forwarded-For` and `X-Forwarded-Proto`;
- accept request bodies as large as AgentHub's own limits (Files uploads up to
500 MB, prompt drops 20 MB). nginx's default `client_max_body_size` is 1 MB,
and its own 413 is an HTML page the UI can only report as a failed upload.
nginx:
@@ -83,6 +86,7 @@ server {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1d;
client_max_body_size 500m;
}
}
```
@@ -355,6 +359,7 @@ claude setup-token
agenthub accounts add team-1
agenthub accounts list # live panes, $ and tokens in the last 5 h / 7 d, quota, state
agenthub accounts remove team-1
agenthub accounts pin team-1 mia # switch mia to team-1 (unpin mia = automatic again)
```
In server mode an admin can do the same on the **Users** page, in the
@@ -423,6 +428,28 @@ How a new session picks its account (`server/lib/claude-pool.js`):
pane still needs that member's own `/login`.
- A token more than a year old is skipped.
- A resumed session picks an account again, like a new one.
- **Switching a user to one account.** An admin can take one user off the
ranking: the pencil (**Edit**) on that account's row in the Account pool
card — tick the users who run on it, untick to send one back to automatic (or
`agenthub accounts pin <account> <user> [--tool codex]`, `unpin <user>`; their
own password again, each change audited). A user ticked on one account
moves off any other. Every session in a
workspace that user OWNS then runs on that account, even when it is full;
other users are not affected, and the account is not reserved for them.
- **A Claude login switches at once, without a restart.** The server first
copies every home's newer pair back into the pool (so the login leaving
the home keeps its only live refresh token), then writes the new login
into the user's `~/.claude/.credentials.json` as them. New sessions start
on it; sessions already running keep their process and move to it the
next time Claude Code re-reads its credentials (it does so before it
refreshes its access token, so at the latest when that token expires).
A login the user made themselves (`/login`) is never written over — the
card says so. Their sessions are booked to the account they started on.
- A setup-token, a Codex account, or a host pane gets the account at its
next spawn; running ones keep theirs.
- An expired account falls back to the ranking. Removing the account drops
the switch; deleting the user drops theirs. Server mode only
(`server/lib/account-pins.js`).
- Quota is read with each account's own token, in the background, at most every
5 minutes. If the usage endpoint refuses a setup-token, the state column shows
`no quota (…)` and ranking uses spend alone.
@@ -447,6 +474,7 @@ CODEX_HOME=$(mktemp -d) codex login
agenthub accounts add team-chatgpt --tool codex --from /tmp/tmp.XXXX
agenthub accounts list --tool codex
agenthub accounts remove team-key --tool codex
agenthub accounts pin team-key mia --tool codex # pins work as for Claude
```
- **API key**: any number of panes at once. The pane gets the key through
@@ -570,7 +598,7 @@ The pieces, and what each is for (the install steps are in the
| `agenthub.service` | A SYSTEM unit, `User=<host>`, with `SupplementaryGroups=agenthub agenthub-proc` and `AmbientCapabilities=CAP_DAC_READ_SEARCH`: the dashboard can read every member's tool files (Claude writes transcripts `0600`), but cannot write them. Every program it starts has the capability removed first (`setpriv`), so host panes, git and npm do not inherit it. It reads node and the plugin root from `<data dir>/runtime/agenthub.system.env`, a file the host owns, so a plugin update repoints it without root |
| `/etc/sudoers.d/agenthub` | The host may run only `tmux`, `git`, `gh` (by the absolute paths recorded in `/etc/agenthub/server.conf`), `/opt/agenthub/libexec/tmux-member`, `/opt/agenthub/libexec/write-as` and `/opt/agenthub/tools/bin/claude` (the [plugin baseline](#plugin-baseline-opt-in)) AS a member, plus, as root, only `/opt/agenthub/sbin/agenthub-provision` and `systemctl start/stop/restart/enable/disable agenthub.service`. Members are never in sudoers |
| `/etc/agenthub/server.conf` | Root's, `0644`: the host account and uid, the users root, the port, and the `tmux`/`git`/`gh` paths sudoers lists. Its presence turns S3 on |
| `write-as`, `pane-launch`, `tmux-member` | `/opt/agenthub/libexec/`, root's. `tmux-member -S <socket> …` runs a member's tmux call with their running server's own binary (else `TMUX_BIN`). `write-as <path> [append\|replace\|private\|mkdir\|remove]` runs AS a member and does the one change, content on stdin. `pane-launch <file>` starts a member's pane from its launch file |
| `write-as`, `pane-launch`, `tmux-member` | `/opt/agenthub/libexec/`, root's. `tmux-member -S <socket> …` runs a member's tmux call with their running server's own binary (else `TMUX_BIN`). `write-as <path> [append\|replace\|private\|mkdir\|remove\|extract]` runs AS a member and does the one change, content on stdin (`extract`: a tar the dashboard wrote, unpacked with `tar -k` — the Files page's batch upload). `pane-launch <file>` starts a member's pane from its launch file |
| `agenthub-provision` | The one root command besides `systemctl` on this unit: `create`, `disable` or `enable` `ah-<name>`. It refuses an account that exists outside `agenthub-members` and never deletes anything |
| `/etc/claude-code/managed-settings.json` | Members' Claude Code hooks, through `/opt/agenthub/libexec/member-hook`, which does nothing for the host account (its plugin reports). A foreign file there is left alone |
| `hidepid=invisible` | `/proc` (now and in `/etc/fstab`): an account sees only its own processes, so no member can read another's command line. A token passed with `tmux -e` is in one |
+2 -2
View File
@@ -2,7 +2,7 @@
Asked 2026-10-08 (modelled on Orca's 3-step welcome). Decisions:
- **Bypass is never remembered** (CLAUDE.md rule). The wizard remembers a default *permission mode* (Auto / Accept edits / Ask / Plan); bypass stays the red per-session switch.
- **The wizard never offers bypass.** It remembers a default *permission mode* (Auto / Accept edits / Ask / Plan). Since 2026-10-19 Settings → AI tools → **Session defaults** may make Bypass the default (Claude's `bypassPermissions`, Codex's bypass switch), behind a confirm; the pet chat still never starts in it.
- **Per account, on the server.** The choices follow the account to any browser. Local mode is one account, `local`.
- **The tutorial is a step-by-step spotlight tour.**
@@ -24,7 +24,7 @@ Asked 2026-10-08 (modelled on Orca's 3-step welcome). Decisions:
- **On save:** wrapping `localStorage.setItem` for `PREF_KEYS` pushes each change (debounced).
- [x] New preference keys:
- `agenthub.defaultTool`, read by AddLaneModal before the install default;
- `agenthub.permissionMode`, read by WorkspaceHome. `bypassPermissions` is never accepted here.
- `agenthub.permissionMode`, read by WorkspaceHome. `bypassPermissions` is accepted only as set from Settings → Session defaults.
- [x] `components/onboarding/OnboardingWizard.tsx`, mounted in Layout. It shows when the account is not onboarded AND has no workspace (an account that already has one is marked onboarded silently). Steps:
1. Default agent (detected tools as a grid, plus "show more") and the default permission mode.
2. Appearance: System, Dark and Light as preview cards, a theme grid for dark and for light below them, then language and terminal font.
+1003 -2
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.10",
"version": "2.8.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.10",
"version": "2.8.14",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.10",
"version": "2.8.14",
"bin": {
"agenthub": "bin/agenthub.js"
},
+5 -1
View File
@@ -21,10 +21,13 @@
# copy the path, recursively, to the 3rd argument (symlinks copied as
# links, never followed; mode and times kept; never onto an
# existing name)
# extract a tar on stdin unpacked into the directory (the dashboard's
# batch upload, a tar it wrote itself: folders and regular files
# only); an existing file is never replaced (`-k` fails instead)
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
set -eu
usage() {
echo "usage: write-as <absolute path> [append|replace|private|mkdir|remove] | <path> move|copy <absolute dest>" >&2
echo "usage: write-as <absolute path> [append|replace|private|mkdir|remove|extract] | <path> move|copy <absolute dest>" >&2
exit 2
}
case "${2:-}" in
@@ -48,6 +51,7 @@ case "${2:-}" in
private) swap "$1" 0600 ;;
mkdir) exec mkdir -p -m 0755 -- "$1" ;;
remove) exec rm -rf -- "$1" ;;
extract) exec tar -x -k -p --no-same-owner -f - -C "$1" ;;
move)
[ -e "$3" ] || [ -L "$3" ] && { echo "write-as: $3 exists" >&2; exit 1; }
exec mv -T -- "$1" "$3" ;;
+37 -3
View File
@@ -632,12 +632,46 @@ An `antigravity` workspace carries no pipeline either.
per-conversation SQLite files (`~/.gemini/antigravity-cli/conversations/<id>.db`)
every `DASHBOARD_ANTIGRAVITY_POLL_MS` ms (default 15000, `0` disables). They
become `sessions` rows (`tool = 'antigravity'`) and `token_usage` rows, and
`antigravity_conversations` holds the mtime read for each file.
`antigravity_conversations` holds the mtime read for each file. The
session's conversation (`GET /api/sessions/:id/transcript`) is read from its
step log (`brain/<id>/.system_generated/logs/transcript_full.jsonl`,
`transcriptPath`): prompts, the model's text, thinking, tool calls and
tokens, and each tool's result tied to its call by step.
- **Cursor sessions:** `server/lib/cursor-usage.js` reads Cursor's agent
transcripts (`~/.cursor/projects/*/agent-transcripts/<id>/<id>.jsonl`) every
`DASHBOARD_CURSOR_POLL_MS` ms (default 15000, `0` disables) into `sessions`
rows (`tool = 'cursor'`) — no `token_usage`, the transcripts carry none —
and `cursor_transcripts` holds the mtime read for each file.
and `cursor_transcripts` holds the mtime read for each file. The session's
conversation (`GET /api/sessions/:id/transcript`) is read from that recorded
file: prompts, replies, thinking, tool calls and results, with no timestamps
or model (Cursor writes none).
- **Oh My Pi sessions:** `server/lib/omp-usage.js` reads omp's session journals
(`~/.omp/agent/sessions/<encoded cwd>/<timestamp>_<id>.jsonl`, plus every
subagent journal under `<timestamp>_<id>/`) every `DASHBOARD_OMP_POLL_MS` ms
(default 15000, `0` disables), each from the offset the last pass stopped at,
into `sessions` (`tool = 'omp'`), `token_usage` (the session's own and its
subagents' turns, per model) and `agents` (one `omp-<session>-<key>` subagent
row per subagent journal). `omp_sessions` holds each journal's offset and
what it said so far; `GET /api/sessions/:id/transcript(s)` reads an omp
session's journals from there. Under S3 each member's `~/.omp` is read only
through `openSafeAsync`.
- **OpenCode sessions:** `server/lib/opencode-usage.js` reads OpenCode's
SQLite store (`~/.local/share/opencode/opencode.db`, under `XDG_DATA_HOME`
when set) every `DASHBOARD_OPENCODE_POLL_MS` ms (default 15000, `0`
disables), when its file or WAL changed: each root `session` row becomes a
`sessions` row (`tool = 'opencode'`), its tree's assistant `message` tokens
per `modelID` become `token_usage` (reasoning counted as output), and each
child session (the `task` tool's) an `opencode-<id>` subagent row.
`opencode_stores` holds each store's mtime and `opencode_sessions` each root
session's newest `time_updated`; the transcript routes read the conversation
(messages + parts) from the store itself. A member's store and WAL are copied
through the guard before they are opened.
- **Grok sessions:** Grok runs the hooks in `~/.claude/settings.json` (its
Claude compatibility), so AgentHub's own hook reaches `POST /api/hooks/event`
with no `tool`; `routes/hooks.js` tells it apart by the camelCase keys only
Grok sends (`hookEventName` + `workspaceRoot`), records it as `tool = 'grok'`
and drops its `transcript_path` (Grok's file, never parsed as a Claude
transcript). No tokens: Grok's session files are not read.
- **Quiet sessions:** agy fires no exit hook, so a session quiet for ten
minutes (with hook events) or two (without) is settled to `completed`, its
agents too, and sets its workspace idle. That workspace must be one the hooks left
@@ -776,7 +810,7 @@ HTTP surface for the tmux-backed terminal run: one session per workspace, named
Codex-only spawn options (`sandbox`, `approval`, `resumeLast`) are accepted by the workspace action `POST /api/lanes/:id/start`, not by `POST /api/run` — see [`docs/API.md`](../docs/API.md).
The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`) and its Codex counterpart `codex_config_changed` (broadcast by `routes/codex-config.js` after every successful file mutation and CLI-backed mcp/plugin/marketplace action — always `source: "dashboard"`, there is no `$CODEX_HOME` filesystem watcher).
The pane itself is not JSON: its bytes stream over `/ws-pty/:runId` as binary WebSocket frames. The JSON WebSocket surface adds `cc_config_changed` (broadcast by `lib/cc-watcher.js` on `fs.watch` events under `~/.claude/` and by `routes/cc-config.js` after every successful PUT/DELETE — debounced at 500 ms, payload `{ source: "dashboard"|"fs", action?, scope?, type?, name?, paths? }`) and its Codex counterpart `codex_config_changed` (broadcast by `routes/codex-config.js` after every successful file mutation and CLI-backed mcp/plugin/marketplace action — always `source: "dashboard"`, there is no `$CODEX_HOME` filesystem watcher), and `transcript_changed` `{ session_id }` — the session sync's `fs.watch` on the projects tree saw a Claude transcript grow (`pushTranscript` in `server/index.js`, at most every 250 ms per session; nothing is parsed for it), so a chat showing that session reads its new lines at once instead of at its next 3 s poll. It reaches whoever the session's own `new_event` reaches (`broadcastAudience`).
### Import History
+52
View File
@@ -181,6 +181,58 @@ describe("stepsFor", () => {
});
});
describe("a system-wide npm prefix", () => {
const ro = path.join(root, "ro-prefix");
const user = path.join(root, "user-local");
const asRoot = process.getuid?.() === 0;
before(() => {
for (const d of [path.join(ro, "lib", "node_modules"), path.join(ro, "bin")]) {
fs.mkdirSync(d, { recursive: true });
fs.chmodSync(d, 0o555);
}
versions.__setUserPrefix(user);
});
after(() => {
versions.__setUserPrefix(undefined);
for (const d of [path.join(ro, "lib", "node_modules"), path.join(ro, "bin")]) {
fs.chmodSync(d, 0o755);
}
});
it("installs into the account's ~/.local when npm's global prefix is not writable", async (t) => {
if (asRoot) return t.skip("root writes anywhere");
versions.__setExec(async (_f, args) => {
if (args[0] === "prefix") return { stdout: `${ro}\n` };
throw new Error("unexpected");
});
assert.deepEqual(await jobs.stepsFor("fresh", "install"), [
{
argv: [path.join(shellBin, "npm"), "install", "-g", "--prefix", user, "fresh-pkg@latest"],
},
]);
});
it("updates a tool under ~/.local/lib/node_modules with the same --prefix", async () => {
const pkg = path.join(user, "lib", "node_modules", "fresh-pkg", "cli.js");
exe(pkg);
fs.mkdirSync(path.join(user, "bin"), { recursive: true });
fs.symlinkSync(pkg, path.join(serverBin, "fresh"));
aiTools.refresh();
try {
assert.equal(await versions.managedBy("fresh"), "user");
assert.deepEqual(await jobs.stepsFor("fresh", "update"), [
{
argv: [path.join(shellBin, "npm"), "install", "-g", "--prefix", user, "fresh-pkg@latest"],
},
]);
} finally {
fs.rmSync(path.join(serverBin, "fresh"));
aiTools.refresh();
}
});
});
describe("versions", () => {
it("marks npm-managed by realpath and badges a newer version", async () => {
const v = await versions.versions({ fresh: true });
+6 -6
View File
@@ -190,23 +190,23 @@ describe("install / update / icons", () => {
it("starts an install job and reports it", async () => {
jobs.__reset();
assert.equal((await request("GET", "/api/ai-tools/job")).body, null);
const r = await request("POST", "/api/ai-tools/copilot/install");
const r = await request("POST", "/api/ai-tools/codex/install");
assert.equal(r.status, 202);
assert.equal(r.body.job.tool, "copilot");
assert.equal(r.body.job.tool, "codex");
const done = await settle();
assert.equal(done.status, "ok");
assert.deepEqual(spawned.at(-1), [
path.join(BIN, "npm"),
"install",
"-g",
"@github/copilot@latest",
"@openai/codex@latest",
]);
});
it("maps refusals to statuses", async () => {
jobs.__reset();
assert.equal((await request("POST", "/api/ai-tools/nope/install")).status, 404);
const noInstall = await request("POST", "/api/ai-tools/antigravity/install");
const noInstall = await request("POST", "/api/ai-tools/claude/install");
assert.equal(noInstall.status, 400);
assert.equal(noInstall.body.error.code, "ENOINSTALL");
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
@@ -216,7 +216,7 @@ describe("install / update / icons", () => {
const notNpm = await request("POST", "/api/ai-tools/opencode/update");
assert.equal(notNpm.status, 409);
assert.equal(notNpm.body.error.code, "ENOTNPM");
const cross = await request("POST", "/api/ai-tools/copilot/install", undefined, {
const cross = await request("POST", "/api/ai-tools/codex/install", undefined, {
Origin: "https://evil.test",
});
assert.equal(cross.status, 403);
@@ -225,7 +225,7 @@ describe("install / update / icons", () => {
it("answers EBUSY while a job runs", async () => {
jobs.__reset({ timeoutMs: 50 });
hang = true;
assert.equal((await request("POST", "/api/ai-tools/copilot/install")).status, 202);
assert.equal((await request("POST", "/api/ai-tools/codex/install")).status, 202);
const busy = await request("POST", "/api/ai-tools/amp/install");
assert.equal(busy.status, 409);
assert.equal(busy.body.error.code, "EBUSY");
+1
View File
@@ -194,6 +194,7 @@ describe("enable / default", () => {
launcher: null,
prompt: { mode: "flag" },
resume: false,
resumeById: false,
bypass: false,
proxyCapable: false,
modelFlag: false,
@@ -0,0 +1,146 @@
/**
* @file antigravity-transcript.test.js
* @description An Antigravity (agy) session's conversation view: its
* `transcript_full.jsonl` step log mapped to the Claude transcript shape —
* the prompt out of `<USER_REQUEST>`, the model's thinking, text, tool calls
* and tokens, each tool result tied to its call by step and an invalid call
* marked an error — one "main" transcript, no subagent files, and a session
* id that is no UUID finding nothing. The agy dir and the DB are temp dirs;
* the steps are synthetic, in the shape agy 1.4 writes.
* @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 ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-agy-transcript-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.AGENTHUB_GEMINI_DIR = path.join(ROOT, "gemini");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
const { describe, it, after } = require("node:test");
const assert = require("node:assert/strict");
const { db } = require("../db");
const agy = require("../lib/antigravity-usage");
let server;
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
const ID = "57154e1a-65e5-4b51-a116-1890f5ca831f";
const AT = "2026-10-21T05:08:24Z";
function steps(id) {
const dir = path.join(
process.env.AGENTHUB_GEMINI_DIR,
"antigravity-cli",
"brain",
id,
".system_generated",
"logs"
);
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "transcript_full.jsonl");
const lines = [
{
step_index: 0,
source: "USER_EXPLICIT",
type: "USER_INPUT",
status: "DONE",
created_at: AT,
content:
"<USER_REQUEST>\nCreate c.txt\n</USER_REQUEST>\n<ADDITIONAL_METADATA>\nThe current local time is: x.\n</ADDITIONAL_METADATA>",
},
{
step_index: 1,
source: "MODEL",
type: "PLANNER_RESPONSE",
status: "DONE",
created_at: AT,
input_tokens: 3889,
cache_read_tokens: 768,
output_tokens: 12,
thinking: "Two commands.",
content: "Running them now.",
tool_calls: [
{ name: "run_command", args: { CommandLine: "echo ok > c.txt" } },
{ name: "read", args: { path: "c.txt" } },
],
},
{
step_index: 2,
source: "MODEL",
type: "GENERIC",
status: "DONE",
created_at: AT,
content: "Created At: x\nCompleted At: y\n\nThe command exited with code 0.\n",
},
{
step_index: 3,
source: "MODEL",
type: "GENERIC",
status: "INVALID",
error: 'unknown tool: "read"',
created_at: AT,
content: 'Created At: x\nCompleted At: y\nError invalid tool call: unknown tool: "read"',
},
{
step_index: 4,
source: "MODEL",
type: "PLANNER_RESPONSE",
status: "DONE",
created_at: AT,
content: "Done.",
},
];
fs.writeFileSync(file, lines.map((l) => JSON.stringify(l)).join("\n") + "\n");
return file;
}
describe("an Antigravity session's conversation", () => {
it("reads agy's step log in the Claude transcript shape", async () => {
const file = steps(ID);
assert.equal(agy.transcriptPath(ID), file);
assert.equal(agy.transcriptPath("../../etc"), null);
db.prepare(
"INSERT INTO sessions (id, name, status, started_at, tool) VALUES (?, 'c', 'completed', ?, 'antigravity')"
).run(ID, AT);
const { createApp, startServer } = require("../index");
server ||= await startServer(createApp(), 0);
const BASE = `http://127.0.0.1:${server.address().port}`;
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
assert.deepEqual(
list.transcripts.map((t) => [t.id, t.type]),
[["main", "main"]]
);
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
assert.deepEqual(
body.messages.map((m) => [m.sender, m.content.map((c) => c.type)]),
[
["user", ["text"]],
["assistant", ["thinking", "text", "tool_use", "tool_use"]],
["tool", ["tool_result"]],
["tool", ["tool_result"]],
["assistant", ["text"]],
]
);
const [user, plan, ok, bad] = body.messages;
assert.equal(user.content[0].text, "Create c.txt");
assert.equal(user.timestamp, AT);
// Each result names the call it answers: call i of step s lands at step s+1+i.
assert.deepEqual(
plan.content.filter((c) => c.type === "tool_use").map((c) => c.id),
[ok.content[0].id, bad.content[0].id]
);
assert.equal(ok.content[0].output, "The command exited with code 0.\n");
assert.equal(ok.content[0].is_error, false);
assert.equal(bad.content[0].is_error, true);
assert.equal(plan.usage.input_tokens, 3889);
assert.equal(plan.usage.cache_read_input_tokens, 768);
const sub = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=x`)).json();
assert.equal(sub.messages.length, 0);
});
});
+3
View File
@@ -100,6 +100,9 @@ const EXPECTED_API_PATHS = [
"/api/webhooks/{id}",
"/api/webhooks/{id}/test",
"/api/webhooks/{id}/deliveries",
"/api/artifacts",
"/api/artifacts/{id}/install",
"/api/host",
"/api/openapi.json",
"/api/docs",
"/api/redoc",
+103 -4
View File
@@ -2,8 +2,8 @@
* @file Tests for the Artifacts hub (server/lib/artifacts.js, routes/artifacts.js):
* claude.ai pages (link allowlist, hook capture, one row per URL), workspace
* file candidates and pins (a snapshot, served sandboxed), skills (snapshot,
* symlink/secret refusal, install into the caller's Claude home with
* replace moving the old one aside), plugins (marketplace allowlist, install
* symlink/secret refusal, install into the caller's ~/.claude and ~/.agents skills with
* replace and uninstall moving the old one aside), plugins (marketplace allowlist, install
* argv; a marketplace link read into its plugin list, the same plugin added
* twice kept as one row, key/market edited), skills uploaded (one Markdown
* file, a folder, a zip, several skills at once — name and frontmatter set,
@@ -161,7 +161,7 @@ describe("files", () => {
});
describe("skills", () => {
it("snapshots a workspace skill and installs it into the caller's Claude home", async () => {
it("snapshots a workspace skill and installs it into ~/.claude/skills and ~/.agents/skills", async () => {
const list = await request("GET", `/api/artifacts/skills?laneId=${lane.id}`);
assert.deepEqual(list.body.items, [{ name: "deploy", from: "workspace" }]);
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "deploy" });
@@ -169,11 +169,25 @@ describe("skills", () => {
assert.equal(r.body.item.description, "Ship it");
assert.deepEqual(r.body.item.files.map((f) => f.path).sort(), ["SKILL.md", "scripts/run.sh"]);
const id = r.body.item.id;
const listed = async () =>
(await request("GET", "/api/artifacts?kind=skill")).body.items.find((i) => i.id === id);
assert.equal((await listed()).installed, false);
const first = await request("POST", `/api/artifacts/${id}/install`, {});
assert.equal(first.status, 200, first.text);
assert.equal((await listed()).installed, true);
const who = await request("GET", `/api/artifacts/${id}/installs`);
assert.equal(who.status, 200, who.text);
assert.equal(who.body.items.length, 1);
assert.equal(who.body.items[0].mine, true);
assert.deepEqual(who.body.items[0].paths, first.body.paths);
assert.equal(who.body.unrecorded, 0);
const target = path.join(process.env.CLAUDE_HOME, "skills", "deploy");
assert.equal(fs.readFileSync(path.join(target, "scripts", "run.sh"), "utf8"), "echo hi\n");
// The cross-tool folder beside the Claude home (Codex, Gemini, OpenCode…).
const shared = path.join(ROOT, ".agents", "skills", "deploy");
assert.equal(fs.readFileSync(path.join(shared, "scripts", "run.sh"), "utf8"), "echo hi\n");
assert.deepEqual(first.body.paths, [target, shared]);
const again = await request("POST", `/api/artifacts/${id}/install`, {});
assert.equal(again.status, 409);
@@ -183,8 +197,47 @@ describe("skills", () => {
assert.equal(replaced.status, 200, replaced.text);
assert.ok(!fs.existsSync(path.join(target, "mine.txt")));
const aside = path.join(process.env.CLAUDE_HOME, "agenthub-replaced-skills");
const [old] = fs.readdirSync(aside);
const old = fs.readdirSync(aside).find((n) => !n.includes("-agents-"));
assert.equal(fs.readFileSync(path.join(aside, old, "mine.txt"), "utf8"), "keep me");
assert.ok(fs.readdirSync(aside).some((n) => n.startsWith("deploy-agents-")));
// Uninstall moves both copies aside too, never deletes them.
const gone = await request("POST", `/api/artifacts/${id}/uninstall`, {});
assert.equal(gone.status, 200, gone.text);
assert.deepEqual(gone.body.paths, [target, shared]);
assert.ok(!fs.existsSync(target) && !fs.existsSync(shared));
assert.equal((await listed()).installed, false);
const kept = fs.readdirSync(aside).filter((n) => n.includes("-uninstalled-"));
assert.equal(kept.length, 2);
const none = await request("POST", `/api/artifacts/${id}/uninstall`, {});
assert.equal(none.status, 409);
assert.equal(none.body.error.code, "ENOTINSTALLED");
});
it("edits a skill's name, description and instructions into a new snapshot", async () => {
const item = (await request("GET", "/api/artifacts?kind=skill")).body.items[0];
const before = db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot;
const r = await request("PATCH", `/api/artifacts/${item.id}`, {
title: "Ship",
skill: { name: "ship it", description: "Use to ship", content: "# Ship\n\nDo it." },
});
assert.equal(r.status, 200, r.text);
assert.equal(r.body.item.title, "Ship");
assert.equal(r.body.item.source, "ship-it");
assert.equal(r.body.item.description, "Use to ship");
// Other files are kept; the old snapshot is gone.
assert.ok(r.body.item.files.some((f) => f.path === "scripts/run.sh"));
const after = db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(item.id).snapshot;
assert.notEqual(after, before);
const md = await request("GET", `/api/artifacts/${item.id}/raw?path=SKILL.md`);
assert.match(
md.text,
/^---\nname: ship-it\n[\s\S]*description: Use to ship\n---\n\n# Ship\n\nDo it\.$/
);
const empty = await request("PATCH", `/api/artifacts/${item.id}`, {
skill: { name: "x", description: "d", content: " " },
});
assert.equal(empty.status, 400);
});
it("refuses a skill holding a symlink or a secret-looking file", async () => {
@@ -230,6 +283,12 @@ describe("plugins", () => {
["claude", "plugin", "marketplace", "add", "o/r"],
["claude", "plugin", "install", "--scope", "user", "tool@market"],
]);
calls.length = 0;
const un = await request("POST", `/api/artifacts/${r.body.item.id}/uninstall`, {});
assert.equal(un.status, 200, un.text);
assert.deepEqual(calls, [
["claude", "plugin", "uninstall", "--scope", "user", "--keep-data", "tool@market"],
]);
});
});
@@ -476,6 +535,46 @@ describe("sharing and visibility", () => {
assert.equal(artifacts.canSeeId(dev, hidden), false);
const shared = db.prepare("SELECT id FROM artifacts WHERE title = 'b'").get().id;
assert.throws(() => artifacts.update(dev, shared, { title: "x" }), { code: "EFORBIDDEN" });
// Deleting a shared row from its owner's library keeps it in the Hub;
// only unsharing it then deletes it.
const mine = db
.prepare(
"INSERT INTO artifacts (kind, title, owner_id, url, shared) VALUES ('page', 'd', 7, 'https://claude.ai/d', 1)"
)
.run().lastInsertRowid;
const kept = artifacts.remove(dev, mine);
assert.equal(kept.kept, true);
assert.ok(!artifacts.list(dev, { scope: "mine" }).some((i) => i.title === "d"));
const hubRow = artifacts.list(dev, { scope: "hub" }).find((i) => i.title === "d");
assert.equal(hubRow.inLibrary, false);
process.env.AGENTHUB_MODE = "server";
let gone;
try {
gone = artifacts.update(dev, mine, { shared: false });
} finally {
delete process.env.AGENTHUB_MODE;
}
assert.equal(gone.removed, true);
assert.equal(db.prepare("SELECT id FROM artifacts WHERE id = ?").get(mine), undefined);
// Installs of someone else's shared row: a developer sees only their own,
// its owner sees everyone's but not where another account wrote it.
db.prepare("UPDATE artifacts SET installs = 3 WHERE id = ?").run(shared);
const add = db.prepare(
"INSERT INTO artifact_installs (artifact_id, user_id, paths) VALUES (?, ?, '[\"/h/x\"]')"
);
add.run(shared, 7);
add.run(shared, 9);
const own = artifacts.installsOf(dev, shared);
assert.equal(own.items.length, 1);
assert.deepEqual(own.items[0].paths, ["/h/x"]);
assert.equal(own.unrecorded, 0);
const owner = { method: "GET", user: { id: 8, role: "developer", username: "o" } };
const seen = artifacts.installsOf(owner, shared);
assert.equal(seen.items.length, 2);
assert.ok(seen.items.every((i) => i.paths === undefined));
assert.equal(seen.unrecorded, 1);
});
it("refuses a cross-origin write", async () => {
+48 -1
View File
@@ -9,7 +9,9 @@
* same account; removing the account takes it out of those homes; and the
* Users-page route moves a saved switch-user profile in — the one this
* machine's own Claude Code is logged into with its LIVE login, the host's
* home then being one more placement the server refreshes.
* home then being one more placement the server refreshes. A pin of the
* owner to another login waits while a live pane holds the first, until the
* admin's switch writes it into the home (never over the member's own login).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -188,6 +190,51 @@ describe("a pool login", () => {
assert.equal(pool.__read("team"), null);
});
it("follows the owner's pin, and an admin's switch moves the home's login now", async () => {
const { db } = require("../db");
const pins = require("../lib/account-pins");
const miaId = db.prepare("SELECT id FROM users WHERE username = 'mia'").get().id;
pool.addLoginAccount("alpha", { oauth: oauth("a"), account: ACCOUNT });
pool.addLoginAccount("beta", {
oauth: oauth("b"),
account: { accountUuid: "uuid-beta", emailAddress: "beta@example.com" },
});
assert.equal(pool.forSpawn(acct, miaId).name, "alpha", "unpinned: the balancer's pick");
pins.setPin(miaId, "claude", "beta");
assert.equal(pool.forSpawn(acct, miaId).name, "beta");
assert.equal(pool.forSpawn(acct, null).name, "alpha", "another owner is not pinned");
// A pane of mia's runs on alpha: beta waits until it ends.
pool.forSpawn(acct).place();
fs.mkdirSync(path.join(HOME, "proj"), { recursive: true });
const lane = db
.prepare("INSERT INTO lanes (cwd, owner_id) VALUES (?, ?)")
.run(path.join(HOME, "proj"), miaId).lastInsertRowid;
db.prepare(
`INSERT INTO dashboard_runs (id, mode, cwd, status, started_at, account, lane_id, tool)
VALUES ('pin-run', 'interactive', ?, 'running', ?, 'alpha', ?, 'claude')`
).run(path.join(HOME, "proj"), new Date().toISOString(), lane);
assert.equal(pool.forSpawn(acct, miaId).name, "alpha", "a bare pin waits for the pane");
// The admin's switch writes beta into the home now; the pane is not touched.
assert.equal(await pins.applyPin(miaId, "claude"), "switched");
assert.equal(diskCreds().refreshToken, oauth("b").refreshToken);
assert.equal(pool.__read("alpha").placed.mia, undefined, "alpha no longer holds mia's home");
assert.equal(pool.forSpawn(acct, miaId).name, "beta");
assert.equal(await pins.applyPin(miaId, "claude"), "kept");
// Unpinned with the pane still live: the home's login stays, no flip back.
pins.setPin(miaId, "claude", null);
assert.equal(pool.forSpawn(acct, miaId).name, "beta");
// A login the member made themselves is never written over.
fs.writeFileSync(CREDS, JSON.stringify({ claudeAiOauth: oauth("own") }));
pins.setPin(miaId, "claude", "alpha");
assert.equal(await pins.applyPin(miaId, "claude"), "own-login");
assert.equal(diskCreds().refreshToken, oauth("own").refreshToken);
pins.setPin(miaId, "claude", "beta");
pool.removeAccount("beta");
assert.equal(pins.pinFor(miaId, "claude"), null, "a removed account drops its pins");
db.prepare("DELETE FROM dashboard_runs WHERE id = 'pin-run'").run();
db.prepare("DELETE FROM lanes WHERE id = ?").run(lane);
});
it("refuses a second copy of one Claude account", () => {
pool.addLoginAccount("team", { oauth: oauth("a"), account: ACCOUNT });
assert.throws(() => pool.addLoginAccount("team-2", { oauth: oauth("b"), account: ACCOUNT }), {
+132
View File
@@ -0,0 +1,132 @@
/**
* @file Tests for `server/lib/clone-queue.js`: no more clones run at once
* than the limit, the rest start in arrival order as slots free, a failing
* clone frees its slot too, the per-clone thread share, and a tracked
* clone's progress: its queue position, git's --progress lines parsed, and
* shown only to its owner.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const os = require("node:os");
const queue = require("../lib/clone-queue");
const saved = process.env.AGENTHUB_CLONE_CONCURRENCY;
function gate() {
let open;
const p = new Promise((r) => (open = r));
return { p, open };
}
describe("clone-queue", () => {
afterEach(() => {
if (saved === undefined) delete process.env.AGENTHUB_CLONE_CONCURRENCY;
else process.env.AGENTHUB_CLONE_CONCURRENCY = saved;
});
it("runs at most the limit at once, the rest in arrival order", async () => {
process.env.AGENTHUB_CLONE_CONCURRENCY = "2";
const started = [];
const gates = [gate(), gate(), gate(), gate()];
const runs = gates.map((g, i) =>
queue.withCloneSlot(async () => {
started.push(i);
await g.p;
return i;
})
);
await new Promise((r) => setImmediate(r));
assert.deepEqual(started, [0, 1]);
assert.deepEqual(queue.cloneQueueState(), { running: 2, waiting: 2, limit: 2 });
gates[1].open();
await runs[1];
await new Promise((r) => setImmediate(r));
assert.deepEqual(started, [0, 1, 2]);
gates[0].open();
gates[2].open();
gates[3].open();
assert.deepEqual(await Promise.all(runs), [0, 1, 2, 3]);
assert.deepEqual(queue.cloneQueueState(), { running: 0, waiting: 0, limit: 2 });
});
it("frees the slot when a clone fails", async () => {
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
await assert.rejects(
queue.withCloneSlot(async () => {
throw new Error("boom");
}),
/boom/
);
assert.equal(await queue.withCloneSlot(async () => "ok"), "ok");
assert.equal(queue.cloneQueueState().running, 0);
});
it("splits the cores between the running clones", () => {
const cores = os.availableParallelism?.() ?? os.cpus().length;
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
assert.equal(queue.threads(), Math.max(1, cores));
process.env.AGENTHUB_CLONE_CONCURRENCY = String(cores * 4);
assert.equal(queue.threads(), 1);
delete process.env.AGENTHUB_CLONE_CONCURRENCY;
assert.equal(queue.concurrency(), Math.max(1, Math.floor(cores / 2)));
});
it("reports a tracked clone's queue position, then git's progress, to its owner only", async () => {
process.env.AGENTHUB_CLONE_CONCURRENCY = "1";
const first = gate();
const a = queue.track("tok-aaaaaaaaaaaaaaaa", 7);
const b = queue.track("tok-bbbbbbbbbbbbbbbb", 7);
const ra = queue.withCloneSlot(() => first.p, a);
let release;
const rb = queue.withCloneSlot(() => new Promise((r) => (release = r)), b);
await new Promise((r) => setImmediate(r));
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).state, "running");
const waitingView = queue.progress("tok-bbbbbbbbbbbbbbbb", 7);
assert.equal(waitingView.state, "queued");
assert.equal(waitingView.position, 1);
assert.equal(waitingView.running, 1);
// Another user — or nobody — never sees it.
assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 8), null);
assert.equal(queue.progress("tok-cccccccccccccccc", 7), null);
queue.report(
a,
"Cloning into 'r'...\nremote: Counting objects: 100% (5/5), done.\n" +
"Receiving objects: 12% (120/1000), 3.00 MiB | 1.50 MiB/s\rReceiving objects: 45% (450/1000), 120.50 MiB | 5.20 MiB/s"
);
const v = queue.progress("tok-aaaaaaaaaaaaaaaa", 7);
assert.equal(v.phase, "receiving");
assert.equal(v.percent, 45);
assert.equal(v.detail, "450/1000, 120.50 MiB | 5.20 MiB/s");
queue.report(a, "Resolving deltas: 100% (300/300), done.\n");
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).phase, "resolving");
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).detail, "300/300");
first.open();
await ra;
queue.finish(a, true);
assert.equal(queue.progress("tok-aaaaaaaaaaaaaaaa", 7).state, "done");
await new Promise((r) => setImmediate(r));
assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 7).state, "running");
release();
await rb;
queue.finish(b, false);
assert.equal(queue.progress("tok-bbbbbbbbbbbbbbbb", 7).state, "failed");
});
it("refuses a malformed or reused token, and keeps progress out of an error", () => {
assert.throws(() => queue.track("short", null), { code: "EBADFIELD" });
assert.throws(() => queue.track("../../etc/passwd-xxxxxxxx", null), { code: "EBADFIELD" });
queue.track("tok-dddddddddddddddd", null);
assert.throws(() => queue.track("tok-dddddddddddddddd", null), { code: "EBADFIELD" });
assert.equal(
queue.stripProgress(
"Cloning into 'r'...\nReceiving objects: 45% (450/1000)\rfatal: early EOF\n"
),
"Cloning into 'r'...\nfatal: early EOF"
);
});
});
+72 -4
View File
@@ -4,8 +4,10 @@
* project slug walked back onto the filesystem) and no tokens, an unchanged
* file never re-read, a quiet one settled to completed, a session the user
* deleted staying deleted, and a non-cursor row with the same id never
* touched. The Cursor dir and the DB are temp dirs; the transcripts are
* synthetic, in the shape Cursor writes.
* touched, a session claimed by the Cursor pane it started in, and the
* transcript mapped to the Claude transcript shape by the
* transcript routes. The Cursor dir and the DB are temp dirs; the transcripts
* are synthetic, in the shape Cursor writes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,13 +18,18 @@ const path = require("node:path");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cursor-usage-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.AGENTHUB_CURSOR_DIR = path.join(ROOT, "cursor");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const { db } = require("../db");
const cursor = require("../lib/cursor-usage");
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
let server;
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
const ID = "7481e37e-9157-4145-b985-2ea9a2117f52";
const PROJECT = path.join(fs.realpathSync(ROOT), "work", "my app");
@@ -65,7 +72,8 @@ const session = (id) => db.prepare("SELECT * FROM sessions WHERE id = ?").get(id
beforeEach(() => {
fs.rmSync(process.env.AGENTHUB_CURSOR_DIR, { recursive: true, force: true });
db.exec(
"DELETE FROM token_usage; DELETE FROM agents; DELETE FROM sessions; DELETE FROM cursor_transcripts;"
"DELETE FROM token_usage; DELETE FROM agents; DELETE FROM sessions; DELETE FROM cursor_transcripts;" +
" DELETE FROM dashboard_runs; DELETE FROM lanes;"
);
});
@@ -175,4 +183,64 @@ describe("cursor usage", () => {
assert.equal(session(ID).name, `Cursor ${ID.slice(0, 8)}`);
assert.equal(session(ID).cwd, null);
});
it("maps the transcript to the transcript shape", async () => {
const file = transcript(ID);
const more = [
{
role: "assistant",
message: {
content: [
{ type: "text", text: "Reading it now." },
{ type: "tool_use", id: "t1", name: "Grep", input: { pattern: "x" } },
],
},
},
{
role: "user",
message: { content: [{ type: "tool_result", tool_use_id: "t1", content: "no match" }] },
},
{ role: "assistant", message: { content: [{ type: "text", text: "Done." }] } },
];
fs.appendFileSync(file, more.map((l) => JSON.stringify(l)).join("\n") + "\n");
await cursor.ingestCursorUsage();
assert.equal(cursor.transcriptPath(ID), file);
const { createApp, startServer } = require("../index");
server ||= await startServer(createApp(), 0);
const BASE = `http://127.0.0.1:${server.address().port}`;
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
assert.deepEqual(
list.transcripts.map((t) => [t.id, t.type]),
[["main", "main"]]
);
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
assert.deepEqual(
body.messages.map((m) => [m.sender, m.content.map((c) => c.type)]),
[
["user", ["text"]],
["assistant", ["tool_use"]],
["assistant", ["text", "tool_use"]],
["tool", ["tool_result"]],
["assistant", ["text"]],
]
);
assert.equal(body.messages[0].content[0].text, "Review the campaign code\nplease");
assert.equal(body.messages[3].content[0].output, "no match");
const sub = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=x`)).json();
assert.equal(sub.messages.length, 0);
});
it("gives the session to the Cursor pane it started in", async () => {
db.prepare("INSERT INTO lanes (id, title, cwd) VALUES (5, 'w', ?)").run(PROJECT);
db.prepare(
`INSERT INTO dashboard_runs (id, lane_id, cwd, status, started_at, tool)
VALUES ('agenthub-lane-5', 5, ?, 'running', ?, 'cursor')`
).run(PROJECT, new Date(Date.now() - 60_000).toISOString());
transcript(ID);
await cursor.ingestCursorUsage();
assert.equal(session(ID).lane_id, 5);
const run = db.prepare("SELECT session_id FROM dashboard_runs WHERE id = 'agenthub-lane-5'");
assert.equal(run.get().session_id, ID);
});
});
+119 -1
View File
@@ -2,7 +2,9 @@
* @file Route tests for the workspace File Explorer (`/api/lanes/:id/fs/*`,
* server/lib/file-explorer.js): list, mkdir/newfile, rename, move, copy (in
* place, across workspaces), delete into the trash and restore/purge, a
* streamed upload into new sub-folders with every conflict policy, a zip of
* streamed upload into new sub-folders with every conflict policy, a batch
* of many small files in one request (and, in a member's folder, as ONE
* write-as `extract` of a tar that real `tar` unpacks), a zip of
* files and folders that unzips back to the same bytes — and the guards: no
* `..`, no symlink walking out, nothing in `.git`, no secret file, the
* workspace folder itself never moved or deleted, cross-origin refused.
@@ -220,6 +222,77 @@ describe("File Explorer", () => {
assert.ok(!fs.existsSync(path.join(W1, "big.bin")));
});
it("uploads many small files as one batch, planned whole before writing", async () => {
const files = [
["many/a.txt", "a"],
["many/deep/b.txt", "bb"],
["many/empty", ""],
[`many/${"n".repeat(120)}/long.txt`, "long"],
];
const body = (list) => {
const head = Buffer.from(
JSON.stringify({ files: list.map(([p, c]) => ({ path: p, size: Buffer.byteLength(c) })) })
);
const len = Buffer.alloc(4);
len.writeUInt32BE(head.length);
return Buffer.concat([len, head, ...list.map(([, c]) => Buffer.from(c))]);
};
const batch = (list, conflict = "", dir = "up") =>
request(
"POST",
`/api/lanes/${lane1}/fs/upload-batch?dir=${dir}${conflict ? `&conflict=${conflict}` : ""}`,
body(list),
{ "Content-Type": "application/octet-stream" }
);
assert.equal((await fsApi(lane1, "mkdir", { path: "up" })).status, 201);
const check = await fsApi(lane1, "upload-check", { dir: "up", paths: files.map(([p]) => p) });
assert.deepEqual(check.body, { taken: [], batch: true });
const r = await batch(files);
assert.equal(r.status, 201);
assert.equal(r.body.saved, 4);
for (const [p, c] of files) assert.equal(fs.readFileSync(path.join(W1, "up", p), "utf8"), c);
// Taken names: one 409 naming them all, and nothing of the batch written.
const again = await batch([
["many/a.txt", "x"],
["many/new.txt", "n"],
["many/deep/b.txt", "y"],
]);
assert.equal(again.status, 409);
assert.deepEqual(again.body.error.names, ["many/a.txt", "many/deep/b.txt"]);
assert.ok(!fs.existsSync(path.join(W1, "up/many/new.txt")));
const taken = await fsApi(lane1, "upload-check", { dir: "up", paths: ["many/a.txt", "z"] });
assert.deepEqual(taken.body.taken, ["many/a.txt"]);
// skip / keep / replace for the whole batch.
const skip = await batch(
[
["many/a.txt", "x"],
["many/new.txt", "n"],
],
"skip"
);
assert.deepEqual([skip.body.saved, skip.body.skipped], [1, 1]);
const keep = await batch(
[
["many/a.txt", "k"],
["many/a (1).txt", "k1"],
],
"keep"
);
assert.deepEqual(keep.body.paths, ["up/many/a (2).txt", "up/many/a (1).txt"]);
await batch([["many/a.txt", "R"]], "replace");
assert.equal(fs.readFileSync(path.join(W1, "up/many/a.txt"), "utf8"), "R");
// The guards are upload's: no .git, no `..`, no file over a folder, no short body.
assert.equal((await batch([[".git/hooks/x", "x"]], "", "")).status, 400);
assert.equal((await batch([["../out", "x"]])).status, 400);
assert.equal((await batch([["many", "x"]])).status, 409);
const short = body([["s.txt", "12345"]]).subarray(0, -2);
const cut = await request("POST", `/api/lanes/${lane1}/fs/upload-batch?dir=up`, short, {
"Content-Type": "application/octet-stream",
});
assert.equal(cut.status, 400);
assert.ok(!fs.existsSync(path.join(W1, "up/s.txt")));
});
it("zips files and folders into one download that unzips to the same bytes", async () => {
const r = await request("GET", `/api/lanes/${lane1}/fs/zip?p=src&p=site/css/app.css`);
assert.equal(r.status, 200);
@@ -286,6 +359,51 @@ describe("File Explorer in a member's folder (S3, run-as stubbed)", () => {
assert.ok(fs.existsSync(path.join(M, "f.txt")), "the host itself moved nothing");
});
it("writes a batch as ONE write-as extract whose tar real tar unpacks", async () => {
const { spawn } = require("node:child_process");
const prev = [runAs.writeAsAsync, runAs.writeAsCanExtract];
const runs = [];
runAs.writeAsCanExtract = () => true;
runAs.writeAsAsync = (_a, file, args, { input }) => {
runs.push([file, ...args]);
return new Promise((resolve, reject) => {
const tar = spawn("tar", ["-x", "-k", "-p", "--no-same-owner", "-f", "-", "-C", file]);
let err = "";
tar.stderr.on("data", (d) => (err += d));
tar.on("close", (code) => (code ? reject(new Error(err)) : resolve()));
input.pipe(tar.stdin);
});
};
try {
const long = `${"x".repeat(150)}/${"y".repeat(90)}.txt`;
const list = [
["b/one.txt", "1"],
["b/sub/two.txt", "22"],
[long, "L"],
];
const head = Buffer.from(
JSON.stringify({ files: list.map(([p, c]) => ({ path: p, size: c.length })) })
);
const len = Buffer.alloc(4);
len.writeUInt32BE(head.length);
const { Readable } = require("node:stream");
const out = await fx.uploadBatch(
M,
"d",
Readable.from([Buffer.concat([len, head, ...list.map(([, c]) => Buffer.from(c))])])
);
assert.equal(out.saved, 3);
assert.deepEqual(runs, [[path.join(M, "d"), "extract"]]);
for (const [p, c] of list) assert.equal(fs.readFileSync(path.join(M, "d", p), "utf8"), c);
assert.equal((fs.statSync(path.join(M, "d/b/one.txt")).mode & 0o777).toString(8), "644");
// An old helper: the check says "no batch", the batch says EUPGRADE.
runAs.writeAsCanExtract = () => false;
assert.equal((await fx.uploadCheck(M, "d", ["z.txt"])).batch, false);
} finally {
[runAs.writeAsAsync, runAs.writeAsCanExtract] = prev;
}
});
it("says the helper needs updating instead of failing obscurely", async () => {
runAs.writeAsCanMove = () => false;
try {
+59
View File
@@ -64,6 +64,8 @@ before(async () => {
"init",
]);
execFileSync("git", ["clone", "-q", "--bare", src, bare]);
// A forge serves a partial clone only when it allows filters.
execFileSync("git", ["-C", bare, "config", "uploadpack.allowFilter", "true"]);
remote = http.createServer((_req, res) => {
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="t"' });
@@ -183,6 +185,61 @@ describe("POST /api/lanes/clone", () => {
assert.equal(r.body.error.code, "ENOREPO");
});
it("makes a blobless clone only when asked, and refuses a non-boolean", async () => {
const r = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "r-partial",
partial: true,
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const dir = path.join(WORK, "r-partial");
assert.equal(fs.readFileSync(path.join(dir, "README.md"), "utf8"), "hi\n");
const cfg = (k) =>
execFileSync("git", ["-C", dir, "config", "--get", k], { encoding: "utf8" }).trim();
assert.equal(cfg("remote.origin.partialclonefilter"), "blob:none");
// The full clone above is not a promisor remote.
assert.throws(() =>
execFileSync("git", ["-C", path.join(WORK, "r"), "config", "--get", "remote.origin.promisor"])
);
const bad = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "r-bad",
partial: "yes",
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADFIELD");
});
it("reports a clone's progress by its token, and refuses a bad token", async () => {
const token = "progress-test-0000001";
const r = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "r-progress",
progressToken: token,
});
assert.equal(r.status, 201, JSON.stringify(r.body));
const p = await request("GET", `/api/lanes/clone/progress/${token}`);
assert.equal(p.status, 200);
assert.equal(p.body.state, "done");
assert.equal(p.body.position, null);
assert.equal(
(await request("GET", "/api/lanes/clone/progress/nope-nope-nope-nope")).status,
404
);
const bad = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "r-badtoken",
progressToken: "x",
});
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADFIELD");
assert.ok(!fs.existsSync(path.join(WORK, "r-badtoken")));
});
it("refuses a cross-origin request", async () => {
const r = await request(
"POST",
@@ -649,6 +706,8 @@ describe("git-identity, member account (S3, stubbed)", () => {
/^core\.sshCommand=ssh -o IdentityFile='.*\/id_ed25519' -o IdentityFile='.*\/id_rsa' -o IdentitiesOnly=yes$/
);
assert.deepEqual(args.slice(-3), ["--", "git@gh.test:o/r.git", "r"]);
// index-pack gets a share of the cores, never persisted into the clone.
assert.match(args.slice(0, i).join(" "), /-c pack\.threads=\d+/);
});
it("gives a member's fetch their own store, and puts a typed login in it only for the call", () => {

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