Compare commits

...

13 Commits

Author SHA1 Message Date
nntrivi2001 6b602c76b8 chore: v2.8.25 — per-user Today, Alt+V image paste on Windows, local paste/drop by path 2026-10-26 11:18:05 +07:00
nntrivi2001 f2f1cd9b6c fix: per-user Today, native paste and drop on a local machine
- Today's spend in the status bar and the Claude Code statusline is the
  caller's own (`GET /api/pricing/cost?mine=1`, scope.ownSessionWhere), not
  every user's sum for an admin or analyst.
- A screenshot pasted into a local claude pane is sent as Claude Code's own
  image-paste key: Alt+V on Windows (Ctrl+V did nothing there), ^V elsewhere,
  and only when the machine can read its clipboard (wl-paste/xclip + a
  display on Linux), else it is uploaded (GET /api/run/native-paste {key}).
- A file manager's files pasted or dropped on a local browser go in by their
  own paths, read from the OS clipboard or the file manager's selection
  (server/lib/file-manager.js, POST /api/run/clipboard-files and
  /selected-files): no upload, no size limit. A copied image file is pasted
  by path, text that comes with a picture of it (spreadsheet cells) is
  pasted as text, Windows paths go in as `C:/…` mentions.
- Tab titles for Snapshot, Artifacts, Files and Administration.
- An npm tool AgentHub installed under ~/.local is updated there, not by its
  own updater into npm's global prefix.
2026-10-26 11:18:05 +07:00
nntrivi2001 c59e196e41 chore: v2.8.24 — sub-agent edits in Edited N files, OpenCode/Grok dropped, Artifacts list view and skill sync
Edited N files (#86): a turn's card counts the files its subagents wrote
or edited (read from their own transcripts, nested ones folded in), and a
turn longer than the chat's first page keeps the edits made before the
page cut. A non-Claude transcript page honours its limit again.

AI tools: OpenCode and Grok Build left the catalog (opencode-usage.js
gone; a Grok payload on the Claude hook route is dropped), and so did the
leftover Kimi Code quota reader.

Artifacts: a list view, skill packs and badges only by exception; a
shared skill follows its owner's folder, and an installer whose copy
differs is offered Update.
2026-10-26 09:46:45 +07:00
nntrivi2001 72aef2426c feat(artifacts): a shared skill follows its owner's folder; installers get Update
A skill in the Hub was a snapshot taken once: the owner changing
~/.claude/skills/<name> changed nothing there, and an installer had to
uninstall and install again to get a newer version.

- syncSkillSources (on GET /api/artifacts, at most every 15 s): a skill row
  taken from a folder — the owner's ~/.claude/skills/<name>, or the
  workspace's .claude/skills/<name> — is brought up to it. Only a folder
  whose names/sizes/mtimes changed is read again, with a pick's checks (no
  symlink, no secret, <= 5 MB, a member's files as theirs); a change is a
  NEW snapshot, the old one dropped, and an artifact_changed broadcast.
- artifacts.version (snapshot content hash) and source_hash (the folder's
  when last taken, additive columns): an upload or a Hub edit records the
  folder as it is then, so only a LATER folder change replaces it.
- A list row's updateAvailable: installed, and the viewer's copy differs
  from version. The page shows Update (a pack: Update N); install with
  {update: true} replaces the copies (old ones moved aside) and is not
  counted as another install.
2026-10-26 09:34:21 +07:00
nntrivi2001 244f999538 feat(artifacts): list view, skill packs, badges by exception
The page showed every artifact as a tall card with a blue Install and four
text actions, a Shared badge on nearly all of them and 12 cards per section,
so 26 artifacts filled ~1900px (5000px on a phone).

- List view by default (Grid kept, a per-browser choice): one row per
  artifact — kind icon, name, one line of description, owner and installs,
  ONE primary action, everything else in its ⋯ menu / right-click.
- Three or more skills of one owner sharing a name prefix (sg-*, vp-*) are
  one pack row: open it for its skills, or Install all / Install N more
  after one confirmation (a name already there is left alone).
- Badges only for the exception: Private (not shared), Hub only, Auto.
- No per-section paging: 30 entries, then Show more.
- Scope tabs under the title; category chips scroll on a phone.
- Another user's private row (seen by an admin) gets its own section
  instead of sitting under Shared by others.
- "1 installs" → "1 install".
2026-10-26 09:34:10 +07:00
nntrivi2001 127da516ac chore(quota): drop the Kimi Code quota reader (Kimi already left the catalog)
Redone on v2.8.22 from 3179d25 (feat/grok-usage, never merged).
2026-10-26 09:31:37 +07:00
nntrivi2001 c0832182f2 feat(ai-tools): drop OpenCode and Grok Build
- opencode.json / grok.json leave the built-in catalog; opencode-usage.js,
  its poller, transcript branch and table creation go with it (existing
  tables and session rows stay as history).
- A Grok payload reaching the Claude hook route (Grok runs the Claude hooks
  itself) is dropped instead of recorded as tool = 'grok'.
- quota.js no longer reads Grok or OpenCode Go logins.
- Redone on v2.8.22 from c6b2892 (feat/grok-usage, never merged).
2026-10-26 09:31:30 +07:00
nntrivi2001 9ea7758abb fix(transcript): a non-Claude page honours its limit again (#86)
`carry?.drop(messages.shift())` skips its argument when `carry` is null —
optional chaining short-circuits the whole call — so a Codex, omp, Cursor
or Antigravity page never shifted its sliding window and returned the
whole journal (codex-transcript.test.js: 8 messages for ?limit=2). Shift
first, then hand the dropped message to the carry.
2026-10-26 08:35:39 +07:00
nntrivi2001 f51a6a895e fix(chat): keep a long turn's Edited N files card whole across the page cut (#86)
The reported turn (/vp-auto, ~120 messages) showed "Edited 1 file": the
chat loads the newest 50 messages, and the turn's other Write
(rbac_routes_test.go, +176) was in the part before the page.

- turnCarry (agent-edits.js): every message the route's sliding window
  drops goes through it; it keeps the file changes since the last prompt it
  dropped (a dropped call whose result is on the page, and dropped
  subagents, included).
- A Claude page that starts mid-turn answers them as `turn_edits`; the
  chat keeps the one from the page that holds its oldest message and adds
  it to that turn's card.

Files a turn changed through Bash (python/sed writing a file) are still not
listed: nothing in the transcript says which files a command wrote.
2026-10-26 08:32:45 +07:00
nntrivi2001 5ccbaf5c23 fix(chat): count a subagent's edits in the turn's Edited N files card (#86)
A skill that hands its work to subagents (vp-auto) showed "Edited 1 file"
for a turn that changed 4: a subagent's Write/Edit calls are in its own
agent-<id>.jsonl, so the card only saw the parent's.

- server/lib/agent-edits.js reads a subagent's transcript (readLines, a
  member's only as theirs) for the files it wrote or edited with their +/-
  lines, nested subagents folded in (3 levels), cached by size + mtime.
- The transcript route puts them on each Claude Agent/Task result as
  `edits`; the card adds them to the turn's own edits. A file only a
  subagent wrote is listed, never an artifact card (its versions are
  rebuilt from this transcript's inputs, which a subagent's are not).
2026-10-26 08:32:45 +07:00
nntrivi2001 c8bc4a95a9 chore: v2.8.23 — workspace groups, inner repos in Changes (opt-in), sub-agent tokens counted
Workspace groups: named, foldable, ordered sidebar sections kept per
account (agenthub.workspace.groups); a workspace joins one from its menu,
a drop on the header, a drag under a row, or Alt+Up/Down past a group's
edge; headers drag to reorder; a folded header shows its most urgent
state; New workspace in this group; fold/unfold all.

Inner repos: GET /api/lanes/:id/repos + ?repo= on git/fs routes; the
Changes panel shows a parent folder's repos only when Settings -> Git ->
Changes panel turns it on (agenthub.changes.innerRepos, off by default).

Cost: the hook writer adds a session's sub-agent transcripts to its
tokens and hours (server/lib/subagent-usage.js) — a sub-agent on the
session's own model was never counted live (~8% of a week here); an
imported session is re-raised on every pass, so a claude -p run first
seen with an empty transcript no longer stays at $0 (~10%). A one-time
raise-only catch-up (user_version 5) re-reads ~/.claude/projects.

Also: omp install script may redirect to its exact install.redirect URL
(#85, repo-links test exempts it), native paste in the terminal.
2026-10-23 16:41:45 +07:00
nntrivi2001 65a001dad4 fix(lanes): cloning into a deleted workspace's folder re-attaches it instead of failing UNIQUE
A workspace record outlives its folder (deleted from the File Explorer or a
shell). Cloning the same repo into that path again ran `git clone`, then
createLane hit idx_lanes_cwd_tool and answered 500 "UNIQUE constraint failed:
lanes.cwd, lanes.tool", leaving the new clone with no workspace on it.

POST /api/lanes/clone now looks for the caller's own workspace at exactly
that folder and tool and answers 200 { lane, created: false, path }, like
POST /ensure. Another user's workspace there is still refused before the
clone (folderTakenByOther), and a nested path still makes its own workspace.
2026-10-23 16:28:18 +07:00
nntrivi2001 572d615d4e chore: v2.8.22 — Users today/7/30-day spend counts every tool, update chip follows a running update
- Users: today / 7 / 30-day spend read only token_usage_hourly, which holds
  Claude transcript hours only — omp/Codex/Antigravity/Cursor sessions,
  sub-agent files, baselines and long-context pricing were missing. Now
  booked by hourlyTokenRows + calculateCost like the cost routes.
- Update: a page adopts a plugin update the server reports as running
  (after a reload, another tab, or one started from an older bundle), and
  the progress chip shows whenever an update runs and the modal is not
  visible — also when the status stops offering it mid-run.
2026-10-23 11:26:12 +07:00
120 changed files with 5638 additions and 1972 deletions
+9 -9
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 KiB

After

Width:  |  Height:  |  Size: 465 KiB

+1 -1
View File
@@ -49,7 +49,7 @@
<g transform="translate(20,22)"><rect width="48" height="48" rx="12" fill="#4cc3f5" fill-opacity="0.12"/><rect x="11" y="11" width="11" height="11" rx="3" fill="#4cc3f5"/><rect x="26" y="11" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="11" y="26" width="11" height="11" rx="3" fill="none" stroke="#4cc3f5" stroke-width="2.5"/><rect x="26" y="26" width="11" height="11" rx="3" fill="#4cc3f5"/></g>
<text x="84" y="44" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="21" font-weight="700" fill="#f1f5ff">Every AI coding agent</text>
<text x="84" y="72" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Claude Code, Codex, Antigravity,</text>
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, Kimi, Grok and more</text>
<text x="84" y="94" font-family="'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif" font-size="16" fill="#9aa6c4">Cursor, omp and more</text>
</g>
<g transform="translate(792,300)">
<rect width="344" height="116" rx="16" fill="#ffffff" fill-opacity="0.045" stroke="#ffffff" stroke-opacity="0.10"/>

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+8
View File
@@ -24,10 +24,18 @@ export function DocumentTitle() {
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
} else if (path.startsWith("/sessions")) {
title = t("sessions");
} else if (path.startsWith("/snapshot/")) {
title = t("snapshot");
} else if (path.startsWith("/workspace")) {
title = t("workspace");
} else if (path.startsWith("/settings")) {
title = t("settings");
} else if (path.startsWith("/artifacts")) {
title = t("artifacts");
} else if (path.startsWith("/files")) {
title = t("files");
} else if (path.startsWith("/admin")) {
title = t("admin");
} else if (path !== "/") {
title = t("notFound");
}
+3 -5
View File
@@ -5,7 +5,8 @@
* — with a green dot while a newer release is out —, how many SESSIONS are
* running and how many need the user (`GET /api/sessions?status=active` and
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — and, on the
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
* user's OWN spend even for an admin — and, on the
* Workspace page, the open workspace's total (`?lane=` on the same call: the
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
* the URL's `session`, or the focused pane's session from the page's run poll,
@@ -50,9 +51,6 @@ const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
codex: "Codex",
antigravity: "Antigravity",
cursor: "Cursor",
grok: "Grok",
kimi: "Kimi",
opencode: "OpenCode Go",
};
function localDate(d = new Date()): string {
@@ -406,7 +404,7 @@ export function StatusBar({
const gen = costGen.current;
const live = () => gen === costGen.current;
api.pricing
.totalCost({ date: localDate() })
.totalCost({ date: localDate(), mine: true })
.then((r) => live() && setToday(r))
.catch(() => undefined);
api.budgets
+27 -9
View File
@@ -19,9 +19,12 @@
* background process can do for it. Escape, the backdrop and × close it for
* this page load only — also while an update applies: it keeps running in the
* background (the page still reloads itself once the new server is up), and a
* failure re-opens the modal with its error. While hidden, a small chip in the
* corner keeps showing the update — with the plugin chain's current step from
* `plugin_update_progress` — and a click on it shows the modal again; "Skip this version" is the one
* failure re-opens the modal with its error. While hidden — or once the status
* stops offering the update mid-run — a small chip in the corner keeps showing
* it, with the plugin chain's current step from `plugin_update_progress`, and a
* click on it shows the modal again. A chain the server reports as running
* (started before a reload or from another tab) is adopted the same way;
* "Skip this version" is the one
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
* error to an AI agent: it opens (or adopts) a workspace on the install folder
* and starts a Claude Code session there with the error as its prompt — the
@@ -242,13 +245,14 @@ export function UpdateNotifier() {
status?.latest_release ||
status?.remote_sha ||
(installed ? `installed:${installed.root}` : null);
// Updating replaces the host's install: only an admin is offered it.
// Updating replaces the host's install: only an admin is offered it. A
// running update stays shown (unless closed) even once the status stops
// offering it — the new version may already be on disk before it restarts.
const show = Boolean(
isAdmin &&
status?.update_available &&
dismissKey &&
dismissedSha !== dismissKey &&
closedKey !== dismissKey
closedKey !== dismissKey &&
(applying || restarting || (status?.update_available && dismissedSha !== dismissKey))
);
const busy = applying || restarting || fixing;
@@ -344,6 +348,19 @@ export function UpdateNotifier() {
[syncFromPayload, t]
);
// A chain already running on the server (started before a reload, in another
// tab, or from the old bundle) is adopted: this tab shows its steps, then
// reloads onto the new server like the tab that started it.
const running = status?.plugin_update_progress ?? null;
const adoptedRef = useRef(false);
useEffect(() => {
if (!running || !isAdmin || applying || restarting || adoptedRef.current) return;
adoptedRef.current = true;
setProgress(running);
setRestarting(true);
pollUntilBack(true, status?.local_version ?? null);
}, [running, isAdmin, applying, restarting, pollUntilBack, status?.local_version]);
const applyNow = async () => {
if (applying || restarting) return;
setError(null);
@@ -406,8 +423,9 @@ export function UpdateNotifier() {
})
: null;
// Hidden while it runs: a chip keeps it in sight and brings the modal back.
if (!show && isAdmin && (applying || restarting) && dismissKey && closedKey === dismissKey) {
// Hidden while it runs — closed, or the status no longer offers the update
// (the new version is already on disk): a chip keeps it in sight.
if (!show && isAdmin && (applying || restarting)) {
return (
<button
type="button"
+415 -10
View File
@@ -15,6 +15,16 @@
* first, no owner last, groups foldable — `collapsedOwners`); one this user
* may not change (`canEditLane`) is read only — no menu, no new session, and a
* session opens its analysis page (`/sessions/<id>`), never a pane.
* The user's own GROUPS (`lib/workspaceGroups.ts`, kept per account on the
* server like the other preferences) arrange the list under foldable headers —
* each group in its order, then "Ungrouped" — inside each owner group when
* there are owner groups. A workspace joins a group from its menu, by a drop
* on the group's header, by a drag that lands it below a row of that
* group, or by Alt+↑/↓ past the group's edge; a header is dragged onto
* another to reorder the groups, and its menu renames, moves, folds every
* group, adds a new workspace straight into it or deletes it (its
* workspaces stay, ungrouped). A folded header still shows the worst state
* of its workspaces (needs you, error, running) and marks the selected one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -30,7 +40,7 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
import { ChevronDown, ChevronRight, FolderPlus, MoreHorizontal, Plus, Search } from "lucide-react";
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
import type { Lane } from "../lib/types";
import { useLanes } from "../hooks/useLanes";
@@ -46,7 +56,31 @@ import {
writeLaneOrder,
} from "../lib/laneOrderStorage";
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
import {
arrangeByGroup,
assignGroup,
createGroup,
deleteGroup,
groupOf,
moveGroup,
moveGroupBefore,
pruneWorkspaceGroups,
renameGroup,
setAllFolded,
setGroupFolded,
useWorkspaceGroups,
type WorkspaceGroup,
} from "../lib/workspaceGroups";
import { confirmDialog } from "../lib/confirm";
import { ContextMenu, MenuItem, MenuSeparator } from "./ContextMenu";
import {
LANE_DRAG_TYPE,
WorkspaceRow,
isLaneDrag,
workspaceDot,
workspaceDotLabel,
workspaceName,
} from "./lanes/WorkspaceRow";
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
import { UserAvatar } from "./users/UserAvatar";
@@ -98,8 +132,23 @@ export function WorkspaceTree() {
);
const [addOpen, setAddOpen] = useState(false);
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
// "New workspace in this group": the group the added workspace joins.
const [addToGroup, setAddToGroup] = useState<string | null>(null);
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
const [orderTick, setOrderTick] = useState(0);
const groups = useWorkspaceGroups();
const hasGroups = groups.groups.length > 0;
const [renamingGroup, setRenamingGroup] = useState<string | null>(null);
const [groupMenu, setGroupMenu] = useState<{
group: WorkspaceGroup;
x: number;
y: number;
} | null>(null);
// A removed workspace leaves the stored groups (they hold ids only).
const laneIdsKey = lanes.map((l) => l.id).join(",");
useEffect(() => {
if (laneIdsKey) pruneWorkspaceGroups(laneIdsKey.split(",").map(Number));
}, [laneIdsKey]);
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
// status or session moves, so that is when the live-pane list is re-read;
@@ -126,8 +175,9 @@ export function WorkspaceTree() {
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
}, []);
const openAdd = (sourceRepo?: string) => {
const openAdd = (sourceRepo?: string, groupId: string | null = null) => {
setAddPrefill(sourceRepo ? { sourceRepo } : null);
setAddToGroup(groupId);
setAddOpen(true);
api.run
.cwds()
@@ -148,8 +198,9 @@ export function WorkspaceTree() {
const toolFilter =
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
const sorted = useMemo(() => {
const list = sortByStoredOrder(lanes, order).filter(
(l) => !toolFilter || l.tool === toolFilter
const list = arrangeByGroup(
sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
groups
);
if (!grouped) return list;
// Stable: each owner's workspaces keep the stored order inside the group.
@@ -157,7 +208,7 @@ export function WorkspaceTree() {
return [...list].sort(
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
);
}, [lanes, order, toolFilter, grouped, meId]);
}, [lanes, order, toolFilter, grouped, meId, groups]);
const q = query.trim().toLowerCase();
const nameHit = (l: Lane) =>
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
@@ -190,6 +241,26 @@ export function WorkspaceTree() {
setOrderTick((n) => n + 1);
};
// Alt+↑/↓. Past the edge of its group the workspace stays where it is on
// screen and joins the next group instead (the arrangement would otherwise
// put it straight back): last of the group above, first of the one below —
// opened if folded, so the focused row stays shown. The shown order is
// written as it stands, so the store agrees. Never across an owner's section.
const nudge = (from: number, to: number) => {
const lane = sorted[from];
const next = sorted[to];
if (lane && next && hasGroups && ownerKey(lane) === ownerKey(next)) {
const target = groupOf(groups, next.id);
if ((target?.id ?? null) !== (groupOf(groups, lane.id)?.id ?? null)) {
assignGroup(lane.id, target?.id ?? null);
if (target?.folded) setGroupFolded(target.id, false);
reorder((ids) => [...ids]);
return;
}
}
reorder((ids) => moveLane(ids, from, to));
};
// A row only starts a drag; the whole section takes the drop, so a gap — a
// workspace's session list, the space above or below a row — lands too.
// `dropAt` is the insertion point in `sorted`, drawn as a line.
@@ -223,11 +294,74 @@ export function WorkspaceTree() {
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
// Number("") is 0: an empty payload must not move the first row.
const from = raw === "" ? NaN : Number(raw);
if (!Number.isInteger(from)) return;
const lane = sorted[from];
// Dropped on a group's header: into that group, at its top.
const header = (e.target as HTMLElement).closest<HTMLElement>("[data-group-drop]");
if (header && lane) {
const gid = header.dataset.groupDrop || null;
assignGroup(lane.id, gid);
const first = sorted.findIndex(
(l) => (groups.of[l.id] ?? null) === gid && l.id !== lane.id
);
if (first >= 0) reorder((ids) => moveBefore(ids, from, first));
return;
}
const before = dropIndexAt(e.clientY);
if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before));
// Elsewhere it joins the group of the row it now sits under (the line
// drawn above a header closes the section before it).
if (lane && hasGroups) {
const rest = sorted.filter((l) => l.id !== lane.id);
const at = sorted.slice(0, before).filter((l) => l.id !== lane.id).length;
const neighbour = rest[at - 1] ?? rest[0];
if (neighbour) assignGroup(lane.id, groups.of[neighbour.id] ?? null);
}
reorder((ids) => moveBefore(ids, from, before));
},
};
// A group no workspace is in yet still shows (to be renamed, or dropped on),
// just above the ungrouped ones; a search hides it.
const emptyGroups = q
? []
: groups.groups.filter((g) => !sorted.some((l) => groups.of[l.id] === g.id));
const firstUngrouped = hasGroups ? sorted.findIndex((l) => !groupOf(groups, l.id)) : -1;
const groupHeader = (g: WorkspaceGroup | null, members: Lane[], first: boolean) => (
<GroupHeader
key={g?.id ?? "none"}
group={g}
count={members.length}
// What a folded group hides: the most urgent of its workspaces, and
// whether the open one is among them.
worst={worstOf(members)}
holdsSelected={members.some((l) => l.id === selected.lane)}
onGroupDrop={(dragged) => moveGroupBefore(dragged, g?.id ?? null)}
folded={!!g?.folded && !q}
renaming={!!g && renamingGroup === g.id}
first={first}
onToggle={() => g && setGroupFolded(g.id, !g.folded)}
onMenu={(x, y) => g && setGroupMenu({ group: g, x, y })}
onRenameEnd={(name) => {
setRenamingGroup(null);
if (g && name !== null) renameGroup(g.id, name);
}}
/>
);
const newGroup = (laneIds: number[] = []) => {
const id = createGroup(t("tree.groupDefaultName"), laneIds);
setRenamingGroup(id);
};
const removeGroup = async (g: WorkspaceGroup) => {
const ok = await confirmDialog({
title: t("tree.groupDelete"),
message: t("tree.groupDeleteConfirm", { name: g.name }),
confirmLabel: t("tree.groupDelete"),
});
if (!ok) return;
deleteGroup(g.id);
};
const rename = async (lane: Lane, title: string | null) => {
setRenamingId(null);
if (title === null || title === (lane.title || "")) return;
@@ -304,6 +438,16 @@ export function WorkspaceTree() {
>
<Search className="h-3.5 w-3.5" />
</button>
<button
type="button"
data-testid="workspace-tree-new-group"
onClick={() => newGroup()}
aria-label={t("tree.newGroup")}
title={t("tree.newGroup")}
className={headBtn}
>
<FolderPlus className="h-3.5 w-3.5" />
</button>
<button
type="button"
data-testid="workspace-tree-add"
@@ -347,6 +491,16 @@ export function WorkspaceTree() {
: ownerShown(lane) || `#${lane.owner_id}`;
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
// The user's own group, inside the owner's.
const ug = groupOf(groups, lane.id);
const ugKey = ug?.id ?? "";
const sameSection = (l: Lane) =>
ownerKey(l) === owner && (groupOf(groups, l.id)?.id ?? "") === ugKey;
const prev = sorted[i - 1];
const sectionStart = hasGroups && (groupStart || !prev || !sameSection(prev));
const sectionShown = sorted.some((l) => sameSection(l) && visible(l));
const sectionFolded = !!ug?.folded && !q;
const hiddenRow = folded || sectionFolded;
return (
<div key={lane.id} className="relative">
{groupStart && groupShown && (
@@ -381,8 +535,13 @@ export function WorkspaceTree() {
</span>
</button>
)}
{i === firstUngrouped && emptyGroups.map((g) => groupHeader(g, [], false))}
{sectionStart &&
sectionShown &&
!folded &&
groupHeader(ug, sorted.filter(sameSection), i === 0 || !!groupStart)}
{dropAt === i && <DropLine />}
{visible(lane) && !folded && (
{visible(lane) && !hiddenRow && (
<WorkspaceRow
lane={lane}
index={i}
@@ -398,13 +557,13 @@ export function WorkspaceTree() {
}}
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
onNewSession={() => select(lane.id)}
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
onReorder={nudge}
onMenu={(x, y) => setMenu({ lane, x, y })}
onRenameStart={() => setRenamingId(lane.id)}
onRenameEnd={(title) => void rename(lane, title)}
/>
)}
{expanded(lane.id) && !folded && (
{expanded(lane.id) && !hiddenRow && (
<WorkspaceSessionList
lane={lane}
liveRuns={liveRuns}
@@ -420,6 +579,8 @@ export function WorkspaceTree() {
</div>
);
})}
{firstUngrouped === -1 &&
emptyGroups.map((g, k) => groupHeader(g, [], k === 0 && sorted.length === 0))}
{dropAt === sorted.length && (
<div className="relative">
<DropLine />
@@ -440,8 +601,92 @@ export function WorkspaceTree() {
y={menu.y}
onClose={() => setMenu(null)}
onAction={(a) => onMenuAction(menu.lane, a)}
groups={groups.groups}
groupId={groups.of[menu.lane.id] ?? null}
onGroup={(target) => {
const id = menu.lane.id;
setMenu(null);
if (target === "new") newGroup([id]);
else assignGroup(id, target);
}}
/>
)}
{groupMenu && (
<ContextMenu
x={groupMenu.x}
y={groupMenu.y}
onClose={() => setGroupMenu(null)}
testId={`workspace-group-menu-${groupMenu.group.id}`}
>
<MenuItem
onSelect={() => {
setRenamingGroup(groupMenu.group.id);
setGroupMenu(null);
}}
>
{t("tree.groupRename")}
</MenuItem>
<MenuItem
disabled={groups.groups[0]?.id === groupMenu.group.id}
onSelect={() => {
moveGroup(groupMenu.group.id, -1);
setGroupMenu(null);
}}
>
{t("tree.groupMoveUp")}
</MenuItem>
<MenuItem
disabled={groups.groups[groups.groups.length - 1]?.id === groupMenu.group.id}
onSelect={() => {
moveGroup(groupMenu.group.id, 1);
setGroupMenu(null);
}}
>
{t("tree.groupMoveDown")}
</MenuItem>
<MenuSeparator />
<MenuItem
testId="workspace-group-add"
onSelect={() => {
const g = groupMenu.group;
setGroupMenu(null);
openAdd(undefined, g.id);
}}
>
{t("tree.groupAddWorkspace")}
</MenuItem>
<MenuItem
testId="workspace-group-fold-all"
onSelect={() => {
setGroupMenu(null);
setAllFolded(true);
}}
>
{t("tree.groupFoldAll")}
</MenuItem>
<MenuItem
testId="workspace-group-unfold-all"
onSelect={() => {
setGroupMenu(null);
setAllFolded(false);
}}
>
{t("tree.groupUnfoldAll")}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
testId="workspace-group-delete"
onSelect={() => {
const g = groupMenu.group;
setGroupMenu(null);
void removeGroup(g);
}}
>
{t("tree.groupDelete")}
</MenuItem>
</ContextMenu>
)}
<Suspense fallback={null}>
{destructive && (
<DestructiveLaneModal
@@ -460,8 +705,10 @@ export function WorkspaceTree() {
onClose={() => {
setAddOpen(false);
setAddPrefill(null);
setAddToGroup(null);
}}
onAdded={(lane) => {
if (addToGroup) assignGroup(lane.id, addToGroup);
void refreshLanes();
select(lane.id);
}}
@@ -472,9 +719,167 @@ export function WorkspaceTree() {
);
}
/** What a group header's drag carries: the group's id. */
const GROUP_DRAG_TYPE = "application/x-agenthub-workspace-group";
const isGroupDrag = (e: { dataTransfer: DataTransfer | null }) =>
!!e.dataTransfer?.types?.includes(GROUP_DRAG_TYPE);
// needsYou outranks an error, an error a running agent; idle/active say nothing.
const URGENCY: Record<string, number> = { needsYou: 3, error: 2, running: 1 };
/** The most urgent of `lanes` by its dot, or null when all are at rest. */
function worstOf(lanes: Lane[]): Lane | null {
let best: Lane | null = null;
let top = 0;
for (const l of lanes) {
const u = URGENCY[workspaceDotLabel(l)] ?? 0;
if (u > top) [best, top] = [l, u];
}
return best;
}
/** A workspace's owner group: its user id, or "none". */
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
/**
* A section's header: a user group (foldable, menu, inline rename) or, with
* `group` null, "Ungrouped". Both take a dropped workspace (`data-group-drop`).
*/
function GroupHeader({
group,
count,
worst,
holdsSelected,
folded,
renaming,
first,
onToggle,
onMenu,
onRenameEnd,
onGroupDrop,
}: {
group: WorkspaceGroup | null;
count: number;
/** The most urgent workspace inside, drawn as its dot while folded. */
worst: Lane | null;
/** The open workspace is in this group (marked while folded). */
holdsSelected: boolean;
folded: boolean;
renaming: boolean;
first: boolean;
onToggle: () => void;
onMenu: (x: number, y: number) => void;
/** The typed name, or null when the edit was cancelled. */
onRenameEnd: (name: string | null) => void;
/** A group header (by id) dropped here: it goes before this group, or last on "Ungrouped". */
onGroupDrop: (draggedId: string) => void;
}) {
const { t } = useTranslation("lanes");
const [draft, setDraft] = useState(group?.name ?? "");
const [dropOver, setDropOver] = useState(false);
useEffect(() => {
if (renaming) setDraft(group?.name ?? "");
}, [renaming, group?.name]);
const label = group ? group.name : t("tree.ungrouped");
return (
<div
data-group-drop={group?.id ?? ""}
data-testid={`workspace-group-${group?.id ?? "none"}`}
onContextMenu={(e) => {
if (!group) return;
e.preventDefault();
onMenu(e.clientX, e.clientY);
}}
// A group (never "Ungrouped") is dragged by its header to reorder.
draggable={!!group && !renaming}
onDragStart={(e) => {
if (!group) return;
e.dataTransfer.setData(GROUP_DRAG_TYPE, group.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (!isGroupDrag(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropOver(true);
}}
onDragLeave={() => setDropOver(false)}
onDrop={(e) => {
setDropOver(false);
if (!isGroupDrag(e)) return;
e.preventDefault();
const id = e.dataTransfer.getData(GROUP_DRAG_TYPE);
if (id) onGroupDrop(id);
}}
className={`group/gh flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-semibold text-fg-muted ${first ? "" : "mt-1.5"} ${dropOver ? "shadow-[inset_0_2px_0_rgb(var(--accent))]" : ""}`}
>
{renaming && group ? (
<input
autoFocus
value={draft}
maxLength={60}
aria-label={t("tree.groupNameLabel")}
onChange={(e) => setDraft(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
onBlur={() => onRenameEnd(draft)}
onKeyDown={(e) => {
if (e.key === "Enter") onRenameEnd(draft);
else if (e.key === "Escape") onRenameEnd(null);
}}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-1 py-0.5 text-[11px] font-normal text-fg-primary"
/>
) : (
<button
type="button"
aria-expanded={group ? !folded : undefined}
disabled={!group}
title={group ? t("tree.groupToggle", { name: label }) : undefined}
onClick={onToggle}
className="flex min-w-0 flex-1 items-center gap-1 text-left hover:text-fg-primary disabled:hover:text-fg-muted"
>
{group ? (
folded ? (
<ChevronRight className="h-3 w-3 shrink-0" />
) : (
<ChevronDown className="h-3 w-3 shrink-0" />
)
) : (
<span className="w-3 shrink-0" />
)}
<span
className={`truncate uppercase tracking-wide ${folded && holdsSelected ? "text-accent" : ""}`}
>
{label}
</span>
<span className="ml-1 shrink-0 font-normal tabular-nums">{count}</span>
{folded && worst && (
<span
data-testid={`workspace-group-state-${group?.id ?? "none"}`}
className="ml-1 flex shrink-0 items-center"
>
<span aria-hidden className={`h-1.5 w-1.5 rounded-full ${workspaceDot(worst)}`} />
<span className="sr-only">{t(`row.state.${workspaceDotLabel(worst)}`)}</span>
</span>
)}
</button>
)}
{group && !renaming && (
<button
type="button"
aria-label={t("tree.groupMenu")}
title={t("tree.groupMenu")}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
onMenu(r.left, r.bottom);
}}
className="rounded p-0.5 opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover/gh:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
/** Where a dragged workspace would land: a line on its parent's top edge. */
function DropLine() {
return (
@@ -34,5 +34,13 @@ describe("DocumentTitle", () => {
expect(titleFor("/")).toMatch(/^Dashboard · /);
expect(titleFor("/sessions")).toMatch(/^Sessions · /);
expect(titleFor("/settings")).toMatch(/^Settings · /);
expect(titleFor("/artifacts")).toMatch(/^Artifacts · /);
expect(titleFor("/files")).toMatch(/^Files · /);
expect(titleFor("/admin")).toMatch(/^Administration · /);
expect(titleFor("/snapshot/abc")).toMatch(/^Snapshot · /);
});
it("titles an unknown route as not found", () => {
expect(titleFor("/nope")).toMatch(/^Not found · /);
});
});
@@ -162,7 +162,7 @@ describe("StatusBar", () => {
const conn = screen.getByRole("button", { name: /Live/ });
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today() });
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today(), mine: true });
expect(cost).toHaveAttribute(
"title",
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
@@ -353,6 +353,39 @@ describe("UpdateNotifier — hidden while it runs", () => {
expect(screen.queryByTestId("update-chip")).toBeNull();
});
it("adopts a chain the server reports as running, and keeps it once the update is no longer offered", async () => {
const running: UpdateStatusPayload = {
...BASE_STATUS,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
commits_behind: 0,
local_version: "1.5.0",
release_check: "ok",
latest_release: "v1.6.0",
// The new version is already installed: the status no longer offers it.
update_available: false,
plugin_update_progress: {
step: "bootstrap",
index: 3,
total: 3,
started_at: new Date().toISOString(),
},
};
statusMock.mockResolvedValue(running);
render(<UpdateNotifier />);
const step = i18n.t("updates:pluginStep.bootstrap", { index: 3, total: 3 });
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
expect(screen.queryByText(i18n.t("updates:updateNow"))).toBeNull();
await userEvent.keyboard("{Escape}");
const chip = await screen.findByTestId("update-chip");
expect(chip).toHaveTextContent(step);
await userEvent.click(chip);
expect(await screen.findByTestId("update-step")).toHaveTextContent(step);
});
it("shows no chip when nothing is running", async () => {
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:title"));
@@ -6,7 +6,9 @@
* workspace's OWN sessions only, a session's state dot, rename and archive,
* selection
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
* search, the row menu's items by kind and tool, and the status dot.
* search, the row menu's items by kind and tool, the status dot, and the
* user's own groups (menu, Alt+arrow past a group's edge, a folded group's
* state dot, dragging a header to reorder, fold all).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -91,6 +93,20 @@ vi.mock("../../lib/api", async (importOriginal) => {
};
});
// The Add modal reports the workspace it made (id 2 here); the tree does the rest.
vi.mock("../lanes/AddLaneModal", () => ({
AddLaneModal: ({ onAdded, onClose }: { onAdded: (l: Lane) => void; onClose: () => void }) => (
<button
type="button"
data-testid="stub-add-lane"
onClick={() => {
onAdded(lane(2, "/r/sub"));
onClose();
}}
/>
),
}));
import { api } from "../../lib/api";
import { WorkspaceTree } from "../WorkspaceTree";
import { workspaceDot } from "../lanes/WorkspaceRow";
@@ -576,6 +592,185 @@ describe("WorkspaceTree", () => {
menu = screen.getByTestId("workspace-row-menu-3");
expect(within(menu).getByText("Reset worktree…")).toBeInTheDocument();
});
it("groups workspaces under named, foldable headers, ungrouped last", async () => {
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-row-1");
// No group yet: no headers at all.
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
// A new group from the row menu holds that workspace and asks for a name.
await user.click(screen.getByTestId("workspace-menu-3"));
await user.click(screen.getByTestId("workspace-move-group-new"));
const name = await screen.findByRole("textbox", { name: "Group name" });
await user.clear(name);
await user.type(name, "Backend{Enter}");
const stored = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
expect(stored.groups).toEqual([expect.objectContaining({ name: "Backend" })]);
const gid = stored.groups[0].id as string;
expect(stored.of).toEqual({ 3: gid });
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
);
expect(screen.getByTestId(`workspace-group-${gid}`)).toHaveTextContent("Backend1");
expect(screen.getByTestId("workspace-group-none")).toHaveTextContent("Ungrouped2");
// Another workspace joins it from its menu.
await user.click(screen.getByTestId("workspace-menu-2"));
await user.click(screen.getByTestId(`workspace-move-group-${gid}`));
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-2", "workspace-row-3", "workspace-row-1"])
);
// Folding hides its rows; the ungrouped one stays.
await user.click(
within(screen.getByTestId(`workspace-group-${gid}`)).getByRole("button", {
expanded: true,
})
);
expect(rowIds()).toEqual(["workspace-row-1"]);
// Deleting the group only ungroups: every workspace is still listed.
fireEvent.contextMenu(screen.getByTestId(`workspace-group-${gid}`));
await user.click(screen.getByTestId("workspace-group-delete"));
await waitFor(() => expect(rowIds()).toHaveLength(3));
expect(screen.queryByTestId("workspace-group-none")).toBeNull();
expect(api.lanes.update).not.toHaveBeenCalled();
});
it("shows a new, empty group to be named, above the ungrouped workspaces", async () => {
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-row-1");
await user.click(screen.getByTestId("workspace-tree-new-group"));
const name = await screen.findByRole("textbox", { name: "Group name" });
await user.clear(name);
await user.type(name, "Later{Enter}");
const gid = JSON.parse(localStorage.getItem("agenthub.workspace.groups")!).groups[0].id;
const header = screen.getByTestId(`workspace-group-${gid}`);
expect(header).toHaveTextContent("Later0");
expect(
header.compareDocumentPosition(screen.getByTestId("workspace-group-none")) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(rowIds()).toHaveLength(3);
});
});
describe("WorkspaceTree groups", () => {
// Groups A = [1], B = [3]; 2 ungrouped. Stored order 1, 2, 3.
const seed = (extra: { folded?: boolean } = {}) =>
localStorage.setItem(
"agenthub.workspace.groups",
JSON.stringify({
groups: [
{ id: "ga", name: "Alpha", ...extra },
{ id: "gb", name: "Beta" },
],
of: { 1: "ga", 3: "gb" },
})
);
const stored = () => JSON.parse(localStorage.getItem("agenthub.workspace.groups")!);
it("moves a workspace into the next group with Alt+arrow past its edge", async () => {
seed();
const user = userEvent.setup();
renderTree();
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
);
// The last of Alpha, Alt+↓: the first of Beta.
screen.getByTestId("workspace-open-1").focus();
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
expect(stored().of).toEqual({ 1: "gb", 3: "gb" });
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-1", "workspace-row-3", "workspace-row-2"])
);
expect(screen.getByTestId("workspace-group-gb")).toHaveTextContent("Beta2");
// The last of Beta, Alt+↓: out into the ungrouped ones.
screen.getByTestId("workspace-open-3").focus();
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
expect(stored().of).toEqual({ 1: "gb" });
expect(api.lanes.update).not.toHaveBeenCalled();
});
it("shows a folded group's most urgent state and marks the open workspace", async () => {
lanes[0] = lane(1, "/r", { title: "Repo", status: "running" });
seed({ folded: true });
renderTree("/workspace?lane=1");
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-2"]));
const state = screen.getByTestId("workspace-group-state-ga");
expect(state).toHaveTextContent("running");
expect(within(screen.getByTestId("workspace-group-ga")).getByText("Alpha")).toHaveClass(
"text-accent"
);
// An open group shows its rows instead.
expect(screen.queryByTestId("workspace-group-state-gb")).toBeNull();
});
it("reorders groups by dragging a header onto another, last onto Ungrouped", async () => {
seed();
renderTree();
await screen.findByTestId("workspace-group-gb");
const data = new Map<string, string>();
const dataTransfer = {
get types() {
return [...data.keys()];
},
setData: (k: string, v: string) => data.set(k, v),
getData: (k: string) => data.get(k) ?? "",
dropEffect: "move",
effectAllowed: "move",
};
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
fireEvent.dragOver(screen.getByTestId("workspace-group-ga"), { dataTransfer });
fireEvent.drop(screen.getByTestId("workspace-group-ga"), { dataTransfer });
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["gb", "ga"]);
await waitFor(() =>
expect(rowIds()).toEqual(["workspace-row-3", "workspace-row-1", "workspace-row-2"])
);
data.clear();
fireEvent.dragStart(screen.getByTestId("workspace-group-gb"), { dataTransfer });
fireEvent.drop(screen.getByTestId("workspace-group-none"), { dataTransfer });
expect(stored().groups.map((g: { id: string }) => g.id)).toEqual(["ga", "gb"]);
// A header drag never moves a workspace.
expect(stored().of).toEqual({ 1: "ga", 3: "gb" });
});
it("puts a workspace added from a group's menu into that group", async () => {
seed();
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-group-gb");
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
await user.click(screen.getByTestId("workspace-group-add"));
await user.click(await screen.findByTestId("stub-add-lane"));
expect(stored().of).toEqual({ 1: "ga", 2: "gb", 3: "gb" });
// The header's own Add belongs to no group: what it adds stays where it is.
act(() =>
localStorage.setItem(
"agenthub.workspace.groups",
JSON.stringify({ ...stored(), of: { 1: "ga" } })
)
);
await user.click(screen.getByTestId("workspace-tree-add"));
await user.click(await screen.findByTestId("stub-add-lane"));
expect(stored().of).toEqual({ 1: "ga" });
});
it("folds and unfolds every group from a header's menu", async () => {
seed();
const user = userEvent.setup();
renderTree();
await screen.findByTestId("workspace-group-ga");
fireEvent.contextMenu(screen.getByTestId("workspace-group-ga"));
await user.click(screen.getByTestId("workspace-group-fold-all"));
await waitFor(() => expect(rowIds()).toEqual(["workspace-row-2"]));
fireEvent.contextMenu(screen.getByTestId("workspace-group-gb"));
await user.click(screen.getByTestId("workspace-group-unfold-all"));
await waitFor(() => expect(rowIds()).toHaveLength(3));
});
});
describe("workspaceDot", () => {
@@ -78,7 +78,12 @@ import { useSpin } from "../../hooks/useSpin";
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
import { throttle } from "../../lib/throttle";
import { useTranslation } from "react-i18next";
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
import type {
TranscriptMessage,
TranscriptInfo,
TranscriptResult,
WSMessage,
} from "../../lib/types";
// Catch-up poll interval. Claude Code only fires hooks on PreToolUse /
// PostToolUse / Stop, which means a user-typed message (no hook) and any
@@ -175,6 +180,8 @@ function ConversationViewPane({
initialTranscriptId ?? null
);
const [hasMore, setHasMore] = useState(false);
// The edits of the turn the oldest loaded page starts inside of (`turn_edits`).
const [leadEdits, setLeadEdits] = useState<TranscriptResult["turn_edits"]>(undefined);
const [error, setError] = useState<string | null>(null);
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
const [showNewMsg, setShowNewMsg] = useState(false);
@@ -283,6 +290,7 @@ function ConversationViewPane({
if (cancelled) return;
setNotStarted(false);
setMessages(result.messages);
setLeadEdits(result.turn_edits);
setTotal(result.total);
setHasMore(result.has_more);
lastLineRef.current = result.last_line;
@@ -343,6 +351,7 @@ function ConversationViewPane({
// Seed the view in a single render so the user sees the whole
// catch-up batch instead of a blank panel followed by a partial one.
setMessages(result.messages);
setLeadEdits(result.turn_edits);
firstLineRef.current = result.first_line;
setHasMore(result.has_more);
} else {
@@ -487,6 +496,7 @@ function ConversationViewPane({
firstLineRef.current = result.first_line;
setMessages((prev) => [...result.messages, ...prev]);
setLeadEdits(result.turn_edits);
setHasMore(result.has_more);
// Preserve scroll position (don't jump to top)
@@ -686,6 +696,7 @@ function ConversationViewPane({
stopped={stopped}
agentLinks={agentLinks}
compact={compact}
leadEdits={leadEdits}
sessionId={
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
}
@@ -172,6 +172,9 @@ interface MessageListProps {
/** A subagent's conversation inside its step: tight spacing, and the task
* the orchestrator gave it reads as the prompt it is. */
compact?: boolean;
/** The files the first, cut-off turn changed before `messages` begins (the
* transcript page's `turn_edits`): counted in that turn's card. */
leadEdits?: TranscriptContent["edits"];
}
/** Build a map from tool_use id → tool_result for matching */
@@ -491,6 +494,10 @@ type Item =
| { kind: "command"; key: string; name: string | null; output: string | null; error: boolean };
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
// A subagent's result carries the files IT changed (`edits`): they count too.
const AGENT_TOOLS = new Set(["Agent", "Task"]);
// The stand-in for the part of a turn the page cut off: its `edits` count.
const LEAD = "__lead_edits";
/** A line that is only Claude Code's local-command plumbing — the caveat it
* writes first, the command, what the command printed — or null for
@@ -515,11 +522,18 @@ export function localCommand(
return { name, output, error };
}
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
export function chatItems(
messages: TranscriptMessage[],
keys: string[],
leadEdits?: TranscriptContent["edits"]
): Item[] {
const items: Item[] = [];
// The file-changing calls of the turn so far, closed into one card when the
// human speaks again (or the list ends).
let edits: TranscriptContent[] = [];
// human speaks again (or the list ends) — starting with what the page's
// cut-off first turn changed before it.
let edits: TranscriptContent[] = leadEdits?.length
? [{ type: "tool_use", name: LEAD, edits: leadEdits }]
: [];
// The turn's prompt time, its newest assistant line and its output so far,
// for the "Done in …" line under a turn that ended.
let start: string | null = null;
@@ -592,7 +606,11 @@ export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[]
if (msg.usage) output.add(msg.usage);
msg.content.forEach((block, b) => {
const last = items[items.length - 1];
if (block.type === "tool_use" && EDIT_TOOLS.has(block.name ?? "")) edits.push(block);
if (
block.type === "tool_use" &&
(EDIT_TOOLS.has(block.name ?? "") || AGENT_TOOLS.has(block.name ?? ""))
)
edits.push(block);
// A question to the user is a turn of the conversation, not hidden work.
if (block.type === "tool_use" && block.name === "AskUserQuestion") {
items.push({ kind: "ask", key: `${key}:${b}`, block });
@@ -672,19 +690,38 @@ function EditedFilesCard({
const files = new Map<string, { added: number; removed: number; counted: boolean }>();
// Written whole in this turn (a Write): a component made here is an artifact.
const created = new Set<string>();
// Changed only by a subagent: listed, never an artifact card — the chat
// rebuilds an artifact from this transcript's Write/Edit inputs, which a
// subagent's are not.
const byAgent = new Set<string>();
for (const b of blocks) {
const r0 = b.id ? toolResultMap.get(b.id) : undefined;
if (AGENT_TOOLS.has(b.name ?? "") || b.name === LEAD) {
for (const e of (b.name === LEAD ? b.edits : r0?.edits) ?? []) {
if (!files.has(e.path)) byAgent.add(e.path);
const f = files.get(e.path) ?? { added: 0, removed: 0, counted: false };
f.added += e.added;
f.removed += e.removed;
f.counted ||= e.counted;
files.set(e.path, f);
}
continue;
}
const input = (b.input ?? {}) as Record<string, unknown>;
const p = input.file_path ?? input.notebook_path;
const r = b.id ? toolResultMap.get(b.id) : undefined;
if (typeof p !== "string" || !r || r.is_error) continue;
if (b.name === "Write") created.add(p);
byAgent.delete(p);
const f = files.get(p) ?? { added: 0, removed: 0, counted: false };
f.added += r.diff?.added ?? 0;
f.removed += r.diff?.removed ?? 0;
f.counted ||= !!r.diff;
files.set(p, f);
}
const artifacts = [...files.keys()].filter((p) => isArtifact(p, created.has(p)));
const artifacts = [...files.keys()].filter(
(p) => !byAgent.has(p) && isArtifact(p, created.has(p))
);
const cards = artifacts.length > 0 && (
<div data-testid="artifact-cards" className="flex flex-col gap-2">
{artifacts.map((p) => (
@@ -773,6 +810,7 @@ function MessageListView({
sessionId,
agentLinks,
compact = false,
leadEdits,
}: MessageListProps) {
const { t } = useTranslation("sessions");
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
@@ -798,9 +836,10 @@ function MessageListView({
m.sender === "orchestrator" ? { ...m, sender: "user" as const } : m
)
: messages,
keys
keys,
leadEdits
),
[messages, keys, compact]
[messages, keys, compact, leadEdits]
);
const agents = useMemo(
() => (live ? backgroundAgents(messages, toolResultMap) : []),
@@ -4,7 +4,10 @@
* Desktop's "Edited N files" card: one row per file with the lines added and
* removed summed over the turn, a failed call left out, a row click handed
* to the caller, and a row (or the header, every file) dragged as `@path`
* attachments for a prompt box.
* attachments for a prompt box. Files a subagent changed (its Agent
* result's `edits`, read from its own transcript) are counted in the same
* card, never as an artifact; so are the files a turn changed before the
* loaded page began (`leadEdits`, the page's `turn_edits`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
@@ -127,4 +130,71 @@ describe("the Edited N files card", () => {
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
).toEqual(["@/w/a.ts", "@/w/b.json"]);
});
it("counts the files a subagent changed, beside the turn's own edits", () => {
render(
<MessageList
loading={false}
messages={[
user("run the task"),
call("1", "Edit", "/w/a.go"),
result("1", { added: 1, removed: 0 }),
{
type: "assistant",
sender: "assistant",
timestamp: at,
content: [{ type: "tool_use", id: "2", name: "Agent", input: { prompt: "do it" } }],
} as TranscriptMessage,
{
type: "user",
sender: "tool",
timestamp: at,
content: [
{
type: "tool_result",
id: "2",
output: "done",
agent: { id: "x1" },
edits: [
{ path: "/w/a.go", added: 4, removed: 2, counted: true },
{ path: "/w/b_test.go", added: 9, removed: 0, counted: true },
{ path: "/w/report.html", added: 5, removed: 0, counted: true },
],
},
],
} as TranscriptMessage,
user("thanks"),
]}
/>
);
const card = screen.getByTestId("edited-files");
expect(card.textContent).toContain("Edited 3 files");
expect(within(card).getByText("b_test.go")).toBeTruthy();
expect(within(card).getByTitle("/w/a.go").textContent).toContain("+5"); // a.go: 1 + 4
// A page only a subagent wrote is listed, not shown as an artifact card.
expect(within(card).getByText("report.html")).toBeTruthy();
expect(screen.queryByTestId("artifact-card")).toBeNull();
});
it("adds what the cut-off first turn changed before the page to that turn's card", () => {
render(
<MessageList
loading={false}
leadEdits={[{ path: "/w/rbac_test.go", added: 176, removed: 0, counted: true }]}
messages={[
call("1", "Write", "/w/handler_test.go"),
result("1", { added: 113, removed: 0 }),
user("what changed?"),
call("2", "Edit", "/w/x.go"),
result("2", { added: 1, removed: 0 }),
]}
/>
);
const [first, second] = screen.getAllByTestId("edited-files");
expect(first!.textContent).toContain("Edited 2 files");
expect(within(first!).getByText("rbac_test.go")).toBeTruthy();
expect(within(first!).getByText("handler_test.go")).toBeTruthy();
// The next turn's card is its own.
expect(second!.textContent).toContain("Edited 1 file");
});
});
+3 -2
View File
@@ -85,6 +85,7 @@ import {
type FsTrashItem,
type FsHome,
type FsPlace,
type GitSource,
isHomePlace,
} from "../../lib/api";
import type { Lane } from "../../lib/types";
@@ -283,7 +284,7 @@ export function FileBrowser({
/** A home to open on (`me`, or another user's for an admin). */
initialHome?: string | null;
session?: string | null;
source?: number | null;
source?: GitSource | null;
initialPath?: string;
/** Called after anything changed files, so a tree elsewhere can re-read. */
onChanged?: () => void;
@@ -1882,7 +1883,7 @@ export function FileExplorer({
onClose: () => void;
laneId: number;
session?: string | null;
source?: number | null;
source?: GitSource | null;
initialPath?: string;
onChanged?: () => void;
}) {
@@ -103,7 +103,7 @@ import {
Trash2,
UnfoldVertical,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { api, gitTarget, type GitSource } from "../../lib/api";
import { usePermissions } from "../AuthGate";
import { pasteToLane } from "../../lib/panePaste";
import { readMarkdownPreview } from "../../lib/markdownView";
@@ -827,7 +827,7 @@ export function LaneChangesPanel({
session?: string;
/** Show and act on that extra folder's repo instead (`lane_sources` id);
* wins over `session`, and is keyed on like it. */
source?: number;
source?: GitSource;
/** Open the Add-lane flow for a sibling worktree; see `LaneGitBar`. */
onNewLane?: (branch?: string) => void;
/** The panel has the whole width: the file tree becomes a sidebar and a
@@ -15,7 +15,7 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp } from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { api, gitTarget, type GitSource } from "../../lib/api";
import {
applyChoices,
parseConflicts,
@@ -47,7 +47,7 @@ export function LaneConflictView({
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
source?: GitSource;
path: string;
/** The file left the conflict (resolved, or a whole side taken). */
onResolved: () => void;
+2 -2
View File
@@ -38,7 +38,7 @@ import {
Trash2,
Upload,
} from "lucide-react";
import { api, gitTarget, type GitAuthNeeded } from "../../lib/api";
import { api, gitTarget, type GitSource, type GitAuthNeeded } from "../../lib/api";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
@@ -98,7 +98,7 @@ export function LaneGitBar({
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
source?: GitSource;
git: LaneGitFacts;
/** Rendered at the start of the action row: the header's branch line, so
* the facts and the buttons that act on them read as one row. */
@@ -19,7 +19,7 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { GitBranch } from "lucide-react";
import { LaneGitBar } from "./LaneGitBar";
import { api, gitTarget } from "../../lib/api";
import { api, gitTarget, type GitSource } from "../../lib/api";
import { keepIfSame } from "../../lib/keepIfSame";
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
@@ -37,8 +37,8 @@ export function useLaneGitFacts(
laneId: number,
/** Read that session's own worktree instead (`?session=`). */
session?: string,
/** Read that extra folder's repo instead (`?source=`). */
source?: number
/** Read that extra folder's repo (`?source=`, a number) or that inner repo (`?repo=`, a string) instead. */
source?: GitSource
): [LaneGitFacts | null, () => void] {
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
// Bumped by the git action bar: a commit or a pull changes every fact on
@@ -329,7 +329,7 @@ export function LaneGitRailCounts({
}: {
laneId: number;
session?: string;
source?: number;
source?: GitSource;
/** Opens the Changes panel from the file list's "… more files". */
onShowAll?: () => void;
}) {
@@ -349,7 +349,7 @@ export function LaneGitHeader({
/** Work in that session's own worktree (see `gitTarget`). */
session?: string;
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
source?: number;
source?: GitSource;
git: LaneGitFacts | null;
/** Called after a git bar action: the facts AND the panel's diff moved. */
onChanged: () => void;
@@ -3,7 +3,9 @@
* the pointer (right-click) or under the row's `⋯` button. Every item is an
* existing call: Rename (the row's inline field), New workspace here (the
* Add modal prefilled), Copy path, and Reset/Remove through `DestructiveLaneModal` — Reset only on a
* managed lane. The server's guards are the real ones; the menu only hides
* managed lane — plus Move to group (the sidebar's own groups,
* `lib/workspaceGroups.ts`: one of them, a new one, or out of its group). The
* server's guards are the real ones; the menu only hides
* what the lane's kind or tool makes meaningless. The shell (position,
* Escape / outside-click close) is `ContextMenu`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -11,7 +13,9 @@
import { useTranslation } from "react-i18next";
import type { Lane } from "../../lib/types";
import { Check } from "lucide-react";
import { ContextMenu, MenuItem, MenuSeparator } from "../ContextMenu";
import type { WorkspaceGroup } from "../../lib/workspaceGroups";
import { copyText } from "../../lib/clipboard";
export type WorkspaceMenuAction = "rename" | "new-here" | "reset" | "remove";
@@ -22,6 +26,9 @@ export function WorkspaceRowMenu({
y,
onClose,
onAction,
groups = [],
groupId = null,
onGroup,
}: {
lane: Lane;
x: number;
@@ -29,6 +36,11 @@ export function WorkspaceRowMenu({
onClose: () => void;
/** Items the tree itself carries out (rename field, modals). */
onAction: (action: WorkspaceMenuAction) => void;
/** The sidebar's groups and the one this workspace is in. */
groups?: WorkspaceGroup[];
groupId?: string | null;
/** Move it: to a group's id, out of its group (null), or into a new group ("new"). */
onGroup?: (target: string | null | "new") => void;
}) {
const { t } = useTranslation("lanes");
return (
@@ -43,6 +55,35 @@ export function WorkspaceRowMenu({
>
{t("menu.copyPath")}
</MenuItem>
{onGroup && (
<>
<MenuSeparator />
<div className="px-2 pb-0.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-fg-muted">
{t("menu.moveToGroup")}
</div>
{groups.map((g) => (
<MenuItem
key={g.id}
testId={`workspace-move-group-${g.id}`}
disabled={g.id === groupId}
onSelect={() => onGroup(g.id)}
>
<Check className={`h-3 w-3 shrink-0 ${g.id === groupId ? "" : "invisible"}`} />
<span className="truncate">{g.name}</span>
</MenuItem>
))}
<MenuItem testId="workspace-move-group-new" onSelect={() => onGroup("new")}>
<span className="w-3 shrink-0" />
{t("menu.newGroupWith")}
</MenuItem>
{groupId && (
<MenuItem testId="workspace-move-group-none" onSelect={() => onGroup(null)}>
<span className="w-3 shrink-0" />
{t("menu.removeFromGroup")}
</MenuItem>
)}
</>
)}
<MenuSeparator />
{lane.kind === "managed" && (
<MenuItem danger onSelect={() => onAction("reset")}>
@@ -69,7 +69,7 @@ const TOOLS: AiToolsView = {
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
tool("gemini", "Gemini"),
tool("aider", "Aider", { installed: false, path: null }),
tool("grok", "Grok", { enabled: false }),
tool("goose", "Goose", { enabled: false }),
],
};
+29 -17
View File
@@ -12,7 +12,8 @@
* 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 — so is a local file a browser on the dashboard's
* own machine drops or pastes with its `file://` path, never uploaded —
* own machine drops or pastes with its `file://` path, or pastes from a
* file manager's Copy its OS clipboard holds, 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`);
@@ -78,6 +79,8 @@ import {
localAttachment,
localCandidates,
sameFilesHere,
fileManagerPaths,
isClipboardBitmap,
SKILL_MIME,
rememberUploads,
} from "../../lib/promptAttach";
@@ -463,21 +466,26 @@ export function PromptEditor({
};
/** 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. */
* manager hands over `file://` paths beside the bytes) or its file manager
* holds them (a paste: its clipboard; a drop: its selection — no upload,
* no size limit), else uploaded. */
const attachDropped = (
dt: DataTransfer | null | undefined,
list: FileList | null | undefined
list: FileList | File[] | null | undefined,
from: "paste" | "drop"
) => {
const files = Array.from(list ?? []);
if (files.length === 0) return false;
const paths = localCandidates(dt, files);
if (!paths) return attachImages(files);
if (!isLocalBrowser()) return attachImages(files);
const candidates = localCandidates(dt, files);
setUpload("uploading");
void sameFilesHere(paths, files).then((same) => {
if (!same) return void attachImages(files);
void (async () => {
let paths = candidates && (await sameFilesHere(candidates, files)) ? candidates : null;
paths ??= await fileManagerPaths(files, from);
if (!paths) 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
@@ -518,7 +526,7 @@ export function PromptEditor({
} else if (skill) {
e.preventDefault();
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files)) e.preventDefault();
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files, "drop")) 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.
@@ -683,17 +691,21 @@ export function PromptEditor({
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
else if (pastedLocalFiles(e.clipboardData)) e.preventDefault();
else {
const pasted = Array.from(e.clipboardData?.files ?? []);
const text = e.clipboardData?.getData?.("text/plain") ?? "";
// Cells from a spreadsheet come with a picture of them: the text
// is what was copied (isClipboardBitmap).
const textWithPicture =
pasted.length > 0 && pasted.every(isClipboardBitmap) && !!text.trim();
if (!textWithPicture && attachDropped(e.clipboardData, pasted, "paste"))
e.preventDefault();
else if (!textWithPicture && pastedLocalFiles(e.clipboardData)) e.preventDefault();
else if (text.length > PASTE_AS_FILE_CHARS) {
// 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
// argument and a tmux message both have a size limit).
const text = e.clipboardData?.getData("text/plain") ?? "";
if (text.length > PASTE_AS_FILE_CHARS) {
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
}
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
}
}}
placeholder={placeholder}
+48 -8
View File
@@ -18,7 +18,14 @@
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* 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
* shows as `[Image #n]` like a native drop — except a PASTED image in a
* `claude` pane of a local-mode dashboard opened on its own machine, which is
* sent as its image-paste key (^V; Alt+V on Windows) so `claude` reads the
* desktop clipboard itself, uploading nothing — and a file manager's files
* pasted or dropped on that machine go in by the paths it holds them under
* (its clipboard / its selection: `POST /api/run/clipboard-files`,
* `selected-files`), with no upload and no size limit
* (`lib/nativePaste.ts`, a Settings switch); 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
@@ -69,16 +76,20 @@ import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "..
import {
claudePastes,
copiedFilePaths,
isClipboardBitmap,
droppedFilePaths,
fileManagerPaths,
isLocalBrowser,
localCandidates,
sameFilesHere,
} from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocal";
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocalPaste";
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
@@ -239,6 +250,12 @@ export const TerminalView = memo(function TerminalView({
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
const ctrlVRef = useRef(false);
const { local: localMode } = usePermissions();
// Ask early whether `^V` can reach this machine's clipboard, so the first
// pasted screenshot already takes the right way.
useEffect(() => {
if (tool === "claude" && localMode && isLocalBrowser()) nativePasteWorks();
}, [tool, localMode]);
const onDrop = (e: DragEvent) => {
if (hasText(e)) {
@@ -256,7 +273,7 @@ export const TerminalView = memo(function TerminalView({
e.preventDefault();
dragDepth.current = 0;
const files = Array.from(e.dataTransfer.files);
void attachFiles(files, localCandidates(e.dataTransfer, files));
void attachFiles(files, localCandidates(e.dataTransfer, files), "drop");
};
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
@@ -277,9 +294,27 @@ export const TerminalView = memo(function TerminalView({
return;
}
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
// Cells from a spreadsheet, a page's selection: text WITH a picture of
// it. A terminal pastes the text there, so does this.
if (files.every(isClipboardBitmap) && e.clipboardData?.getData("text/plain")?.trim()) return;
e.preventDefault();
e.stopPropagation();
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
// On the dashboard's own machine the browser's clipboard IS the pane's:
// `claude` reads a pasted image itself on ^V, as in a native terminal —
// nothing uploaded (lib/nativePaste.ts; off for an SSH tunnel's browser).
const nativeImage =
tool === "claude" &&
localMode &&
isLocalBrowser() &&
readNativePaste() &&
nativePasteWorks() &&
files.length > 0 &&
files.every(isClipboardBitmap);
if (nativeImage) {
termRef.current?.input(nativePasteKey(), true);
return;
}
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files), "paste");
else termRef.current?.input("\x16", true);
};
@@ -299,11 +334,15 @@ export const TerminalView = memo(function TerminalView({
/** 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) => {
* that machine's file manager holds them under (a paste: its clipboard; a
* drop: its selection, what the drag carries — no upload, no size limit,
* as a native terminal), else the paths the uploads were saved at. A
* refused file does not cost the others. */
const attachFiles = async (files: File[], local: string[] | null, from: "paste" | "drop") => {
if (files.length === 0) return;
setDrop("uploading");
let paths = local && (await sameFilesHere(local, files)) ? local : null;
paths ??= await fileManagerPaths(files, from);
const errors: unknown[] = [];
if (!paths) {
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
@@ -321,7 +360,8 @@ export const TerminalView = memo(function TerminalView({
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
// A terminal has no + menu: on this machine, a paste goes by path.
else flash(isLocalBrowser() ? "tooBigLocalPaste" : "tooBig", 6000); // a sentence to read
};
useEffect(() => {
@@ -939,7 +979,7 @@ 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" || drop === "tooBig" || drop === "tooBigLocal"
drop === "failed" || drop === "tooBig" || drop === "tooBigLocalPaste"
? "border-red-400 text-red-300"
: "border-sky-400 text-sky-200"
}`}
@@ -60,6 +60,8 @@ vi.mock("../../../lib/api", async (importOriginal) => {
files: r({ items: [] }),
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
localPaths: r({ sizes: [1] }),
clipboardFiles: r({ files: [] }),
selectedFiles: r({ files: [] }),
},
ccConfig: {
commands: r({
@@ -377,6 +379,35 @@ describe("PromptEditor — newlines and images", () => {
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
});
// Chrome on Windows pastes a file manager's copy as bytes only: the path
// comes off the OS clipboard, so a file over the upload limit attaches too.
it("attaches a pasted file manager copy by the path the OS clipboard holds", async () => {
vi.mocked(api.run.clipboardFiles).mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\big.iso", size: 3_000_000_000, dir: false }],
});
vi.mocked(api.run.dropFile).mockClear();
const onImagesChange = vi.fn();
render(
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={[]}
onImagesChange={onImagesChange}
/>
);
const iso = new File(["x"], "big.iso");
Object.defineProperty(iso, "size", { value: 3_000_000_000 });
fireEvent.paste(box(), { clipboardData: { files: [iso], getData: () => "" } });
await vi.waitFor(() => expect(onImagesChange).toHaveBeenCalled());
const update = onImagesChange.mock.calls[0]![0] as (p: unknown[]) => { path: string }[];
expect(update([]).map((a) => a.path)).toEqual(["@C:/Users/u/big.iso"]);
expect(api.run.dropFile).not.toHaveBeenCalled();
});
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();
@@ -10,9 +10,18 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
import {
render,
cleanup,
act,
createEvent,
fireEvent,
screen,
waitFor,
} from "@testing-library/react";
import { TerminalView, loadXterm } from "../TerminalView";
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
const writeMock = vi.fn();
const onDataHandlers: Array<(d: string) => void> = [];
@@ -27,11 +36,18 @@ const inputMock = vi.fn();
let textarea: HTMLTextAreaElement;
const dropFileMock = vi.fn();
const localPathsMock = vi.fn();
const nativePasteMock = vi.fn(() => Promise.resolve({ native: true }));
type Held = { files: { path: string; size: number | null; dir: boolean }[] };
const clipboardFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
const selectedFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
vi.mock("../../../lib/api", () => ({
api: {
run: {
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
localPaths: (paths: string[]) => localPathsMock(paths),
nativePaste: () => nativePasteMock(),
clipboardFiles: () => clipboardFilesMock(),
selectedFiles: () => selectedFilesMock(),
},
},
}));
@@ -590,6 +606,168 @@ describe("TerminalView", () => {
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
});
it("hands a local claude pane's pasted image to claude as ^V, uploading nothing", async () => {
__resetNativePaste();
dropFileMock.mockReset();
inputMock.mockClear();
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
expect(inputMock).toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
expect(pasteMock).not.toHaveBeenCalled();
});
// Claude Code on Windows binds its image paste to Alt+V; a ^V did nothing.
it("sends Alt+V instead of ^V when the dashboard runs on Windows", async () => {
__resetNativePaste();
nativePasteMock.mockResolvedValueOnce({ native: true, key: "alt+v" } as never);
dropFileMock.mockReset();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
expect(inputMock).toHaveBeenCalledWith("\x1bv", true);
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
});
// No wl-paste/xclip (or no display) on the dashboard's machine: ^V would
// paste nothing, so the screenshot is uploaded like a remote browser's.
it("uploads a claude pane's pasted image when the machine cannot read its clipboard", async () => {
__resetNativePaste();
nativePasteMock.mockResolvedValueOnce({ native: false });
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteMock).toHaveBeenCalled());
await Promise.resolve();
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
});
it("uploads a claude pane's pasted image once native paste is turned off", async () => {
localStorage.setItem("agenthub.terminal.nativePaste", "off");
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
inputMock.mockClear();
try {
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
} finally {
localStorage.removeItem("agenthub.terminal.nativePaste");
}
});
// A file manager's Copy reaches Chrome on Windows as bytes and a name only:
// the pane gets the file's own path off the OS clipboard — no upload, so
// no size limit (a 3 GB video too), as a native terminal pastes it.
it("pastes a copied file by the path the OS clipboard holds, uploading nothing", async () => {
__resetNativePaste();
dropFileMock.mockReset();
pasteMock.mockClear();
inputMock.mockClear();
clipboardFilesMock.mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\Videos\\demo.mp4", size: 3_000_000_000, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const video = new File([new Uint8Array([1])], "demo.mp4", { type: "video/mp4" });
Object.defineProperty(video, "size", { value: 3_000_000_000 });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [video], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/demo.mp4 "));
expect(dropFileMock).not.toHaveBeenCalled();
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
});
// Chrome hands a file dragged from Explorer no path; the drag carries the
// items selected there, so the pane gets their own paths — as a native
// terminal types them — and nothing is uploaded, whatever the size.
it("drops a file manager's file by the path selected there, uploading nothing", async () => {
dropFileMock.mockReset();
pasteMock.mockClear();
selectedFilesMock.mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\Videos\\big.mkv", size: 5_000_000_000, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const video = new File([new Uint8Array([1])], "big.mkv");
Object.defineProperty(video, "size", { value: 5_000_000_000 });
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { files: [video], types: ["Files"], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/big.mkv "));
expect(dropFileMock).not.toHaveBeenCalled();
});
it("uploads a dropped file the file manager does not hold selected", async () => {
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-notes.txt" });
selectedFilesMock.mockResolvedValueOnce({
files: [{ path: "/home/u/other.txt", size: 1, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="codex" />);
const notes = new File(["x"], "notes.txt");
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { files: [notes], types: ["Files"], getData: () => "" },
});
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(notes, "r1"));
});
// A copied image FILE is not a screenshot: claude's own paste finds no
// picture on a clipboard holding a file, so it goes by path too.
it("pastes a copied image file by path instead of claude's image paste", async () => {
__resetNativePaste();
dropFileMock.mockReset();
pasteMock.mockClear();
inputMock.mockClear();
clipboardFilesMock.mockResolvedValueOnce({
files: [{ path: "/home/u/Pictures/logo.png", size: 2, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const logo = new File([new Uint8Array([137, 80])], "logo.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [logo], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/home/u/Pictures/logo.png "));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
});
// Cells from a spreadsheet come with a picture of them; a terminal pastes
// the text, so the browser's paste is left to xterm.
it("leaves text that comes with a picture of it to xterm's paste", async () => {
__resetNativePaste();
dropFileMock.mockReset();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const pic = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
const ev = createEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [pic], getData: (t: string) => (t === "text/plain" ? "a\tb" : "") },
});
fireEvent(screen.getByTestId("terminal-view"), ev);
expect(ev.defaultPrevented).toBe(false);
expect(inputMock).not.toHaveBeenCalled();
expect(dropFileMock).not.toHaveBeenCalled();
});
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
// pasted like any browser paste: sending ^V for it pasted nothing.
@@ -4,7 +4,9 @@
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
* `#terminal-font`, with a preview line), and — local mode only — whether a
* pasted image reaches a `claude` pane natively (`lib/nativePaste.ts`,
* `#native-paste`, this browser only); **Sessions** (`#sessions-view`) —
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
* `#workspace-tabs`), and whether a Markdown file opens as Preview (the
@@ -39,6 +41,8 @@ import {
} from "../../lib/terminalFont";
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
import { readMarkdownPreview, writeMarkdownPreview } from "../../lib/markdownView";
import { readNativePaste, writeNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate";
import {
TERMINAL_FONT_SIZES,
UI_FONT_SIZES,
@@ -100,6 +104,8 @@ export function AppearanceSettings() {
const [uiSize, setUiSize] = useState(readUiFontSize);
const [allTabs, setAllTabs] = useState(readAllTabs);
const [mdPreview, setMdPreview] = useState(readMarkdownPreview);
const [nativePaste, setNativePaste] = useState(readNativePaste);
const { local } = usePermissions();
const pickTermFont = (font: TerminalFont) => {
writeTerminalFont(font);
setTermFont(font);
@@ -209,6 +215,18 @@ export function AppearanceSettings() {
>
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
</p>
{local && (
<SettingsSwitchRow
id="native-paste"
label={t("nativePaste.label")}
description={t("nativePaste.description")}
checked={nativePaste}
onChange={(on) => {
writeNativePaste(on);
setNativePaste(on);
}}
/>
)}
</SettingsSection>
<SettingsSection
@@ -0,0 +1,26 @@
/**
* @file ChangesPanelSettings — Settings → Git → Changes panel: whether a
* workspace folder that holds several git repositories shows each of them as
* its own section in the Changes panel (off by default: only the folder's own
* git). Writes the account preference in `lib/innerReposPref.ts`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useTranslation } from "react-i18next";
import { useInnerReposMode, writeInnerReposMode } from "../../lib/innerReposPref";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
export function ChangesPanelSettings() {
const { t } = useTranslation("settings");
const mode = useInnerReposMode();
return (
<SettingsSection title={t("changesPanel.title")} description={t("changesPanel.description")}>
<SettingsSwitchRow
label={t("changesPanel.innerRepos")}
description={t("changesPanel.innerReposDesc")}
checked={mode === "all"}
onChange={(on) => writeInnerReposMode(on ? "all" : "own")}
/>
</SettingsSection>
);
}
@@ -65,7 +65,7 @@ const VIEW: AiToolsView = {
tools: [
tool("claude", "Claude Code"),
tool("gemini", "Gemini"),
tool("grok", "Grok", { enabled: false }),
tool("goose", "Goose", { enabled: false }),
tool("aider", "Aider", { installed: false, path: null }),
],
};
@@ -155,9 +155,9 @@ describe("AiToolsSettings", () => {
await user.click(within(gemini).getByText("Set default"));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
// A disabled tool cannot become the default.
const grok = screen.getByTestId("ai-tool-grok");
expect(within(grok).getByText("Set default")).toBeDisabled();
expect(within(grok).getByRole("switch")).toHaveAttribute("aria-checked", "false");
const goose = screen.getByTestId("ai-tool-goose");
expect(within(goose).getByText("Set default")).toBeDisabled();
expect(within(goose).getByRole("switch")).toHaveAttribute("aria-checked", "false");
});
it("refreshes detection", async () => {
@@ -179,7 +179,7 @@ describe("AiToolsSettings", () => {
await user.click(screen.getByRole("menuitem", { name: "Disable" }));
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
// A disabled tool cannot become the default from the menu either.
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-grok") });
await user.pointer({ keys: "[MouseRight]", target: screen.getByTestId("ai-tool-goose") });
expect(screen.getByRole("menuitem", { name: "Set default" })).toBeDisabled();
});
});
@@ -301,11 +301,9 @@ describe("AiToolsSettings — install and update", () => {
describe("AiToolIcon", () => {
it("retries the icon when the same instance shows another tool", async () => {
const { AiToolIcon } = await import("../../AiToolIcon");
const { container, rerender } = render(
<AiToolIcon tool={{ id: "opencode", label: "OpenCode" }} />
);
const { container, rerender } = render(<AiToolIcon tool={{ id: "aider", label: "Aider" }} />);
fireEvent.error(container.querySelector("img")!);
expect(screen.getByText("O")).toBeInTheDocument();
expect(screen.getByText("A")).toBeInTheDocument();
rerender(<AiToolIcon tool={{ id: "cursor", label: "Cursor CLI" }} />);
expect(container.querySelector('img[src="/api/ai-tools/cursor/icon"]')).toBeTruthy();
});
@@ -0,0 +1,33 @@
/**
* @file ChangesPanelSettings.test.tsx
* @description Settings → Git → Changes panel: the inner-repositories switch
* starts off, turning it on stores `all` in the synced account preference,
* and turning it off again removes the key (back to the default).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ChangesPanelSettings } from "../ChangesPanelSettings";
import { INNER_REPOS_KEY, readInnerReposMode } from "../../../lib/innerReposPref";
describe("ChangesPanelSettings", () => {
beforeEach(() => localStorage.clear());
it("is off by default and toggles the account preference", () => {
render(<ChangesPanelSettings />);
const sw = screen.getByRole("switch", {
name: /Show the repositories inside the workspace folder/,
});
expect(sw).toHaveAttribute("aria-checked", "false");
expect(readInnerReposMode()).toBe("own");
fireEvent.click(sw);
expect(localStorage.getItem(INNER_REPOS_KEY)).toBe("all");
expect(sw).toHaveAttribute("aria-checked", "true");
fireEvent.click(sw);
expect(localStorage.getItem(INNER_REPOS_KEY)).toBeNull();
expect(readInnerReposMode()).toBe("own");
});
});
+28 -2
View File
@@ -34,7 +34,6 @@
"download": "Download",
"noPreview": "This file type cannot be shown here — download it to open it.",
"install": "Install",
"installs": "{{count}} installs",
"installed": "Installed to {{path}}",
"installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it",
"rename": "Edit",
@@ -197,5 +196,32 @@
"unowned.claimed": "Added to your library",
"unowned.declined": "Removed from your list",
"unowned.assigned": "Owner assigned",
"unowned.empty": "Nothing without an owner."
"unowned.empty": "Nothing without an owner.",
"installs_one": "{{count}} install",
"installs_other": "{{count}} installs",
"private": "Private",
"privateHint": "Not shared: only you (and admins) see it. Share puts it in the Hub.",
"showMore": "Show {{count}} more",
"view.label": "Layout",
"view.list": "List view",
"view.grid": "Grid view",
"pack.title": "{{prefix}}-* pack",
"pack.count_one": "{{count}} skill",
"pack.count_other": "{{count}} skills",
"pack.installAll": "Install all",
"pack.installRest": "Install {{count}} more",
"pack.installTitle": "Install {{count}} skills of {{prefix}}-*?",
"pack.installBody": "Each one is written into your own account. A skill you already have under the same name is left as it is:",
"pack.installed": "Installed {{ok}} · already there {{skipped}} · failed {{failed}}",
"section.unshared": "Not shared by their owners",
"section.unsharedHint": "Other users' private items — only admins see these",
"update": "Update",
"updateHint": "Its owner changed it since you installed it",
"updateTitle": "Update “{{title}}”?",
"updateBody": "Your copy of {{name}} is replaced by the newest version. The current one is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
"updated": "Updated {{name}}",
"pack.updateN": "Update {{count}}",
"pack.updateTitle": "Update {{count}} skills of {{prefix}}-*?",
"pack.updateBody": "Each copy is replaced by the newest version; the current ones are moved to ~/.claude/agenthub-replaced-skills/:",
"pack.updated": "Updated {{ok}} · failed {{failed}}"
}
+19 -1
View File
@@ -657,5 +657,23 @@
"fx.readOnly": "Read-only",
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
"tmuxUpgrade": "Upgrade tmux: {{advice}}"
"tmuxUpgrade": "Upgrade tmux: {{advice}}",
"changes.rescanRepos": "Scan for repositories again",
"tree.newGroup": "New group",
"tree.groupDefaultName": "New group",
"tree.ungrouped": "Ungrouped",
"tree.groupToggle": "Show or hide the {{name}} group",
"tree.groupMenu": "Group options",
"tree.groupRename": "Rename group",
"tree.groupDelete": "Delete group",
"tree.groupMoveUp": "Move group up",
"tree.groupMoveDown": "Move group down",
"tree.groupDeleteConfirm": "Delete the group \"{{name}}\"? Its workspaces stay, ungrouped.",
"tree.groupNameLabel": "Group name",
"menu.moveToGroup": "Move to group",
"menu.removeFromGroup": "Remove from group",
"menu.newGroupWith": "New group…",
"tree.groupAddWorkspace": "New workspace in this group…",
"tree.groupFoldAll": "Fold all groups",
"tree.groupUnfoldAll": "Unfold all groups"
}
+2 -1
View File
@@ -88,5 +88,6 @@
"now": "now",
"users": "Users",
"admin": "Administration",
"files": "Files"
"files": "Files",
"snapshot": "Snapshot"
}
+1
View File
@@ -73,6 +73,7 @@
"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",
"tooBigLocalPaste": "File too large to upload (max {{mb}} MB) — copy it in your file manager and paste it here instead (Ctrl+V): a pasted file goes in by its own path",
"remove": "Remove attachment",
"view": "View image"
},
+10
View File
@@ -1075,6 +1075,12 @@
"noMatch": "No setting matches “{{query}}”.",
"results": "Search results"
},
"changesPanel": {
"title": "Changes panel",
"description": "For a workspace folder that holds several git repositories (each with its own .git). Saved to your account.",
"innerRepos": "Show the repositories inside the workspace folder",
"innerReposDesc": "Each repository found inside the folder (up to two levels down) gets its own section in the Changes panel. Off: only the folder's own git is shown."
},
"worktrees": {
"title": "Worktree suggestions",
"description": "When you add a folder to a workspace, the repo's worktrees that have no workspace are suggested. Saved in this browser only.",
@@ -1134,6 +1140,10 @@
"18": "Larger"
}
},
"nativePaste": {
"label": "Paste images natively",
"description": "On this machine, an image pasted into a Claude pane is read by Claude from your clipboard, like in a normal terminal — nothing is uploaded. Needs wl-paste or xclip on Linux; without them the image is uploaded instead. Turn it off if you open the dashboard through an SSH tunnel: Claude would read the server's clipboard. This browser only."
},
"terminalFont": {
"title": "Terminal font",
"description": "The font and size of the workspace terminals. Open terminals change at once. Saved to your account.",
+25 -1
View File
@@ -197,5 +197,29 @@
"unowned.claimed": "Đã nhận về thư viện của bạn",
"unowned.declined": "Đã bỏ khỏi danh sách của bạn",
"unowned.assigned": "Đã gán chủ",
"unowned.empty": "Không có mục nào chưa có chủ."
"unowned.empty": "Không có mục nào chưa có chủ.",
"private": "Riêng tư",
"privateHint": "Chưa chia sẻ: chỉ bạn (và admin) thấy. Bấm Chia sẻ để đưa lên Hub.",
"showMore": "Xem thêm {{count}}",
"view.label": "Cách hiển thị",
"view.list": "Dạng danh sách",
"view.grid": "Dạng lưới",
"pack.title": "Bộ {{prefix}}-*",
"pack.count": "{{count}} skill",
"pack.installAll": "Cài tất cả",
"pack.installRest": "Cài {{count}} còn lại",
"pack.installTitle": "Cài {{count}} skill của bộ {{prefix}}-*?",
"pack.installBody": "Mỗi skill được ghi vào tài khoản của bạn. Skill nào bạn đã có cùng tên thì giữ nguyên:",
"pack.installed": "Đã cài {{ok}} · đã có sẵn {{skipped}} · lỗi {{failed}}",
"section.unshared": "Chủ chưa chia sẻ",
"section.unsharedHint": "Mục riêng tư của người khác — chỉ admin thấy",
"update": "Cập nhật",
"updateHint": "Người tạo đã sửa skill này từ khi bạn cài",
"updateTitle": "Cập nhật “{{title}}”?",
"updateBody": "Bản {{name}} của bạn sẽ được thay bằng phiên bản mới nhất. Bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/, không bị xóa.",
"updated": "Đã cập nhật {{name}}",
"pack.updateN": "Cập nhật {{count}}",
"pack.updateTitle": "Cập nhật {{count}} skill của bộ {{prefix}}-*?",
"pack.updateBody": "Mỗi bản sẽ được thay bằng phiên bản mới nhất; bản hiện tại được chuyển sang ~/.claude/agenthub-replaced-skills/:",
"pack.updated": "Đã cập nhật {{ok}} · lỗi {{failed}}"
}
+19 -1
View File
@@ -657,5 +657,23 @@
"fx.readOnly": "Chỉ đọc",
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}"
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}",
"changes.rescanRepos": "Quét lại các repository",
"tree.newGroup": "Nhóm mới",
"tree.groupDefaultName": "Nhóm mới",
"tree.ungrouped": "Chưa phân nhóm",
"tree.groupToggle": "Hiện hoặc ẩn nhóm {{name}}",
"tree.groupMenu": "Tuỳ chọn nhóm",
"tree.groupRename": "Đổi tên nhóm",
"tree.groupDelete": "Xoá nhóm",
"tree.groupMoveUp": "Đưa nhóm lên",
"tree.groupMoveDown": "Đưa nhóm xuống",
"tree.groupDeleteConfirm": "Xoá nhóm \"{{name}}\"? Các workspace trong nhóm vẫn giữ nguyên, chỉ không còn thuộc nhóm.",
"tree.groupNameLabel": "Tên nhóm",
"menu.moveToGroup": "Chuyển vào nhóm",
"menu.removeFromGroup": "Bỏ khỏi nhóm",
"menu.newGroupWith": "Nhóm mới…",
"tree.groupAddWorkspace": "Workspace mới trong nhóm này…",
"tree.groupFoldAll": "Thu gọn mọi nhóm",
"tree.groupUnfoldAll": "Mở rộng mọi nhóm"
}
+2 -1
View File
@@ -88,5 +88,6 @@
"now": "bây giờ",
"users": "Người dùng",
"admin": "Quản trị",
"files": "Quản lý file"
"files": "Quản lý file",
"snapshot": "Snapshot"
}
+1
View File
@@ -73,6 +73,7 @@
"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",
"tooBigLocalPaste": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy copy file trong trình quản lý file rồi dán vào đây (Ctrl+V): file được dán sẽ dùng đường dẫn gốc",
"remove": "Bỏ đính kèm",
"view": "Xem ảnh"
},
+10
View File
@@ -1063,6 +1063,12 @@
"noMatch": "Không có cài đặt nào khớp “{{query}}”.",
"results": "Kết quả tìm kiếm"
},
"changesPanel": {
"title": "Panel Changes",
"description": "Dành cho thư mục workspace chứa nhiều repo git (mỗi repo có .git riêng). Lưu theo tài khoản của bạn.",
"innerRepos": "Hiện các repo bên trong thư mục workspace",
"innerReposDesc": "Mỗi repo tìm thấy trong thư mục (sâu tối đa hai cấp) có một mục riêng trong panel Changes. Tắt: chỉ hiện git của chính thư mục."
},
"worktrees": {
"title": "Gợi ý worktree",
"description": "Khi thêm thư mục cho một workspace, các worktree của repo chưa có workspace sẽ được gợi ý. Chỉ lưu trong trình duyệt này.",
@@ -1122,6 +1128,10 @@
"18": "Rất lớn"
}
},
"nativePaste": {
"label": "Dán ảnh kiểu native",
"description": "Trên máy này, ảnh dán vào pane Claude được Claude tự đọc từ clipboard, như trong terminal thường — không upload gì. Trên Linux cần wl-paste hoặc xclip; thiếu thì ảnh được upload thay thế. Tắt nếu bạn mở dashboard qua SSH tunnel: khi đó Claude sẽ đọc clipboard của server. Chỉ áp dụng cho trình duyệt này."
},
"terminalFont": {
"title": "Font terminal",
"description": "Font và cỡ chữ của các terminal trong workspace. Terminal đang mở đổi ngay. Lưu vào tài khoản của bạn.",
@@ -0,0 +1,114 @@
/**
* @file workspaceGroups.test.ts
* @description Tests for the sidebar's workspace groups store: create, rename,
* move (by step or onto another group) and delete (which only ungroups), fold all, assignment, pruning of removed
* workspaces, a damaged stored value, and the arrangement the sidebar draws —
* groups in order, each keeping the drag order, ungrouped last.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, beforeEach } from "vitest";
import {
WORKSPACE_GROUPS_KEY,
arrangeByGroup,
assignGroup,
createGroup,
deleteGroup,
groupOf,
moveGroup,
moveGroupBefore,
pruneWorkspaceGroups,
readWorkspaceGroups,
renameGroup,
setAllFolded,
setGroupFolded,
} from "../workspaceGroups";
const lanes = (...ids: number[]) => ids.map((id) => ({ id }));
describe("workspaceGroups", () => {
beforeEach(() => localStorage.clear());
it("starts empty and survives a damaged value", () => {
expect(readWorkspaceGroups()).toEqual({ groups: [], of: {} });
localStorage.setItem(WORKSPACE_GROUPS_KEY, "{nope");
expect(readWorkspaceGroups()).toEqual({ groups: [], of: {} });
});
it("creates a group holding workspaces, renames, folds and deletes it", () => {
const id = createGroup(" Frontend ", [1, 2]);
let v = readWorkspaceGroups();
expect(v.groups).toEqual([{ id, name: "Frontend" }]);
expect(groupOf(v, 1)?.id).toBe(id);
renameGroup(id, " ");
expect(readWorkspaceGroups().groups[0]!.name).toBe("Frontend");
renameGroup(id, "Web");
setGroupFolded(id, true);
v = readWorkspaceGroups();
expect(v.groups[0]).toMatchObject({ name: "Web", folded: true });
deleteGroup(id);
v = readWorkspaceGroups();
expect(v.groups).toEqual([]);
expect(v.of).toEqual({});
});
it("moves a workspace between groups and out of them", () => {
const a = createGroup("A");
const b = createGroup("B");
assignGroup(5, a);
assignGroup(5, b);
expect(readWorkspaceGroups().of["5"]).toBe(b);
assignGroup(5, "missing");
expect(readWorkspaceGroups().of["5"]).toBeUndefined();
assignGroup(5, a);
assignGroup(5, null);
expect(readWorkspaceGroups().of["5"]).toBeUndefined();
});
it("reorders groups, clamped at both ends", () => {
const a = createGroup("A");
const b = createGroup("B");
moveGroup(a, 1);
expect(readWorkspaceGroups().groups.map((g) => g.id)).toEqual([b, a]);
moveGroup(a, 5);
expect(readWorkspaceGroups().groups.map((g) => g.id)).toEqual([b, a]);
});
it("forgets removed workspaces and ignores ids naming no group", () => {
const a = createGroup("A", [1, 2, 3]);
pruneWorkspaceGroups([1, 3]);
expect(readWorkspaceGroups().of).toEqual({ 1: a, 3: a });
localStorage.setItem(
WORKSPACE_GROUPS_KEY,
JSON.stringify({ groups: [{ id: a, name: "A" }], of: { 1: a, 2: "gone" } })
);
expect(readWorkspaceGroups().of).toEqual({ 1: a });
});
it("arranges groups in order, keeping the drag order, ungrouped last", () => {
const a = createGroup("A", [4, 2]);
const b = createGroup("B", [3]);
moveGroup(b, -1);
const v = readWorkspaceGroups();
expect(arrangeByGroup(lanes(1, 2, 3, 4, 5), v).map((l) => l.id)).toEqual([3, 2, 4, 1, 5]);
expect(groupOf(v, 2)?.id).toBe(a);
});
it("moves a group before another, or last, and folds every group at once", () => {
const a = createGroup("A");
const b = createGroup("B");
const c = createGroup("C");
const ids = () => readWorkspaceGroups().groups.map((g) => g.id);
moveGroupBefore(c, a);
expect(ids()).toEqual([c, a, b]);
moveGroupBefore(c, null);
expect(ids()).toEqual([a, b, c]);
moveGroupBefore(a, a);
moveGroupBefore("nope", b);
expect(ids()).toEqual([a, b, c]);
setAllFolded(true);
expect(readWorkspaceGroups().groups.every((g) => g.folded)).toBe(true);
setAllFolded(false);
expect(readWorkspaceGroups().groups.some((g) => g.folded)).toBe(false);
});
});
+65 -8
View File
@@ -895,11 +895,17 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
/**
* What a workspace git call works on: the workspace (its id) or, with
* `session`, that session's own worktree (`claude --worktree`), or, with
* `source`, one of the workspace's extra folders — the server resolves the
* tree from the session or `lane_sources` row (`?session=` / `?source=`),
* never from a path.
* `source`, one of the workspace's extra folders, or with `repo`, a repo
* inside the workspace folder — the server resolves the tree from the session
* or `lane_sources` row (`?session=` / `?source=`), or accepts `?repo=` only
* when its own walk of the folder found that repo; never from a path.
*/
export type GitTarget = number | { lane: number; session?: string; source?: number };
export type GitTarget = number | { lane: number; session?: string; source?: number; repo?: string };
/** Which extra repo a Changes section reads: an extra folder's `lane_sources`
* id (number), or a repo inside the workspace folder, by its path relative
* to it (string, from `GET /lanes/:id/repos`). */
export type GitSource = number | string;
/** `/lanes/<id>/git<sub>` plus `q` and the target's `session=` / `source=`. */
function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
@@ -907,6 +913,7 @@ function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
const lane = typeof target === "number" ? target : target.lane;
if (typeof target !== "number" && target.session) params.set("session", target.session);
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
if (typeof target !== "number" && target.repo) params.set("repo", target.repo);
const qs = params.toString();
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
}
@@ -1005,16 +1012,24 @@ export interface FsTrashItem {
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
export type FsConflict = "fail" | "skip" | "keep" | "replace";
/** The target for `laneId`: an extra folder's repo, else `session`'s worktree, else the workspace. */
/** The target for `laneId`: an extra folder's or an inner repo, else `session`'s worktree, else the workspace. */
export function gitTarget(
laneId: number,
session?: string | null,
source?: number | null
source?: GitSource | null
): GitTarget {
if (source) return { lane: laneId, source };
if (typeof source === "string" && source) return { lane: laneId, repo: source };
if (typeof source === "number" && source) return { lane: laneId, source };
return session ? { lane: laneId, session } : laneId;
}
/** A file the dashboard machine's file manager holds, by its own path. */
export interface FileManagerFile {
path: string;
size: number | null;
dir: boolean;
}
export const api = {
// ───────────────────────── Server mode login (routes/auth.js) ─────────────────────────
auth: {
@@ -2144,7 +2159,9 @@ export const api = {
*
* @returns {@link CostResult} — the aggregate cost breakdown across sessions.
*/
totalCost: (opts: { lane?: number; date?: string; tool?: string; range?: DateRange } = {}) => {
totalCost: (
opts: { lane?: number; date?: string; tool?: string; range?: DateRange; mine?: boolean } = {}
) => {
// Scope the aggregate to the active data-scope, exactly like the sessions /
// stats / analytics endpoints — otherwise switching the Data scope selector
// left the Dashboard "total cost" showing the un-narrowed global total.
@@ -2154,6 +2171,8 @@ export const api = {
// `date` (local YYYY-MM-DD): only that day's usage, breakdown included.
if (opts.date) qs.set("date", opts.date);
if (opts.tool) qs.set("tool", opts.tool);
// `mine`: only the caller's own sessions, even for an admin/analyst (server mode).
if (opts.mine) qs.set("mine", "1");
// `range`: total, breakdown and by_tool for those local days only.
applyRange(qs, opts.range);
applyScope(qs);
@@ -3135,6 +3154,13 @@ export const api = {
method: "POST",
body: JSON.stringify({ replace }),
}),
/** POST /api/artifacts/:id/install `{update: true}` — an installed skill's
* copies replaced by its newest version (the old ones moved aside). */
updateInstalled: (id: number) =>
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
method: "POST",
body: JSON.stringify({ update: true }),
}),
/** 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`, {
@@ -3335,6 +3361,27 @@ export const api = {
method: "POST",
body: JSON.stringify({ paths }),
}),
/** POST /api/run/clipboard-files — the files a file manager's Copy put on
* the dashboard machine's clipboard, by their own paths (`size` null for a
* folder); 403 EREMOTE unless local mode and a loopback browser. */
clipboardFiles: () =>
request<{ files: FileManagerFile[] }>("/run/clipboard-files", {
method: "POST",
body: "{}",
}),
/** POST /api/run/selected-files — the files selected in that machine's
* file manager (Explorer, Finder): what a drag from it carries. Same
* shape and refusal as `clipboardFiles`. */
selectedFiles: () =>
request<{ files: FileManagerFile[] }>("/run/selected-files", {
method: "POST",
body: "{}",
}),
/** GET /api/run/native-paste — whether a `claude` pane can read this
* machine's clipboard itself (a reader installed, a display reachable);
* false for a remote browser and in server mode. `key`: the chord
* `claude` reads an image with on that machine (Alt+V on Windows). */
nativePaste: () => request<{ native: boolean; key?: "ctrl+v" | "alt+v" }>("/run/native-paste"),
/** 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) }),
@@ -4174,6 +4221,13 @@ export const api = {
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
* folders and context files, and whether its tool can take them. */
sources: (id: number) => request<LaneSourcesView>(`/lanes/${id}/sources`),
/** GET /api/lanes/:id/repos — the git repos inside the workspace folder
* (a parent folder of several repos), each a Changes section read with
* `?repo=<path>`; `root` = the folder itself is in a repo. */
repos: (id: number, refresh = false) =>
request<{ root: boolean; items: { path: string; name: string }[]; truncated: boolean }>(
`/lanes/${id}/repos${refresh ? "?refresh=1" : ""}`
),
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
setInstructions: (id: number, instructions: string | null) =>
request<LaneSourcesView>(`/lanes/${id}/instructions`, {
@@ -4408,6 +4462,9 @@ export interface Artifact {
inLibrary: boolean;
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
installed?: boolean;
/** List rows only: an installed skill whose copy differs from its newest
* version (its owner changed it since) — the list offers Update. */
updateAvailable?: boolean;
/** file: captured when its session stopped, not pinned by hand. */
auto?: boolean;
/** Server mode: nobody owns it yet. */
+56
View File
@@ -0,0 +1,56 @@
/**
* @file innerReposPref — whether the Changes panel lists the git repositories
* INSIDE a workspace folder (a parent folder of several repos, one section
* each via `GET /api/lanes/:id/repos`) or only the folder's own git. Off by
* default ("own"): the panel neither walks the folder nor shows them until
* the account turns it on in Settings → Git → Changes panel. One account
* preference, `agenthub.changes.innerRepos` (`lib/userPrefs.ts`,
* `server/routes/me.js`), read through `useSyncExternalStore` and re-read
* when the account's preferences load (`PREFS_EVENT`) or another tab writes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useSyncExternalStore } from "react";
import { PREFS_EVENT } from "./workspaceGroups";
export const INNER_REPOS_KEY = "agenthub.changes.innerRepos";
export type InnerReposMode = "all" | "own";
const listeners = new Set<() => void>();
export function readInnerReposMode(): InnerReposMode {
try {
return localStorage.getItem(INNER_REPOS_KEY) === "all" ? "all" : "own";
} catch {
return "own";
}
}
export function writeInnerReposMode(mode: InnerReposMode): void {
try {
// The default is stored as no value, so an account that never chose keeps following it.
if (mode === "all") localStorage.setItem(INNER_REPOS_KEY, "all");
else localStorage.removeItem(INNER_REPOS_KEY);
} catch {
/* disabled storage: nothing to remember */
}
listeners.forEach((l) => l());
}
function subscribe(l: () => void) {
listeners.add(l);
const onStorage = (e: StorageEvent) => {
if (e.key === INNER_REPOS_KEY || e.key === null) l();
};
window.addEventListener("storage", onStorage);
window.addEventListener(PREFS_EVENT, l);
return () => {
listeners.delete(l);
window.removeEventListener("storage", onStorage);
window.removeEventListener(PREFS_EVENT, l);
};
}
export function useInnerReposMode(): InnerReposMode {
return useSyncExternalStore(subscribe, readInnerReposMode);
}
+71
View File
@@ -0,0 +1,71 @@
/**
* @file nativePaste — Settings → Appearance → "Paste images natively": a
* per-browser preference in `localStorage` (`agenthub.terminal.nativePaste`,
* deliberately NOT synced to the account — it is about this browser's
* machine). On (the default), a `claude` pane in a local-mode dashboard opened
* from this machine's own browser gets an image paste as `^V`, so `claude`
* reads the desktop clipboard itself like a native terminal — nothing is
* uploaded or copied. Off, the image is uploaded like a remote browser's. An
* SSH tunnel's browser also reaches the dashboard by loopback, and its `^V`
* would read the SERVER's clipboard — that is what turning it off is for.
* The server must also be able to read its clipboard (`nativePasteWorks`: on
* Linux `wl-paste`/`xclip` and a display) — else the `^V` pasted nothing.
* On Windows Claude Code binds its image paste to Alt+V, not Ctrl+V
* (`nativePasteKey`): a `^V` there was silently ignored.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { api } from "./api";
export const NATIVE_PASTE_KEY = "agenthub.terminal.nativePaste";
/** True unless this browser turned native image paste off. */
export function readNativePaste(): boolean {
try {
return window.localStorage.getItem(NATIVE_PASTE_KEY) !== "off";
} catch {
return true;
}
}
export function writeNativePaste(on: boolean) {
try {
if (on) window.localStorage.removeItem(NATIVE_PASTE_KEY);
else window.localStorage.setItem(NATIVE_PASTE_KEY, "off");
} catch {
/* storage blocked — the default */
}
}
let works: boolean | null = null;
let asked: Promise<void> | null = null;
// Claude Code's image-paste chord on the dashboard's machine: Ctrl+V, but
// Alt+V on Windows, where a ^V reaches no binding and pastes nothing.
let key = "\x16";
/** Whether the dashboard's machine can hand a pane its clipboard, as last
* answered — false until it has (an upload then, which always works). The
* first call asks the server once per page. */
export function nativePasteWorks(): boolean {
asked ??= api.run
.nativePaste()
.then((r) => {
works = r.native === true;
key = r.key === "alt+v" ? "\x1bv" : "\x16";
})
.catch(() => void (works = false));
return works === true;
}
/** The bytes that make the pane's `claude` read its clipboard: ^V, or ESC v
* (Alt+V) on a Windows dashboard. */
export function nativePasteKey(): string {
return key;
}
/** Test seam: forget the server's answer. */
export function __resetNativePaste() {
works = null;
asked = null;
key = "\x16";
}
+54 -3
View File
@@ -9,8 +9,10 @@
* dashboard's own machine attaches by path instead of uploading
* (`localCandidates` — a drag's uri-list, or the plain-text paths a pasted
* file-manager copy carries —, 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.
* `POST /api/run/local-paths` —, `localAttachment`), and the pasted or
* dropped files that machine's file manager holds (`fileManagerPaths`). An
* attachment is a path sent with the prompt; those checks are the calls here
* to the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -144,6 +146,53 @@ export async function sameFilesHere(paths: string[], files: File[]): Promise<boo
}
}
/** A pasted BITMAP (a screenshot), not a copied image FILE: browsers name the
* clipboard's picture `image.png`, while a file manager's copy keeps its own
* name. Only a bitmap goes to `claude`'s own image paste — a copied file is
* pasted by its path, as a native terminal does (`claude` finds no picture
* on a clipboard that holds a file) — and a bitmap that comes WITH text (a
* spreadsheet's cells, a page's selection) is the text's picture: a
* terminal pastes the text. */
export function isClipboardBitmap(f: File): boolean {
return f.type.startsWith("image/") && f.name === "image.png";
}
/** The pasted or dropped `files` by their own paths on the dashboard's
* machine, as its file manager holds them — a paste on its OS clipboard, a
* drop as its selection, which is what a drag from it carries
* (`POST /api/run/clipboard-files` / `selected-files`): a native terminal's
* paths, and the one way past the upload limit. Null when this is not a
* loopback browser, on a refusal (server mode), or when any file is not
* held under the same name and size (an SSH tunnel's browser pastes
* ANOTHER machine's files). Paths come back in `files`' order. */
export async function fileManagerPaths(
files: File[],
from: "paste" | "drop"
): Promise<string[] | null> {
if (!isLocalBrowser() || files.length === 0) return null;
try {
const { files: held } = await (from === "paste"
? api.run.clipboardFiles()
: api.run.selectedFiles());
const base = (p: string) =>
p
.replace(/[\\/]+$/, "")
.split(/[\\/]/)
.pop() ?? "";
const left = [...held];
const paths: string[] = [];
for (const f of files) {
const i = left.findIndex((h) => base(h.path) === f.name && (h.dir || h.size === f.size));
if (i < 0) return null;
paths.push(left[i]!.path.replace(/(.)[\\/]+$/, "$1"));
left.splice(i, 1);
}
return paths;
} catch {
return null;
}
}
/** 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`. */
@@ -169,9 +218,11 @@ export function droppedFilePaths(dt: DataTransfer | null | undefined): string[]
/** A file on this machine, by its absolute path, as an attachment. */
export function localAttachment(path: string): Attachment {
// A Windows path in `/` form, like `claudeMention` (Windows takes either).
const p = /^[A-Za-z]:\\/.test(path) ? path.replace(/\\/g, "/") : path;
return {
// A space would end the mention: Claude Code reads `@"…"` whole.
path: /\s/.test(path) ? `@"${path}"` : `@${path}`,
path: /\s/.test(p) ? `@"${p}"` : `@${p}`,
name:
path
.replace(/[\\/]$/, "")
+6
View File
@@ -375,6 +375,12 @@ export const SETTINGS_INDEX: {
},
{ section: "git", titleKey: "worktrees.claude", to: "#worktrees" },
{ section: "git", titleKey: "worktrees.agenthub", to: "#worktrees" },
{
section: "git",
titleKey: "changesPanel.innerRepos",
keywords: ["repositories", "nested", "monorepo", "changes"],
to: "#changes-panel",
},
];
function tier(hay: string | undefined, q: string, exact: number): number {
+12 -5
View File
@@ -732,10 +732,9 @@ 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), `"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`. */
* hooks), `"cursor"` (Cursor's agent transcripts, no tokens) or `"omp"`
* (its own session journals, with tokens and subagents). Rows an older build
* wrote may still say `"opencode"` or `"grok"`. 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.
@@ -1217,7 +1216,7 @@ export interface CostResult {
/** GET /api/pricing/quota: plan quota spent per native tool (server/lib/quota.js). */
export interface ToolQuota {
id: "claude" | "codex" | "antigravity" | "cursor" | "grok" | "kimi" | "opencode";
id: "claude" | "codex" | "antigravity" | "cursor";
/** One per plan window: `5h`, `wk`, `mo`, …; `usedPercent` is 0–100.
* `group` names the model pool a window belongs to (Antigravity's). */
windows: Array<{
@@ -3236,6 +3235,10 @@ export interface TranscriptContent {
/** Present on a Write/Edit/MultiEdit "tool_result": the lines it added and
* removed (from Claude Code's `structuredPatch`). */
diff?: { added: number; removed: number };
/** Present on an Agent/Task "tool_result" whose subagent (or one it started)
* wrote or edited files: each file with its +/- lines (`counted`: the
* results carried them). Read from the subagent's own transcript. */
edits?: { path: string; added: number; removed: number; counted: boolean }[];
/** Present on an AskUserQuestion "tool_result": question text → the option
* the user picked, or what they typed. */
answers?: Record<string, string>;
@@ -3320,6 +3323,10 @@ export interface TranscriptResult {
last_line: number;
/** JSONL line number of the first message in this page. */
first_line: number;
/** Claude only, when the page starts in the middle of a turn: the files
* that turn changed before the page's first message — the chat adds them
* to the turn's "Edited N files" card. */
turn_edits?: { path: string; added: number; removed: number; counted: boolean }[];
}
/** One entry in a session's transcript picker (main agent, a subagent, or a
+5
View File
@@ -20,6 +20,7 @@ import { api } from "./api";
import i18n from "../i18n";
import { applyStoredTheme } from "../hooks/useTheme";
import { FONT_SIZE_EVENT, applyUiFontSize } from "./fontSize";
import { PREFS_EVENT } from "./workspaceGroups";
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
export const PREF_KEYS = [
@@ -44,6 +45,8 @@ export const PREF_KEYS = [
"agenthub.tour",
"agenthub.workspace.allTabs",
"agenthub.markdown.view",
"agenthub.workspace.groups",
"agenthub.changes.innerRepos",
] as const;
const SYNCED = new Set<string>(PREF_KEYS);
const PUSH_DELAY_MS = 400;
@@ -116,6 +119,8 @@ function reapply(prefs: Record<string, string>) {
window.dispatchEvent(new Event("sagi:prefs"));
applyUiFontSize();
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
// Every other store that reads these keys (the sidebar's workspace groups).
window.dispatchEvent(new Event(PREFS_EVENT));
const lng = prefs.i18nextLng;
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
}
+221
View File
@@ -0,0 +1,221 @@
/**
* @file workspaceGroups — the sidebar's user-made workspace groups ("Frontend",
* "Client A", …): their names, order and fold state, and which group each
* workspace sits in. One JSON value under `agenthub.workspace.groups`, one of
* the account preference keys (`lib/userPrefs.ts`, `server/routes/me.js`), so
* the groups are kept per ACCOUNT on the server and follow it to any browser,
* machine or port, and through updates. Like the drag order, it is a viewer's
* arrangement, not a workspace fact: nothing about a lane changes, the CLI and
* `GET /api/lanes` know nothing of it, and deleting a group only ungroups its
* workspaces. A `useSyncExternalStore` reader cached on the raw string, so
* every reader agrees; it re-reads when the account's preferences are loaded
* (`PREFS_EVENT`) and when another tab writes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useSyncExternalStore } from "react";
export const WORKSPACE_GROUPS_KEY = "agenthub.workspace.groups";
/** Fired by `userPrefs.hydrate` once an account's values are in localStorage. */
export const PREFS_EVENT = "agenthub:prefs";
export interface WorkspaceGroup {
id: string;
name: string;
/** Folded shut in the sidebar. */
folded?: boolean;
}
export interface WorkspaceGroups {
/** In display order. */
groups: WorkspaceGroup[];
/** Lane id → group id. A lane absent here, or naming no group, is ungrouped. */
of: Record<string, string>;
}
const EMPTY: WorkspaceGroups = { groups: [], of: {} };
export const MAX_GROUP_NAME = 60;
const listeners = new Set<() => void>();
let cache: { raw: string | null; value: WorkspaceGroups } = { raw: null, value: EMPTY };
function readRaw(): string | null {
try {
return localStorage.getItem(WORKSPACE_GROUPS_KEY);
} catch {
return null;
}
}
function parse(raw: string | null): WorkspaceGroups {
if (!raw) return EMPTY;
try {
const v = JSON.parse(raw) as Partial<WorkspaceGroups>;
const groups = (Array.isArray(v.groups) ? v.groups : []).filter(
(g): g is WorkspaceGroup =>
!!g && typeof g.id === "string" && !!g.id && typeof g.name === "string"
);
const ids = new Set(groups.map((g) => g.id));
const of: Record<string, string> = {};
if (v.of && typeof v.of === "object") {
for (const [lane, gid] of Object.entries(v.of)) {
if (typeof gid === "string" && ids.has(gid)) of[lane] = gid;
}
}
return { groups, of };
} catch {
return EMPTY;
}
}
export function readWorkspaceGroups(): WorkspaceGroups {
const raw = readRaw();
if (raw === cache.raw) return cache.value;
cache = { raw, value: parse(raw) };
return cache.value;
}
function write(next: WorkspaceGroups): void {
try {
localStorage.setItem(WORKSPACE_GROUPS_KEY, JSON.stringify(next));
} catch {
/* disabled storage: the groups live until the page closes */
cache = { raw: JSON.stringify(next), value: next };
}
listeners.forEach((l) => l());
}
/** Change the groups through `fn` (given the current value, never mutated). */
export function updateWorkspaceGroups(fn: (cur: WorkspaceGroups) => WorkspaceGroups): void {
write(fn(readWorkspaceGroups()));
}
const cleanName = (name: string) => name.trim().slice(0, MAX_GROUP_NAME);
/** A new group at the end, holding `laneIds` (moved out of their old group). Returns its id. */
export function createGroup(name: string, laneIds: number[] = []): string {
const id = `g${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
updateWorkspaceGroups((cur) => {
const of = { ...cur.of };
for (const lane of laneIds) of[lane] = id;
return { groups: [...cur.groups, { id, name: cleanName(name) }], of };
});
return id;
}
/** Rename; a blank name keeps the old one. */
export function renameGroup(id: string, name: string): void {
const clean = cleanName(name);
if (!clean) return;
updateWorkspaceGroups((cur) => ({
...cur,
groups: cur.groups.map((g) => (g.id === id ? { ...g, name: clean } : g)),
}));
}
/** Delete the group; its workspaces become ungrouped (nothing else changes). */
export function deleteGroup(id: string): void {
updateWorkspaceGroups((cur) => ({
groups: cur.groups.filter((g) => g.id !== id),
of: Object.fromEntries(Object.entries(cur.of).filter(([, gid]) => gid !== id)),
}));
}
export function setGroupFolded(id: string, folded: boolean): void {
updateWorkspaceGroups((cur) => ({
...cur,
groups: cur.groups.map((g) => (g.id === id ? { ...g, folded } : g)),
}));
}
/** Move the group `by` places (−1 up, +1 down), clamped. */
export function moveGroup(id: string, by: number): void {
updateWorkspaceGroups((cur) => {
const from = cur.groups.findIndex((g) => g.id === id);
const to = Math.max(0, Math.min(cur.groups.length - 1, from + by));
if (from < 0 || from === to) return cur;
const groups = [...cur.groups];
groups.splice(to, 0, ...groups.splice(from, 1));
return { ...cur, groups };
});
}
/** Move the group to just before `beforeId` (a drag onto that header), or to the end (null). */
export function moveGroupBefore(id: string, beforeId: string | null): void {
updateWorkspaceGroups((cur) => {
const moving = cur.groups.find((g) => g.id === id);
if (!moving || id === beforeId) return cur;
const rest = cur.groups.filter((g) => g.id !== id);
const at = beforeId === null ? -1 : rest.findIndex((g) => g.id === beforeId);
const groups = at < 0 ? [...rest, moving] : [...rest.slice(0, at), moving, ...rest.slice(at)];
if (groups.every((g, i) => g === cur.groups[i])) return cur;
return { ...cur, groups };
});
}
/** Fold (or unfold) every group at once. */
export function setAllFolded(folded: boolean): void {
updateWorkspaceGroups((cur) => ({
...cur,
groups: cur.groups.map((g) => (!!g.folded === folded ? g : { ...g, folded })),
}));
}
/** Put a workspace in a group, or (null) take it out of every group. */
export function assignGroup(laneId: number, groupId: string | null): void {
updateWorkspaceGroups((cur) => {
const of = { ...cur.of };
if (groupId && cur.groups.some((g) => g.id === groupId)) of[laneId] = groupId;
else delete of[laneId];
return { ...cur, of };
});
}
/** Forget the lanes that no longer exist, so the stored value stays small. */
export function pruneWorkspaceGroups(laneIds: number[]): void {
const cur = readWorkspaceGroups();
const live = new Set(laneIds.map(String));
const stale = Object.keys(cur.of).filter((k) => !live.has(k));
if (!stale.length) return;
write({ ...cur, of: Object.fromEntries(Object.entries(cur.of).filter(([k]) => live.has(k))) });
}
/**
* `lanes` (already in the stored drag order) arranged by group: each group's
* workspaces in group order, keeping their relative order, then the
* ungrouped ones. Stable, never mutates.
*/
export function arrangeByGroup<T extends { id: number }>(lanes: T[], value: WorkspaceGroups): T[] {
const rank = new Map(value.groups.map((g, i) => [g.id, i]));
const at = (l: T) => {
const gid = value.of[l.id];
return gid !== undefined && rank.has(gid) ? rank.get(gid)! : value.groups.length;
};
return lanes
.map((l, i) => ({ l, i }))
.sort((a, b) => at(a.l) - at(b.l) || a.i - b.i)
.map((x) => x.l);
}
/** The group a workspace is in, or null. */
export function groupOf(value: WorkspaceGroups, laneId: number): WorkspaceGroup | null {
const gid = value.of[laneId];
return (gid && value.groups.find((g) => g.id === gid)) || null;
}
function subscribe(l: () => void) {
listeners.add(l);
const onStorage = (e: StorageEvent) => {
if (e.key === WORKSPACE_GROUPS_KEY || e.key === null) l();
};
window.addEventListener("storage", onStorage);
window.addEventListener(PREFS_EVENT, l);
return () => {
listeners.delete(l);
window.removeEventListener("storage", onStorage);
window.removeEventListener(PREFS_EVENT, l);
};
}
export function useWorkspaceGroups(): WorkspaceGroups {
return useSyncExternalStore(subscribe, readWorkspaceGroups);
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -73,6 +73,7 @@ import { ToolProfilesSettings } from "../components/settings/ToolProfilesSetting
import { OmpSharingSettings } from "../components/settings/OmpSharingSettings";
import { PricingSettings } from "../components/settings/PricingSettings";
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
import { ChangesPanelSettings } from "../components/settings/ChangesPanelSettings";
import { useAiTools } from "../hooks/useAiTools";
import {
rankSettings,
@@ -424,6 +425,9 @@ export function SettingsHub({ section }: { section?: string } = {}) {
<GitIdentitySettings />
</section>
)}
<div id="changes-panel" className="scroll-mt-24">
<ChangesPanelSettings />
</div>
<div id="worktrees" className="scroll-mt-24">
<WorktreeVisibilitySettings />
</div>
+111 -17
View File
@@ -69,8 +69,10 @@ import {
ChartColumn,
ChevronDown,
ChevronRight,
GitBranch,
} from "lucide-react";
import { api } from "../lib/api";
import { api, type GitSource } from "../lib/api";
import { useInnerReposMode } from "../lib/innerReposPref";
import { usePermissions } from "../components/AuthGate";
import type {
CwdSuggestion,
@@ -940,6 +942,43 @@ export function Workspace() {
);
}, [panelLaneId, takeGitFolders]);
const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
// The repos INSIDE the workspace folder (a parent folder of several repos):
// one section each, read with `?repo=` (`GET /lanes/:id/repos`, re-read
// with the panel's own refresh and every minute, so a clone shows up) —
// unless the account chose the folder's own git only (Settings → Git →
// Changes panel), which neither walks the folder nor shows them.
const innerMode = useInnerReposMode();
const [innerRepos, setInnerRepos] = useState<{
laneId: number;
root: boolean;
items: { path: string; name: string }[];
} | null>(null);
const loadInnerRepos = useCallback((laneId: number, refresh = false) => {
api.lanes.repos(laneId, refresh).then(
(v) =>
setInnerRepos((prev) =>
prev?.laneId === laneId &&
prev.root === v.root &&
prev.items.map((r) => r.path).join("\n") === v.items.map((r) => r.path).join("\n")
? prev
: { laneId, root: v.root, items: v.items }
),
() => setInnerRepos({ laneId, root: true, items: [] })
);
}, []);
useEffect(() => {
if (panelLaneId === null || innerMode === "own") return;
loadInnerRepos(panelLaneId);
const timer = setInterval(() => loadInnerRepos(panelLaneId, true), 60_000);
return () => clearInterval(timer);
}, [panelLaneId, loadInnerRepos, innerMode]);
const inner = innerMode === "all" && innerRepos?.laneId === panelLaneId ? innerRepos : null;
const repos = inner?.items ?? [];
// A folder that is no repo itself but holds some: its own section would only
// say "not a git repository", so the repos stand alone.
const showRoot = !inner || inner.root || repos.length === 0;
// A file the chat asks for lies in the inner repo whose path prefixes it.
const repoOf = (p: string) => repos.find((r) => p.startsWith(`${r.path}/`));
// Sections folded shut ("" = the workspace folder, else the folder's id).
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
// Right-click on a section: close extra folders — which REMOVES them from the
@@ -990,15 +1029,19 @@ export function Workspace() {
null
);
const panelLaneRef = useRef<number | null>(null);
const repoOfRef = useRef(repoOf);
repoOfRef.current = repoOf;
panelLaneRef.current = panelAt?.laneId ?? null;
useEffect(() => {
const onOpen = (e: Event) => {
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
if (laneId !== panelLaneRef.current) return;
// Its path is the workspace folder's: unfold that section.
// Its path is the workspace folder's: unfold that section (or the
// inner repo's that holds it).
const repo = repoOfRef.current(path);
setFoldedSources((prev) => {
const next = new Set(prev);
next.delete("");
next.delete(repo ? `repo:${repo.path}` : "");
return next;
});
setChangesOpen(true);
@@ -1612,9 +1655,43 @@ export function Workspace() {
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
{panelAt &&
[
{ key: "", source: undefined as number | undefined },
...folders.map((f) => ({ key: String(f.id), source: f.id })),
].map(({ key, source }, i) => {
...(showRoot
? [
{
key: "",
source: undefined as GitSource | undefined,
name:
currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() ||
currentLane.cwd,
root: undefined as string | undefined,
},
]
: []),
...repos.map((r) => ({
key: `repo:${r.path}`,
source: r.path as GitSource,
name: r.name,
root: `${currentLane.cwd.replace(/[\\/]+$/, "")}/${r.path}`,
})),
...folders.map((f) => ({
key: String(f.id),
source: f.id as GitSource,
name: f.name,
root: f.path,
})),
].map(({ key, source, name, root: dragRoot }, _i, sections) => {
// The chat's file request goes to the section holding it.
const req =
source === undefined
? changesRequest
: typeof source === "string" && changesRequest
? changesRequest.path.startsWith(`${source}/`)
? {
path: changesRequest.path.slice(source.length + 1),
nonce: changesRequest.nonce,
}
: null
: null;
const panel = (
<LaneChangesPanel
// A new tree is a new panel: no read for the previous
@@ -1627,8 +1704,8 @@ export function Workspace() {
full={changesFull}
wide={changesRatio(changesSize) >= 0.45}
onDirtyChange={dirtyFor(key)}
openRequest={source ? null : changesRequest}
filesChanged={source ? 0 : filesChanged}
openRequest={req}
filesChanged={source === undefined ? filesChanged : 0}
// A sibling worktree forks from the lane's SOURCE repo
// when it has one (this lane is itself a worktree of
// it); an adopted lane is its own repo.
@@ -1641,23 +1718,20 @@ export function Workspace() {
}}
/>
);
// No extra folders: the one panel, as it always was.
if (!folders.length) return panel;
// One repo only: the one panel, as it always was.
if (sections.length === 1) return panel;
// Else every repo is a section, stacked like VS Code's
// Source Control; the open ones share the height. A
// folded one stays mounted (CSS), so an unsaved edit in
// it survives.
const folded = foldedSources.has(key);
const name = source
? folders[i - 1]!.name
: currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd;
return (
<section
key={key}
data-testid="panel-git-source"
// A row dragged from an extra folder lies outside
// the pane's folder: `dragProjectPath` makes it absolute.
data-drag-root={source ? folders[i - 1]!.path : undefined}
data-drag-root={dragRoot}
className={`flex flex-col border-b border-border-light ${
folded ? "shrink-0" : "min-h-0 flex-1"
}`}
@@ -1680,7 +1754,12 @@ export function Workspace() {
) : (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
)}
<span className="truncate">{name}</span>
{typeof source === "string" && (
<GitBranch className="h-3 w-3 shrink-0 text-fg-muted" />
)}
<span className="truncate" title={dragRoot}>
{name}
</span>
</button>
<div className={folded ? "hidden" : "flex min-h-0 flex-1 flex-col"}>
{panel}
@@ -1695,7 +1774,18 @@ export function Workspace() {
onClose={sourceCtx.close}
testId="panel-git-source-menu"
>
{sourceCtx.menu.item !== "" && (
{innerMode === "all" && (
<MenuItem
testId="panel-git-source-rescan"
onSelect={() => {
sourceCtx.close();
loadInnerRepos(panelAt.laneId, true);
}}
>
{tLanes("changes.rescanRepos")}
</MenuItem>
)}
{/^\d+$/.test(sourceCtx.menu.item) && (
<MenuItem
testId="panel-git-source-close"
onSelect={() => {
@@ -1777,7 +1867,11 @@ export function Workspace() {
</span>
</button>
{panelAt &&
[undefined, ...folders.map((f) => f.id)].map((source) => (
[
...(showRoot ? [undefined] : []),
...repos.map((r): GitSource => r.path),
...folders.map((f): GitSource => f.id),
].map((source) => (
<LaneGitRailCounts
key={source ?? ""}
laneId={panelAt.laneId}
+82 -11
View File
@@ -13,6 +13,10 @@
* 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.
* Rows carry one primary action; Preview, Share and Edit live in the ⋯ menu.
* A section shows 30 entries before "Show more", and three or more skills of
* one owner sharing a name prefix are one pack row that installs them all.
* An installed skill whose owner changed it shows Update (after a confirmation).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -33,6 +37,7 @@ vi.mock("../../lib/api", () => ({
update: vi.fn(),
remove: vi.fn(),
install: vi.fn(),
updateInstalled: vi.fn(),
uninstall: vi.fn(),
installs: vi.fn(),
setInstallsEnabled: vi.fn(),
@@ -113,6 +118,16 @@ const addMenu = async (user: ReturnType<typeof userEvent.setup>, label: string)
await user.click(screen.getByRole("button", { name: label }));
};
/** Run one item of the first (or the given) row's ⋯ menu. */
async function menuAction(
user: ReturnType<typeof userEvent.setup>,
name: string,
scope: HTMLElement = document.body
) {
await user.click(within(scope).getAllByRole("button", { name: "More actions" })[0]!);
await user.click(within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name }));
}
describe("Adding", () => {
it("adds the plugins picked from a marketplace link", async () => {
const user = userEvent.setup();
@@ -218,7 +233,11 @@ describe("Artifacts", () => {
const user = userEvent.setup();
open();
expect(await screen.findByRole("button", { name: "Hub" })).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /^Share$/ }).length).toBe(2);
await user.click(screen.getAllByRole("button", { name: "More actions" })[0]!);
expect(
within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Share" })
).toBeInTheDocument();
await user.keyboard("{Escape}");
// One merged list by default; Hub is a filter over it.
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" }));
await user.click(screen.getByRole("button", { name: "Hub" }));
@@ -271,20 +290,46 @@ describe("Artifacts", () => {
expect(within(screen.getByTestId("half-mine.own")).getByText("my-page")).toBeInTheDocument();
});
it("pages a long section 12 cards at a time", async () => {
it("shows a long section 30 entries at a time, then the rest on Show more", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: Array.from({ length: 14 }, (_, i) =>
items: Array.from({ length: 34 }, (_, i) =>
item({ id: i + 1, kind: "page", title: `p${i + 1}`, files: [] })
),
});
const user = userEvent.setup();
open();
await screen.findByText("p1");
expect(screen.queryByText("p13")).toBeNull();
expect(screen.getByText("Showing 1-12 of 14")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("p13")).toBeInTheDocument();
expect(screen.queryByText("p1")).toBeNull();
expect(screen.getByText("p30")).toBeInTheDocument();
expect(screen.queryByText("p31")).toBeNull();
await user.click(screen.getByRole("button", { name: "Show 4 more" }));
expect(screen.getByText("p34")).toBeInTheDocument();
expect(screen.getByText("p1")).toBeInTheDocument();
});
it("folds three skills sharing a name prefix into one pack that installs them all", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({ id: 1, kind: "skill", title: "sg-qa", source: "sg-qa" }),
item({ id: 2, kind: "skill", title: "sg-ship", source: "sg-ship", installed: true }),
item({ id: 3, kind: "skill", title: "sg-fix", source: "sg-fix" }),
item({ id: 4, kind: "skill", title: "lone", source: "lone" }),
],
});
vi.mocked(api.artifacts.install).mockResolvedValue({ installed: "/h/x" } as never);
const user = userEvent.setup();
open();
const pack = await screen.findByTestId("pack-sg");
expect(within(pack).getByText("sg-* pack")).toBeInTheDocument();
expect(within(pack).getByText("3 skills")).toBeInTheDocument();
expect(screen.queryByTestId("artifact-1")).toBeNull();
expect(screen.getByTestId("artifact-4")).toBeInTheDocument();
await user.click(within(pack).getByRole("button", { expanded: false }));
expect(within(pack).getByTestId("artifact-1")).toBeInTheDocument();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(within(pack).getByRole("button", { name: "Install 2 more" }));
await waitFor(() => expect(api.artifacts.install).toHaveBeenCalledTimes(2));
expect(api.artifacts.install).toHaveBeenCalledWith(1);
expect(api.artifacts.install).toHaveBeenCalledWith(3);
});
it("lists who installed an artifact, when and where, from its install count", async () => {
@@ -333,12 +378,38 @@ describe("Artifacts", () => {
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 menuAction(user, "Remove from Hub", card);
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("offers Update for an installed skill its owner changed, after a confirmation", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({
id: 2,
kind: "skill",
title: "deploy",
source: "deploy",
installed: true,
updateAvailable: true,
}),
],
});
vi.mocked(api.artifacts.updateInstalled).mockResolvedValue({ installed: "/h/x" });
const user = userEvent.setup();
open();
const card = await screen.findByTestId("artifact-2");
expect(within(card).queryByTestId("artifact-installed")).toBeNull();
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(within(card).getByRole("button", { name: "Update" }));
expect(api.artifacts.updateInstalled).not.toHaveBeenCalled();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(within(card).getByRole("button", { name: "Update" }));
await waitFor(() => expect(api.artifacts.updateInstalled).toHaveBeenCalledWith(2));
});
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 })],
@@ -436,7 +507,7 @@ describe("Artifacts", () => {
const user = userEvent.setup();
open();
await screen.findByText("standup");
await user.click(screen.getByRole("button", { name: "Preview" }));
await menuAction(user, "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();
@@ -464,7 +535,7 @@ describe("Artifacts", () => {
const user = userEvent.setup();
open();
await screen.findByText("standup");
await user.click(screen.getByRole("button", { name: "Edit" }));
await menuAction(user, "Edit");
const name = await screen.findByLabelText(/Skill name/);
expect(name).toHaveValue("standup");
expect(screen.getByLabelText(/Instructions/)).toHaveValue("Old body");
@@ -101,6 +101,8 @@ vi.mock("../../lib/api", async (importOriginal) => {
: [],
})
),
// The repos inside the workspace folder (`?repo=`): none by default.
repos: vi.fn().mockResolvedValue({ root: true, items: [], truncated: false }),
// Closing a folder's section removes it from the workspace.
removeSource: r({
sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }],
@@ -330,6 +332,79 @@ describe("Workspace — the Changes panel's width", () => {
expect(screen.queryByTestId("panel-git-source")).toBeNull();
});
it("neither walks a parent folder nor shows its repos until the account turns it on", async () => {
vi.mocked(api.lanes.repos).mockClear();
vi.mocked(api.lanes.repos).mockResolvedValue({
root: false,
items: [{ path: "api", name: "api" }],
truncated: false,
});
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
<Workspace />
</MemoryRouter>
);
await settle();
const rail = screen.queryByTestId("lane-changes-expand");
if (rail) {
fireEvent.click(rail);
await settle();
}
expect(api.lanes.repos).not.toHaveBeenCalled();
expect(screen.queryByTestId("panel-git-source")).toBeNull();
vi.mocked(api.lanes.repos).mockResolvedValue({ root: true, items: [], truncated: false });
});
it("gives each repo inside a parent folder its own section, without the folder's own", async () => {
// Settings → Git → Changes panel: show the repositories inside.
window.localStorage.setItem("agenthub.changes.innerRepos", "all");
vi.mocked(api.lanes.repos).mockResolvedValue({
root: false,
items: [
{ path: "api", name: "api" },
{ path: "apps/web", name: "apps/web" },
],
truncated: false,
});
vi.mocked(api.lanes.git).mockClear();
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
<Workspace />
</MemoryRouter>
);
await settle();
const rail = screen.queryByTestId("lane-changes-expand");
if (rail) {
fireEvent.click(rail);
await settle();
}
const sections = screen.getAllByTestId("panel-git-source");
expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual([
"api",
"apps/web",
]);
expect(api.lanes.git).toHaveBeenCalledWith({ lane: 2, repo: "apps/web" });
// An inner repo is the folder's own: its menu rescans, never closes it.
fireEvent.contextMenu(sections[1]!.querySelector("button")!);
expect(screen.queryByTestId("panel-git-source-close")).toBeNull();
fireEvent.click(screen.getByTestId("panel-git-source-rescan"));
expect(api.lanes.repos).toHaveBeenLastCalledWith(2, true);
// A chat's file inside an inner repo opens in that repo's section.
fireEvent.click(sections[1]!.querySelector("button")!);
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "false");
act(() => {
openInChanges(2, "/workspace/beta", "/workspace/beta/apps/web/src/x.ts");
});
await settle();
expect(sections[1]!.querySelector("button")).toHaveAttribute("aria-expanded", "true");
expect(api.lanes.gitFile).toHaveBeenCalledWith(
{ lane: 2, repo: "apps/web" },
"src/x.ts",
"work"
);
vi.mocked(api.lanes.repos).mockResolvedValue({ root: true, items: [], truncated: false });
});
it("is one plain panel for a workspace with no extra folders", async () => {
render(
<MemoryRouter initialEntries={["/workspace?lane=2"]}>
@@ -123,6 +123,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
lanes: {
// The Changes panel's folder picker reads the workspace's extra folders.
sources: vi.fn().mockResolvedValue({ sources: [] }),
repos: vi.fn().mockResolvedValue({ root: true, items: [], truncated: false }),
list: vi.fn().mockImplementation(async () => {
recordCall("GET", "/api/lanes");
return { lanes: lanesToReturn, counts: countsToReturn };
@@ -7244,7 +7244,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.21 (rebuild client to match)
UI build v2.8.25 (rebuild client to match)
</span>
</p>
</div>
+42 -9
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -513,7 +513,7 @@ Managed through the `/api/remote-sources/*` routes; sync/status changes are broa
### token_usage_hourly
The same buckets as `token_usage` (session, model, speed, inference_geo, service_tier, the eight count columns and the five `long_*_tokens` subsets, no baselines), split by `hour` — the UTC hour `YYYY-MM-DDTHH` a Claude transcript message was written in. The hook writer fills it from `TranscriptCache`'s `tokensByHour` on every transcript-bearing event (`writeHourly` in `server/lib/hourly-usage.js`, `stmts.upsertHourlyUsage`, MAX per column so a rewritten transcript never takes an hour back), and the server back-fills sessions that have tokens but no hourly rows a few seconds after each start (`backfillHourly`, one transcript per tick). Only the daily split of `GET /api/pricing/cost` reads it; `token_usage` stays the total, and what it holds beyond these rows is booked to the session's start date. Cascades with its session.
The same buckets as `token_usage` (session, model, speed, inference_geo, service_tier, the eight count columns and the five `long_*_tokens` subsets, no baselines), split by `hour` — the UTC hour `YYYY-MM-DDTHH` a Claude transcript message was written in. The hook writer fills it from `TranscriptCache`'s `tokensByHour` on every transcript-bearing event — the session's own transcript plus its sub-agents' (`<session>/subagents/agent-*.jsonl`, `server/lib/subagent-usage.js`; the same sum goes into `token_usage`, so a sub-agent on the session's own model is counted too; Workflow agents stay under their own `workflow` tier) — (`writeHourly` in `server/lib/hourly-usage.js`, `stmts.upsertHourlyUsage`, MAX per column so a rewritten transcript never takes an hour back), and the server back-fills sessions that have tokens but no hourly rows a few seconds after each start (`backfillHourly`, one transcript per tick). Only the daily split of `GET /api/pricing/cost` reads it; `token_usage` stays the total, and what it holds beyond these rows is booked to the session's start date. Cascades with its session.
---
-1
View File
@@ -260,7 +260,6 @@ DASHBOARD_ANTIGRAVITY_POLL_MS=15000 # agy conversation files → Antigravity
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)
+3 -2
View File
File diff suppressed because one or more lines are too long
+4 -7
View File
@@ -310,13 +310,10 @@ per model and one agent per subagent the `task` tool spawned (its journal sits i
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>`).
Cursor sessions resume by id too (`--resume <id>`), and open as a chat like
omp ones. OpenCode and Grok Build are no longer AgentHub tools: a Grok Build
running the Claude hooks in `~/.claude/settings.json` is ignored, and sessions an
older build recorded for either stay in the history.
**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
+2 -1
View File
@@ -160,7 +160,8 @@ Workspaces made before server mode, or by the host account, have none.
An account's last login is kept in `users.last_login_at`, so it still shows
after the login itself has ended.
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
sessions, and the tokens and $ it used today (the browser's local day) and in the last 7 and 30 days — the
sessions, and the tokens and $ it used today (the browser's local day) and in the last 7 and 30 days
(booked like the cost routes' daily split, so Codex/omp/Antigravity and sub-agent usage count) — the
**Administration** page in the sidebar (`/admin`; the old `/users` link
redirects there) shows it on its **Users** tab, with the audit log under it.
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's
+1 -1
View File
@@ -13,7 +13,7 @@ Opus 5.5 · main · ███░░░░░░░ 34% · $1.23 · today $45.68
| branch | `git symbolic-ref --short HEAD` in the session's cwd (short sha when detached; 500 ms cap) |
| context bar | `context_window.used_percentage`, else `current_usage` (input + cache) ÷ `context_window_size`; green < 50 %, yellow < 80 %, red above |
| `$1.23` | this session, Claude Code's own `cost.total_cost_usd` |
| `today $…` | every session today, from the dashboard: `GET /api/pricing/cost?tz_offset=<local>` → today's `daily_costs` row |
| `today $…` | your sessions today, from the dashboard: `GET /api/pricing/cost?tz_offset=<local>&mine=1` → today's `daily_costs` row (in server mode only the pane's workspace owner's sessions, even for an admin; locally every session) |
Today's cost has a **1-second timeout** and is cached for 30 s in
`<os tmpdir>/agenthub-statusline-<uid>.json` — a failed fetch is cached too, so a
+314 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3
info:
title: Agent Dashboard for Claude Code API
version: 2.8.20
version: 2.8.24
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact:
name: Nguyễn Ngọc Trí Vĩ
@@ -12039,6 +12039,11 @@ paths:
required: false
schema:
type: integer
- name: repo
in: query
required: false
schema:
type: string
responses:
'200':
description: The file's bytes, as an attachment.
@@ -12082,6 +12087,11 @@ paths:
required: false
schema:
type: integer
- name: repo
in: query
required: false
schema:
type: string
requestBody:
required: true
content:
@@ -12143,6 +12153,11 @@ paths:
required: false
schema:
type: integer
- name: repo
in: query
required: false
schema:
type: string
responses:
'200':
description: The suggested message
@@ -14704,6 +14719,14 @@ paths:
type: integer
sessions:
type: integer
today:
type: object
description: Spent since the viewer's local midnight (`tz_offset`).
properties:
cost:
type: number
tokens:
type: integer
last7d:
type: object
properties:
@@ -17052,8 +17075,10 @@ paths:
schema:
type: string
enum:
- all
- mine
- hub
- unowned
- name: kind
in: query
schema:
@@ -18286,6 +18311,49 @@ paths:
description: ENOLANE — workspace not found.
'409':
description: ELIVE (a pane holds the session), ELOCKED, or EGIT (git refused, e.g. a dirty tree).
/api/lanes/{id}/repos:
get:
tags:
- Lanes
summary: The git repositories inside a workspace folder
description: 'For a parent folder holding several repos (server/lib/nested-repos.js): every directory up to 2 levels below the workspace folder that holds a `.git` entry, never through a symlink, a dot folder or `node_modules`, and never inside a repo already found; at most 50 repos and 5000 directory entries read. Disk reads only (no git call), cached 30 s; `?refresh=1` reads again. Each `path` (relative, `/`-separated) is what the /git and /fs routes take as `?repo=` — a value this walk did not produce answers 404 ENOREPO. `root` says whether the workspace folder itself is in a repo. A missing folder is an empty list.'
operationId: getLaneRepos
parameters:
- name: id
in: path
required: true
schema:
type: integer
- name: refresh
in: query
required: false
schema:
type: string
enum:
- '1'
responses:
'200':
description: '{root, items: [{path, name}], truncated}.'
content:
application/json:
schema:
type: object
properties:
root:
type: boolean
items:
type: array
items:
type: object
properties:
path:
type: string
name:
type: string
truncated:
type: boolean
'404':
description: ENOLANE — workspace not found.
/api/lanes/{id}/sources:
get:
tags:
@@ -20258,6 +20326,94 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/run/clipboard-files:
post:
tags:
- Run
summary: The files a file manager's Copy put on this machine's clipboard
operationId: runClipboardFiles
description: 'For a browser on this machine: the files the OS clipboard holds (Windows GetFileDropList, macOS NSPasteboard file URLs, Linux text/uri-list via wl-paste/xclip), each that exists here with its size (`null` for a folder), at most 100 — so a pasted copy is attached by its own path, with no upload and no size limit. The client uses a path only when name and size match the pasted file. 403 EREMOTE in server mode or for any non-loopback (or proxied) request.'
responses:
'200':
description: The clipboard's files; empty when it holds none or no reader is installed.
content:
application/json:
schema:
type: object
properties:
files:
type: array
items:
type: object
properties:
path:
type: string
size:
type: integer
nullable: true
dir:
type: boolean
'403':
description: EREMOTE.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/run/selected-files:
post:
tags:
- Run
summary: The files selected in this machine's file manager (what a drag carries)
operationId: runSelectedFiles
description: 'For a browser on this machine: the items selected in Explorer (every window, and the desktop) or Finder — a drag from the file manager carries exactly those — each that exists here with its size (`null` for a folder), at most 100, so a dropped file is attached by its own path with no upload and no size limit. Linux file managers publish no selection: always empty there. Nothing is searched for on disk. 403 EREMOTE in server mode or for any non-loopback (or proxied) request.'
responses:
'200':
description: The selected files; empty when none or no reader is available.
content:
application/json:
schema:
type: object
properties:
files:
type: array
items:
type: object
properties:
path:
type: string
size:
type: integer
nullable: true
dir:
type: boolean
'403':
description: EREMOTE.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/run/native-paste:
get:
tags:
- Run
summary: Whether a claude pane can read this machine's clipboard itself
operationId: runNativePaste
description: '`native`: local mode, a loopback request, and on Linux a reachable display plus `wl-paste` or `xclip` — then the terminal hands a pasted screenshot to `claude` as its image-paste key instead of uploading it. `key`: that chord on this machine — `alt+v` on Windows, `ctrl+v` elsewhere (Claude Code''s own bindings).'
responses:
'200':
description: The answer.
content:
application/json:
schema:
type: object
properties:
native:
type: boolean
key:
type: string
enum:
- ctrl+v
- alt+v
/api/run/interrupted:
get:
tags:
@@ -22135,6 +22291,163 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/artifacts/{id}/claim:
post:
tags:
- Artifacts
summary: Take an unowned artifact
operationId: claimArtifact
description: Server mode. The user the row is suggested to (the owner of the workspace or home its session ran in), or an admin, becomes its owner. Audited. Same-origin only.
parameters:
- name: id
in: path
required: true
description: Artifact id.
schema:
type: string
responses:
'200':
description: Claimed.
content:
application/json:
schema:
type: object
properties:
item:
type: object
'400':
description: ELOCALMODE.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: 'EFORBIDDEN: not the suggested user.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOTFOUND.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: 'EOWNED: it already has an owner.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/artifacts/{id}/decline:
post:
tags:
- Artifacts
summary: Say an unowned artifact offered to you is not yours
operationId: declineArtifact
description: Server mode. Drops the suggestion; the row stays unowned for an admin to place. Audited. Same-origin only.
parameters:
- name: id
in: path
required: true
description: Artifact id.
schema:
type: string
responses:
'200':
description: Declined.
content:
application/json:
schema:
type: object
properties:
id:
type: integer
declined:
type: boolean
'400':
description: ELOCALMODE.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: 'EFORBIDDEN: not the suggested user.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOTFOUND.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: EOWNED.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/artifacts/{id}/assign:
post:
tags:
- Artifacts
summary: Give an unowned artifact to a user (admin)
operationId: assignArtifact
description: Server mode, admin only. Body `{userId}` names an enabled user. Audited. Same-origin only.
parameters:
- name: id
in: path
required: true
description: Artifact id.
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- userId
properties:
userId:
type: integer
responses:
'200':
description: Assigned.
content:
application/json:
schema:
type: object
properties:
item:
type: object
'400':
description: ELOCALMODE / EBADFIELD.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: 'EFORBIDDEN: not an admin.'
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: ENOTFOUND.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: EOWNED.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/settings/import:
post:
tags:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.21",
"version": "2.8.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.21",
"version": "2.8.25",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.21",
"version": "2.8.25",
"bin": {
"agenthub": "bin/agenthub.js"
},
+19 -20
View File
@@ -1538,26 +1538,22 @@ function importSession(dbModule, session) {
backfilled = true;
}
// Reconcile token usage. The earlier importer dropped subagent tokens
// entirely, so any session with subagent JSONLs has under-counted totals.
// replaceTokenUsage's baseline-shift logic guarantees this can never
// reduce a session's totals — at worst it's a no-op.
if (
session.parsedSubagents &&
session.parsedSubagents.some(
(s) =>
s.tokensByModel &&
Object.values(s.tokensByModel).some(
(t) => (t.input || 0) + (t.output || 0) + (t.cacheRead || 0) + (t.cacheWrite || 0) > 0
)
)
) {
const written = writeSessionTokens(
dbModule,
session.sessionId,
combineSessionTokens(session)
// Reconcile token usage on every pass. A session first imported while its
// transcript was still being written (a `claude -p` run with no hooks is
// seen the moment its file appears) was stored with the usage of that
// moment — often none — and its sub-agents' tokens arrive later still.
// replaceTokenUsage keeps the highest total ever written, so this can only
// raise a bucket, never inflate it; it counts as a backfill only when the
// session's total actually grew.
{
const totalOf = db.prepare(
`SELECT COALESCE(SUM(input_tokens + baseline_input + output_tokens + baseline_output
+ cache_read_tokens + baseline_cache_read + cache_write_tokens + baseline_cache_write), 0) AS n
FROM token_usage WHERE session_id = ?`
);
if (written > 0) backfilled = true;
const before = totalOf.get(session.sessionId).n;
writeSessionTokens(dbModule, session.sessionId, combineSessionTokens(session));
if (totalOf.get(session.sessionId).n > before) backfilled = true;
}
return backfilled ? { skipped: false, backfilled: true } : { skipped: true };
@@ -2129,6 +2125,9 @@ async function syncDefaultProjects(dbModule, options = {}) {
*/
async function reconcileTokens(dbModule, options = {}) {
const onProgress = typeof options.onProgress === "function" ? options.onProgress : () => {};
// `exact: false` only raises (replaceTokenUsage's high-water mark): a repair
// that adds what was never counted without touching anything already right.
const exact = options.exact !== false;
const counters = { reconciled: 0, sessionsTouched: 0, modelsWritten: 0, missingFiles: 0 };
if (!fs.existsSync(getProjectsDir())) return counters;
@@ -2164,7 +2163,7 @@ async function reconcileTokens(dbModule, options = {}) {
const tx = dbModule.db.transaction((batch) => {
for (const { sessionId, tokens } of batch) {
const written = writeSessionTokens(dbModule, sessionId, tokens, { exact: true });
const written = writeSessionTokens(dbModule, sessionId, tokens, { exact });
if (written > 0) {
counters.sessionsTouched++;
counters.modelsWritten += written;
+1 -3
View File
@@ -51,14 +51,12 @@ const env = {
// No suite may fetch LiteLLM's price list (reset-pricing syncs at once).
AGENTHUB_PRICING_SYNC: "0",
// Nor run the developer's real agy (it may update itself) or send their
// real Cursor, Grok, Kimi or OpenCode login for the quota.
// real Cursor login for the quota.
AGENTHUB_AGY_USAGE: "0",
// Nor refresh (spend) a real Claude pool login: its refresh token is single use.
DASHBOARD_POOL_REFRESH_MS: "0",
XDG_CONFIG_HOME: tmuxDir,
XDG_DATA_HOME: tmuxDir,
GROK_HOME: tmuxDir,
KIMI_CODE_HOME: tmuxDir,
DASHBOARD_PIPELINES_DIR: path.join(dir, "fixtures", "pipelines"),
// Never this machine's server-mode S3 install: its system unit would become
// "the" service manager and its server.conf would send user changes to sudo.
+6 -17
View File
@@ -655,23 +655,12 @@ An `antigravity` workspace carries no pipeline either.
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.
- **Grok hooks:** Grok is not an AgentHub tool, but it runs the hooks in
`~/.claude/settings.json` (its Claude compatibility), so AgentHub's own hook
can still reach `POST /api/hooks/event` with no `tool`; `routes/hooks.js`
tells it apart by the camelCase keys only Grok sends (`hookEventName` +
`workspaceRoot`) and drops it, rather than record a Claude session whose
`transcript_path` is Grok's file.
- **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
+167
View File
@@ -0,0 +1,167 @@
/**
* @file Tests for server/lib/agent-edits.js (issue #86): the files a
* subagent wrote or edited are read from its own transcript — +/- lines from
* `structuredPatch` or a created file's content, a failed call left out, a
* nested subagent folded in — and put on the parent's Agent result as `edits`;
* and `turnCarry` hands a page of the chat the edits its cut-off first turn
* made before the page began.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "agent-edits-"));
process.env.CLAUDE_HOME = HOME;
const { encodeCwd } = require("../lib/claude-home");
const { agentEdits, attachAgentEdits, turnCarry, _cache } = require("../lib/agent-edits");
const CWD = "/work/repo";
const SID = "11111111-2222-3333-4444-555555555555";
const subDir = path.join(HOME, "projects", encodeCwd(CWD), SID, "subagents");
const call = (id, name, input) => ({
type: "assistant",
message: { content: [{ type: "tool_use", id, name, input }] },
});
const result = (id, toolUseResult, isError = false) => ({
type: "user",
message: { content: [{ type: "tool_result", tool_use_id: id, is_error: isError }] },
toolUseResult,
});
const write = (agentId, lines) =>
fs.writeFileSync(
path.join(subDir, `agent-${agentId}.jsonl`),
lines.map((l) => JSON.stringify(l)).join("\n")
);
before(() => {
fs.mkdirSync(subDir, { recursive: true });
write("a1", [
call("t1", "Edit", { file_path: "/work/repo/main.go" }),
result("t1", { structuredPatch: [{ lines: [" x", "-old", "+new", "+more"] }] }),
call("t2", "Write", { file_path: "/work/repo/new_test.go" }),
result("t2", { type: "create", content: "a\nb\nc\n" }),
call("t3", "Edit", { file_path: "/work/repo/broken.go" }),
result("t3", "String to replace not found", true),
call("t4", "Agent", { prompt: "go deeper" }),
result("t4", { agentId: "a2", status: "completed" }),
]);
write("a2", [
call("u1", "Edit", { file_path: "/work/repo/main.go" }),
result("u1", { structuredPatch: [{ lines: ["+x"] }] }),
call("u2", "Bash", { command: "go test ./..." }),
result("u2", { stdout: "ok" }),
]);
});
after(() => fs.rmSync(HOME, { recursive: true, force: true }));
describe("agentEdits", () => {
it("lists a subagent's files with their lines, nested subagents folded in", async () => {
const edits = await agentEdits(SID, CWD, "a1");
assert.deepEqual(edits, [
{ path: "/work/repo/main.go", added: 3, removed: 1, counted: true },
{ path: "/work/repo/new_test.go", added: 3, removed: 0, counted: true },
]);
});
it("answers an empty list for a subagent with no transcript", async () => {
assert.deepEqual(await agentEdits(SID, CWD, "nope"), []);
});
it("reads a transcript again only after it changed", async () => {
await agentEdits(SID, CWD, "a2");
const file = path.join(subDir, "agent-a2.jsonl");
const before = _cache.get(file);
await agentEdits(SID, CWD, "a2");
assert.equal(_cache.get(file), before);
fs.appendFileSync(
file,
"\n" +
[
call("u3", "Write", { file_path: "/work/repo/late.go" }),
result("u3", { type: "create", content: "z\n" }),
]
.map((l) => JSON.stringify(l))
.join("\n")
);
const edits = await agentEdits(SID, CWD, "a2");
assert.ok(edits.some((e) => e.path === "/work/repo/late.go"));
});
});
describe("attachAgentEdits", () => {
it("puts edits on an Agent result only", async () => {
const messages = [
{
content: [
{ type: "tool_result", id: "t9", agent: { id: "a1" } },
{ type: "tool_result", id: "t8", diff: { added: 1, removed: 0 } },
{ type: "tool_result", id: "t7", agent: { id: "a1" }, is_error: true },
],
},
];
await attachAgentEdits(messages, SID, CWD);
const [ok, plain, failed] = messages[0].content;
assert.ok(ok.edits.some((e) => e.path === "/work/repo/new_test.go"));
assert.equal(plain.edits, undefined);
assert.equal(failed.edits, undefined);
});
});
describe("turnCarry", () => {
const prompt = (text) => ({ type: "user", sender: "user", content: [{ type: "text", text }] });
const use = (id, name, file_path) => ({
type: "assistant",
sender: "assistant",
content: [{ type: "tool_use", id, name, input: file_path ? { file_path } : {} }],
});
const res = (id, extra = {}) => ({
type: "user",
sender: "tool",
content: [{ type: "tool_result", id, ...extra }],
});
it("answers the files the cut-off turn changed before the page", async () => {
const c = turnCarry();
for (const m of [
prompt("old turn"),
use("o1", "Write", "/w/old.go"),
res("o1", { diff: { added: 9, removed: 0 } }),
prompt("this turn"),
use("1", "Write", "/w/a_test.go"),
res("1", { diff: { added: 176, removed: 0 } }),
use("2", "Edit", "/w/bad.go"),
res("2", { is_error: true }),
use("3", "Edit", "/w/b.go"),
])
c.drop(m);
// The page: the result of a dropped call, then more of the same turn.
const page = [res("3", { diff: { added: 2, removed: 1 } }), use("4", "Write", "/w/c.go")];
assert.deepEqual(await c.result(page, SID, CWD), [
{ path: "/w/a_test.go", added: 176, removed: 0, counted: true },
{ path: "/w/b.go", added: 2, removed: 1, counted: true },
]);
});
it("answers null when the page starts on a prompt", async () => {
const c = turnCarry();
c.drop(prompt("p"));
c.drop(use("1", "Write", "/w/a.go"));
c.drop(res("1", { diff: { added: 1, removed: 0 } }));
assert.equal(await c.result([prompt("next")], SID, CWD), null);
});
it("folds in a dropped subagent's own edits", async () => {
const c = turnCarry();
c.drop(prompt("p"));
c.drop(use("9", "Agent"));
c.drop(res("9", { agent: { id: "a1" } }));
const got = await c.result([use("10", "Bash")], SID, CWD);
assert.ok(got.some((e) => e.path === "/work/repo/new_test.go"));
});
});
+2 -2
View File
@@ -45,7 +45,7 @@ before(async () => {
builtin,
tool("gem", [
{ path: "~/.gem/settings.json" },
{ path: "~/.gem/opencode.jsonc" },
{ path: "~/.gem/tool.jsonc" },
{ path: "~/.gem/GEMINI.md" },
{ path: "~/.gem/missing.json" },
])
@@ -57,7 +57,7 @@ before(async () => {
fs.mkdirSync(path.join(home, ".gem"));
fs.writeFileSync(path.join(home, ".gem/settings.json"), '{"theme":"dark"}\n', { mode: 0o600 });
fs.writeFileSync(path.join(home, ".gem/opencode.jsonc"), '{\n // c\n "a": 1,\n}\n');
fs.writeFileSync(path.join(home, ".gem/tool.jsonc"), '{\n // c\n "a": 1,\n}\n');
fs.writeFileSync(path.join(home, ".gem/GEMINI.md"), "# rules\n");
const express = require("express");
+62
View File
@@ -98,7 +98,24 @@ before(async () => {
"scripted.json",
tool("scripted", { install: { kind: "script", url: "https://example.com/install" } })
);
write(
"redirected.json",
tool("redirected", {
install: {
kind: "script",
url: "https://example.com/install",
redirect: "https://cdn.example.net/install.sh",
},
})
);
write("selfy.json", tool("selfy", { update: { kind: "self", args: ["update", "--yes"] } }));
write(
"selfnpm.json",
tool("selfnpm", {
install: { kind: "npm", package: "selfnpm-pkg" },
update: { kind: "self", args: ["update"] },
})
);
write("plain.json", tool("plain"));
exe(path.join(shellBin, "npm"));
exe(path.join(serverBin, "npm"));
@@ -231,6 +248,30 @@ describe("a system-wide npm prefix", () => {
aiTools.refresh();
}
});
it("updates an npm tool with its own updater under ~/.local through npm --prefix", async () => {
const pkg = path.join(user, "lib", "node_modules", "selfnpm-pkg", "cli.js");
exe(pkg);
fs.symlinkSync(pkg, path.join(serverBin, "selfnpm"));
aiTools.refresh();
try {
assert.deepEqual(await jobs.stepsFor("selfnpm", "update"), [
{
argv: [
path.join(shellBin, "npm"),
"install",
"-g",
"--prefix",
user,
"selfnpm-pkg@latest",
],
},
]);
} finally {
fs.rmSync(path.join(serverBin, "selfnpm"));
aiTools.refresh();
}
});
});
describe("versions", () => {
@@ -342,6 +383,27 @@ describe("jobs", () => {
assert.match(done.log, /larger than 1 MB/);
});
it("follows a redirect off its site only to the catalog's exact redirect URL", async () => {
let landed = "https://cdn.example.net/install.sh?x=1";
globalThis.fetch = async () => ({
ok: true,
status: 200,
url: landed,
arrayBuffer: async () => Buffer.from("echo hi\n"),
});
await jobs.start("redirected", "install");
let done = await settle();
assert.equal(done.status, "failed");
assert.match(done.log, /redirected off example\.com/);
assert.equal(spawned.length, 0);
landed = "https://cdn.example.net/install.sh";
await jobs.start("redirected", "install");
done = await settle();
assert.equal(done.status, "ok");
assert.equal(spawned[0].argv[0], "bash");
});
it("reports the end only after re-detecting, so the first poll sees the installed tool", async () => {
// A slow login-shell read at the end: the job must not look finished before it.
jobs.__setHydrate(() => new Promise((r) => setTimeout(() => r(false), 40)));
+31 -11
View File
@@ -26,8 +26,28 @@ for (const name of ["claude", "agy", "npm"]) {
const origPath = process.env.PATH;
process.env.PATH = BIN;
// An npm-installed tool, in the release-synced tier (only a release catalog may
// name an installer): no built-in tool updates through npm.
const REMOTE = path.join(ROOT, "remote-catalog");
fs.mkdirSync(REMOTE);
fs.writeFileSync(
path.join(REMOTE, "npmtool.json"),
JSON.stringify({
id: "npmtool",
label: "npm tool",
detect: ["npmtool"],
prompt: { mode: "flag", flag: "--prompt" },
install: { kind: "npm", package: "npmtool" },
update: { kind: "npm" },
})
);
const { createApp } = require("../index");
require("../lib/ai-tools").refresh();
require("../lib/ai-tools").__setCatalogDirs([
path.join(__dirname, "..", "data", "ai-tools"),
REMOTE,
path.join(ROOT, "custom-catalog"),
]);
let app;
let base;
@@ -59,7 +79,7 @@ describe("/api/ai-tools", () => {
assert.equal(agy.path, path.join(BIN, "agy"));
assert.equal(agy.resume, true);
assert.equal(agy.bypass, true);
assert.equal(body.tools.find((t) => t.id === "opencode").installed, false);
assert.equal(body.tools.find((t) => t.id === "npmtool").installed, false);
assert.equal("args" in agy, false);
});
@@ -76,7 +96,7 @@ describe("/api/ai-tools", () => {
it("answers ENOTOOL / EBADDEFAULT / EBADFIELD", async () => {
assert.equal((await request("PUT", "/api/ai-tools/nope", { enabled: false })).status, 404);
const bad = await request("PUT", "/api/ai-tools/opencode", { default: true });
const bad = await request("PUT", "/api/ai-tools/npmtool", { default: true });
assert.equal(bad.status, 400);
assert.equal(bad.body.error.code, "EBADDEFAULT");
assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400);
@@ -112,10 +132,10 @@ describe("/api/ai-tools", () => {
});
it("refresh re-detects", async () => {
fs.writeFileSync(path.join(BIN, "opencode"), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, "opencode"), 0o755);
fs.writeFileSync(path.join(BIN, "npmtool"), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, "npmtool"), 0o755);
const r = await request("POST", "/api/ai-tools/refresh");
assert.equal(r.body.tools.find((t) => t.id === "opencode").installed, true);
assert.equal(r.body.tools.find((t) => t.id === "npmtool").installed, true);
});
it("/api/run/binary resolves through the catalog", async () => {
@@ -132,9 +152,9 @@ describe("lanes on catalog tools", () => {
fs.mkdirSync(d, { recursive: true });
return d;
};
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "opencode" });
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "npmtool" });
assert.equal(r.status, 201);
assert.equal(r.body.lane.tool, "opencode");
assert.equal(r.body.lane.tool, "npmtool");
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "omp" });
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
@@ -212,8 +232,8 @@ describe("install / update / icons", () => {
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
assert.equal(notInstalled.status, 409);
assert.equal(notInstalled.body.error.code, "ENOTINSTALLED");
// opencode was put on PATH by the refresh test, outside any npm prefix.
const notNpm = await request("POST", "/api/ai-tools/opencode/update");
// npmtool was put on PATH by the refresh test, outside any npm prefix.
const notNpm = await request("POST", "/api/ai-tools/npmtool/update");
assert.equal(notNpm.status, 409);
assert.equal(notNpm.body.error.code, "ENOTNPM");
const cross = await request("POST", "/api/ai-tools/codex/install", undefined, {
@@ -241,7 +261,7 @@ describe("install / update / icons", () => {
assert.deepEqual(spawned.at(-1), [path.join(BIN, "claude"), "update"]);
const v = await request("GET", "/api/ai-tools/versions");
assert.equal(v.status, 200);
assert.equal(v.body.opencode.npmManaged, false);
assert.equal(v.body.npmtool.npmManaged, false);
});
it("update-all answers ENOTHING when nothing has an update", async () => {
+16 -1
View File
@@ -261,6 +261,20 @@ describe("install/update fields and trust tiers", () => {
update: { kind: "script" },
})
);
write(
tb,
"redir.json",
base("redir", {
install: { kind: "script", url: "https://example.com/i", redirect: "https://cdn.io/i.sh" },
})
);
write(
tb,
"badredir.json",
base("badredir", {
install: { kind: "script", url: "https://example.com/i", redirect: "http://cdn.io/i" },
})
);
write(tb, "badpkg.json", base("badpkg", { install: { kind: "npm", package: "Bad Pkg;rm" } }));
write(
tb,
@@ -302,7 +316,8 @@ describe("install/update fields and trust tiers", () => {
url: "https://example.com/install",
});
assert.deepEqual(aiTools.get("claude").update, { kind: "self", args: ["update"] });
for (const id of ["badpkg", "httpurl", "offhost", "npmnoinst", "selfbad"]) {
assert.equal(aiTools.get("redir").install.redirect, "https://cdn.io/i.sh");
for (const id of ["badpkg", "httpurl", "offhost", "badredir", "npmnoinst", "selfbad"]) {
assert.equal(aiTools.has(id), false, id);
}
});
+67 -2
View File
@@ -10,8 +10,10 @@
* junk skipped, nothing stored when one fails) or written in the dialog, a
* file uploaded, sharing refused in local mode, visibility for a server-mode
* developer, the admin's installs switch, same-origin writes, Claude Docs
* docs captured at their birth, documents captured at a session's stop, and
* unowned rows claimed, declined and assigned.
* docs captured at their birth, documents captured at a session's stop,
* unowned rows claimed, declined and assigned, and a skill that follows its
* source folder (a newer change wins over a Hub edit) with Update offered to
* an installed copy that differs.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -338,6 +340,69 @@ describe("skills", () => {
assert.equal(empty.status, 400);
});
it("follows its source folder and offers Update to an installed copy", async () => {
const src = path.join(ws, ".claude", "skills", "notes");
fs.mkdirSync(src, { recursive: true });
fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: notes\ndescription: v1\n---\nOne.\n");
const r = await request("POST", "/api/artifacts/skill", { laneId: lane.id, name: "notes" });
assert.equal(r.status, 201, r.text);
const id = r.body.item.id;
const listed = async () =>
(await request("GET", "/api/artifacts?kind=skill")).body.items.find((i) => i.id === id);
const raw = async () => (await request("GET", `/api/artifacts/${id}/raw?path=SKILL.md`)).text;
assert.equal((await request("POST", `/api/artifacts/${id}/install`, {})).status, 200);
let item = await listed();
assert.equal(item.installed, true);
assert.equal(item.updateAvailable, false);
const installs = item.installs;
// Unchanged folder: nothing taken.
assert.deepEqual(artifacts.syncSkillSources({ force: true }), []);
// The owner changes it: a new snapshot, and the installed copy is behind.
const snapBefore = db.prepare("SELECT snapshot FROM artifacts WHERE id = ?").get(id).snapshot;
fs.writeFileSync(path.join(src, "SKILL.md"), "---\nname: notes\ndescription: v2\n---\nTwo.\n");
fs.writeFileSync(path.join(src, "extra.md"), "more\n");
const changed = artifacts.syncSkillSources({ force: true });
assert.deepEqual(
changed.map((c) => c.id),
[id]
);
assert.match(await raw(), /Two\./);
assert.ok(!fs.existsSync(path.join(process.env.DASHBOARD_DATA_DIR, "artifacts", snapBefore)));
item = await listed();
assert.equal(item.description, "v2");
assert.ok(item.files.some((f) => f.path === "extra.md"));
assert.equal(item.updateAvailable, true);
// Update: the copy is replaced (old one moved aside), not counted again.
const up = await request("POST", `/api/artifacts/${id}/install`, { update: true });
assert.equal(up.status, 200, up.text);
const target = path.join(process.env.CLAUDE_HOME, "skills", "notes");
assert.match(fs.readFileSync(path.join(target, "SKILL.md"), "utf8"), /Two\./);
item = await listed();
assert.equal(item.updateAvailable, false);
assert.equal(item.installs, installs);
// A Hub edit is not undone by the unchanged folder; a newer folder change wins.
const edit = await request("PATCH", `/api/artifacts/${id}`, {
skill: { name: "notes", description: "hub", content: "Edited in the Hub." },
});
assert.equal(edit.status, 200, edit.text);
artifacts.syncSkillSources({ force: true });
assert.match(await raw(), /Edited in the Hub\./);
fs.writeFileSync(
path.join(src, "SKILL.md"),
"---\nname: notes\ndescription: v3\n---\nThree.\n"
);
artifacts.syncSkillSources({ force: true });
assert.match(await raw(), /Three\./);
// Update needs an installed copy.
await request("POST", `/api/artifacts/${id}/uninstall`, {});
const none = await request("POST", `/api/artifacts/${id}/install`, { update: true });
assert.equal(none.body.error.code, "ENOTINSTALLED");
});
it("refuses a skill holding a symlink or a secret-looking file", async () => {
const bad = path.join(ws, ".claude", "skills", "leaky");
fs.mkdirSync(bad, { recursive: true });
+28 -1
View File
@@ -8,7 +8,7 @@
*/
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const { desktopEnv, FORWARDED, __setExecImpl } = require("../lib/desktop-env");
const { desktopEnv, canReadClipboard, FORWARDED, __setExecImpl } = require("../lib/desktop-env");
const saved = {};
@@ -61,3 +61,30 @@ describe("desktopEnv", () => {
assert.equal(desktopEnv().WAYLAND_DISPLAY, undefined);
});
});
describe("canReadClipboard", () => {
const has =
(...names) =>
(n) =>
names.includes(n) ? `/usr/bin/${n}` : null;
it("needs wl-paste for a Wayland display, xclip for an X one", () => {
const wl = { WAYLAND_DISPLAY: "wayland-0" };
const x = { DISPLAY: ":0" };
assert.equal(canReadClipboard({ platform: "linux", env: wl, which: has("wl-paste") }), true);
assert.equal(canReadClipboard({ platform: "linux", env: wl, which: has("xclip") }), false);
assert.equal(canReadClipboard({ platform: "linux", env: x, which: has("xclip") }), true);
assert.equal(canReadClipboard({ platform: "linux", env: x, which: has() }), false);
});
it("is false with no display at all — a headless box", () => {
assert.equal(
canReadClipboard({ platform: "linux", env: {}, which: has("xclip", "wl-paste") }),
false
);
});
it("is true off Linux, where the clipboard tools are built in", () => {
assert.equal(canReadClipboard({ platform: "darwin", env: {}, which: has() }), true);
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* @file file-manager.test.js
* @description server/lib/file-manager.js: the files a file manager's Copy
* put on the OS clipboard (Windows GetFileDropList, macOS NSPasteboard, Linux
* text/uri-list / gnome-copied-files) and the ones selected in it — what a
* drag carries (Windows Shell.Application, macOS Finder) — kept only when
* they exist here, with their sizes; a missing reader is no files.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
clipboardFiles,
selectedFiles,
uriListPaths,
__setExecImpl,
} = require("../lib/file-manager");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "fm-files-"));
const big = path.join(TMP, "big file.mp4");
const dir = path.join(TMP, "folder");
before(() => {
fs.writeFileSync(big, Buffer.alloc(1234));
fs.mkdirSync(dir);
});
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
afterEach(() => __setExecImpl(async () => ""));
describe("uriListPaths", () => {
it("decodes file URIs and skips a gnome list's verb, comments and other hosts", () => {
assert.deepEqual(
uriListPaths("copy\nfile:///home/u/a%20b.png\r\n# c\nfile://other/x\nhttps://x/y\n"),
["/home/u/a b.png"]
);
});
});
describe("clipboardFiles", () => {
it("reads Windows' file drop list with powershell -Sta", async () => {
let seen;
__setExecImpl(async (cmd, args) => {
seen = [cmd, args];
return `${big}\r\n${dir}\r\n${path.join(TMP, "gone.txt")}\r\n`;
});
assert.deepEqual(await clipboardFiles("win32"), [
{ path: big, size: 1234, dir: false },
{ path: dir, size: null, dir: true },
]);
assert.equal(seen[0], "powershell");
assert.ok(seen[1].includes("-Sta"));
assert.match(seen[1].at(-1), /GetFileDropList/);
});
it("reads macOS file URLs through JXA", async () => {
let seen;
__setExecImpl(async (cmd, args) => {
seen = [cmd, args];
return `${big}\n`;
});
assert.deepEqual(await clipboardFiles("darwin"), [{ path: big, size: 1234, dir: false }]);
assert.equal(seen[0], "osascript");
assert.deepEqual(seen[1].slice(0, 2), ["-l", "JavaScript"]);
});
it("reads Linux text/uri-list with the desktop's display", async () => {
const saved = process.env.DISPLAY;
process.env.DISPLAY = ":0";
try {
const calls = [];
__setExecImpl(async (cmd, args, env) => {
calls.push([cmd, args.join(" "), env.DISPLAY]);
return cmd === "xclip" ? `file://${encodeURI(big)}\n` : "";
});
assert.deepEqual(await clipboardFiles("linux"), [{ path: big, size: 1234, dir: false }]);
assert.deepEqual(calls.at(-1), ["xclip", "-selection clipboard -t text/uri-list -o", ":0"]);
} finally {
if (saved === undefined) delete process.env.DISPLAY;
else process.env.DISPLAY = saved;
}
});
it("answers no files when the reader is missing or prints relative paths", async () => {
__setExecImpl(async () => "");
assert.deepEqual(await clipboardFiles("win32"), []);
__setExecImpl(async () => "relative/x.txt\n");
assert.deepEqual(await clipboardFiles("darwin"), []);
});
it("reads Explorer's selection (and the desktop's) for a drop, once per path", async () => {
let seen;
__setExecImpl(async (cmd, args) => {
seen = [cmd, args];
return `${big}\r\n${big}\r\n`;
});
assert.deepEqual(await selectedFiles("win32"), [{ path: big, size: 1234, dir: false }]);
assert.equal(seen[0], "powershell");
assert.match(seen[1].at(-1), /Shell\.Application[\s\S]*SelectedItems[\s\S]*FindWindowSW/);
});
it("reads Finder's selection on macOS, a folder with its trailing slash", async () => {
let seen;
__setExecImpl(async (cmd, args) => {
seen = [cmd, args];
return `${dir}/\n`;
});
assert.deepEqual(await selectedFiles("darwin"), [{ path: `${dir}/`, size: null, dir: true }]);
assert.equal(seen[0], "osascript");
assert.ok(seen[1].includes("repeat with a in (selection as alias list)"));
});
it("has no selection to read on Linux", async () => {
let called = false;
__setExecImpl(async () => {
called = true;
return big;
});
assert.deepEqual(await selectedFiles("linux"), []);
assert.equal(called, false);
});
});
@@ -1,7 +1,7 @@
{
"id": "grok",
"label": "Grok",
"detect": ["grok"],
"id": "septool",
"label": "Separator tool",
"detect": ["septool"],
"prompt": {
"mode": "argv",
"separator": "--"
+19
View File
@@ -143,6 +143,25 @@ describe("POST /api/lanes/clone", () => {
assert.equal(again.body.error.code, "EEXISTS");
});
it("re-attaches a workspace whose folder was deleted instead of failing UNIQUE", async () => {
const first = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "gone",
});
assert.equal(first.status, 201, JSON.stringify(first.body));
fs.rmSync(path.join(WORK, "gone"), { recursive: true, force: true });
const again = await request("POST", "/api/lanes/clone", {
url: "https://forge.test/o/r.git",
parent: WORK,
name: "gone",
});
assert.equal(again.status, 200, JSON.stringify(again.body));
assert.equal(again.body.created, false);
assert.equal(again.body.lane.id, first.body.lane.id);
assert.ok(fs.existsSync(path.join(WORK, "gone", "README.md")));
});
it("clones over ssh with the user's key, kept in the clone's config", async () => {
const made = await request("POST", "/api/me/git/ssh-key", {});
assert.equal(made.status, 201);
+9 -14
View File
@@ -2,10 +2,10 @@
* @file Tests POST /api/hooks/event for a hook Grok Build fired through the
* Claude hooks in `~/.claude/settings.json` (Grok's Claude compatibility): it
* arrives with no `tool`, is told apart by the camelCase keys only Grok sends
* (`hookEventName`, `workspaceRoot`), and lands as a `tool = 'grok'` session
* on the grok workspace — never the claude one sharing the directory — with
* its `transcript_path` dropped. A Claude hook is left Claude's. The DB lives
* in a temp dir.
* (`hookEventName`, `workspaceRoot`), and dropped — Grok is not an AgentHub
* tool, and taken as Claude's it would bind the claude workspace and name a
* Grok file as a Claude transcript. A Claude hook is left Claude's. The DB
* lives in a temp dir.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -58,23 +58,18 @@ const grokData = (id, extra = {}) => ({
});
describe("Grok hooks through the Claude settings", () => {
it("records a grok session, on the grok workspace, with no transcript path", async () => {
it("drops a Grok hook: no session, no event, no workspace bound", async () => {
const claudeLane = lanes.createLane({ title: "c", cwd: CWD, tool: "claude" });
const grokLane = lanes.createLane({ title: "g", cwd: CWD, tool: "grok" });
const id = "0199f0aa-1111-7222-8333-444455556666";
await post({ hook_type: "SessionStart", data: grokData(id) });
const r = await post({ hook_type: "SessionStart", data: grokData(id) });
assert.equal(r.ignored, true);
await post({
hook_type: "UserPromptSubmit",
data: grokData(id, { hookEventName: "user_prompt_submit", prompt: "fix the bug" }),
});
const s = db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
assert.equal(s.tool, "grok");
assert.equal(s.transcript_path, null);
const events = db.prepare("SELECT data FROM events WHERE session_id = ?").all(id);
assert.ok(events.length >= 1);
for (const e of events) assert.equal(JSON.parse(e.data).transcript_path, undefined);
assert.equal(db.prepare("SELECT * FROM sessions WHERE id = ?").get(id), undefined);
assert.equal(db.prepare("SELECT COUNT(*) n FROM events WHERE session_id = ?").get(id).n, 0);
assert.equal(lanes.getLane(claudeLane.id).session_id, null);
assert.equal(lanes.getLane(grokLane.id).session_id, id);
});
it("leaves a Claude hook Claude's", async () => {
+89
View File
@@ -2092,6 +2092,95 @@ describe("an extra folder's own repo (?source=)", () => {
});
});
describe("repos inside a parent workspace folder (?repo=)", () => {
const { execFileSync } = require("child_process");
const gitEnv = Object.fromEntries(
Object.entries(process.env).filter(([k]) => !k.startsWith("GIT_"))
);
const gitIn = (dir, ...args) =>
execFileSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args], {
encoding: "utf8",
env: gitEnv,
});
let parent, laneId, otherLaneId;
before(async () => {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lane-nested-")));
parent = path.join(root, "shop");
for (const rel of ["api", "web", "group/admin"]) {
const dir = path.join(parent, rel);
fs.mkdirSync(dir, { recursive: true });
gitIn(dir, "init", "-q", "-b", "main");
fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
gitIn(dir, "add", ".");
gitIn(dir, "commit", "-qm", "init");
}
// Not repos: a plain folder, a dot folder and node_modules holding one,
// and a symlink to a repo (never followed).
fs.mkdirSync(path.join(parent, "docs"));
fs.mkdirSync(path.join(parent, ".cache/x/.git"), { recursive: true });
fs.mkdirSync(path.join(parent, "node_modules/pkg/.git"), { recursive: true });
fs.symlinkSync(path.join(parent, "api"), path.join(parent, "api-link"));
// A repo found inside a repo is not walked into.
fs.mkdirSync(path.join(parent, "web/inner/.git"), { recursive: true });
laneId = (await request("POST", "/api/lanes", { cwd: parent })).body.lane.id;
otherLaneId = (await request("POST", "/api/lanes", { cwd: path.join(root, "other") })).body.lane
.id;
});
it("lists the repos below the workspace folder", async () => {
const r = await request("GET", `/api/lanes/${laneId}/repos`);
assert.equal(r.status, 200);
assert.equal(r.body.root, false);
assert.deepEqual(
r.body.items.map((i) => i.path),
["api", "group/admin", "web"]
);
});
it("reads and writes git in the named repo only", async () => {
fs.writeFileSync(path.join(parent, "group/admin/new.txt"), "x\n");
const changes = await request("GET", `/api/lanes/${laneId}/git/changes?repo=group/admin`);
assert.ok(changes.body.files.some((f) => f.path === "new.txt"));
const facts = await request("GET", `/api/lanes/${laneId}/git?repo=api`);
assert.equal(facts.body.available, true);
assert.equal(facts.body.base, "main");
const ok = await request("POST", `/api/lanes/${laneId}/git/stage?repo=group/admin`, {
paths: ["new.txt"],
});
assert.equal(ok.status, 200);
assert.match(
gitIn(path.join(parent, "group/admin"), "diff", "--cached", "--name-only"),
/new\.txt/
);
});
it("answers ENOREPO for any path the walk did not find", async () => {
for (const rel of ["docs", "..", "../other", "/etc", "api-link", ".cache/x", "web/inner", ""]) {
const r = await request(
"GET",
`/api/lanes/${laneId}/git/changes?repo=${encodeURIComponent(rel)}`
);
assert.equal(r.status, 404, rel);
assert.equal(r.body.error.code, "ENOREPO", rel);
}
const r = await request("GET", `/api/lanes/${otherLaneId}/git/changes?repo=api`);
assert.equal(r.status, 404);
assert.equal(r.body.error.code, "ENOREPO");
});
it("finds a repo cloned since the last read", async () => {
require("../lib/nested-repos").clearNestedRepoCache();
await request("GET", `/api/lanes/${laneId}/repos`);
await new Promise((r) => setTimeout(r, 2100));
const dir = path.join(parent, "late");
fs.mkdirSync(dir);
gitIn(dir, "init", "-q", "-b", "main");
const r = await request("GET", `/api/lanes/${laneId}/git?repo=late`);
assert.equal(r.status, 200);
});
});
// The server, not a stale client list, picks the tab and refuses a second pane
// on a session one already holds.
describe("start: slot auto and a session already live", () => {
+18
View File
@@ -74,6 +74,24 @@ describe("local mode: one account", () => {
}
});
it("keeps the sidebar's workspace groups, up to 64 KB", async () => {
const groups = JSON.stringify({ groups: [{ id: "g1", name: "Web" }], of: { 1: "g1" } });
const pad = "x".repeat(20 * 1024);
let r = await call("PUT", "/api/me/prefs", {
body: { prefs: { "agenthub.workspace.groups": groups + pad } },
});
assert.equal(r.status, 200);
assert.equal(r.body.prefs["agenthub.workspace.groups"], groups + pad);
r = await call("PUT", "/api/me/prefs", {
body: { prefs: { "agenthub.workspace.groups": "x".repeat(65 * 1024) } },
});
assert.equal(r.status, 400);
r = await call("PUT", "/api/me/prefs", {
body: { prefs: { "agenthub.workspace.groups": null } },
});
assert.equal(r.body.prefs["agenthub.workspace.groups"], undefined);
});
it("keeps the account's own agent instructions, capped, blank clears", async () => {
let r = await call("GET", "/api/me/instructions");
assert.deepEqual(r.body, { instructions: "", max: 16000, tools: ["claude", "codex"] });
-188
View File
@@ -1,188 +0,0 @@
/**
* @file Tests server/lib/opencode-usage.js and the OpenCode branch of the
* transcript routes — OpenCode sessions from its SQLite store: a root session
* created as `tool = 'opencode'` with its title, cwd, model and per-model
* tokens (its subagent session's included, reasoning counted as output), a
* subagent row per child session, an unchanged store never re-read, a
* session the user deleted staying deleted until it is updated, and the
* conversation (text, reasoning, tool call + result) in the Claude
* transcript shape. The store is a temp file with the tables and columns
* opencode 1.19 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-opencode-usage-"));
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db");
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
process.env.AGENTHUB_OPENCODE_DIR = path.join(ROOT, "opencode");
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const { db, Database } = require("../db");
const oc = require("../lib/opencode-usage");
const { createApp, startServer } = require("../index");
const ID = "ses_eac8393b4ffeEz26VGbeyq8sir";
const CHILD = "ses_eac8393b4fffChildSession01";
const CWD = path.join(fs.realpathSync(ROOT), "work");
const STORE = path.join(process.env.AGENTHUB_OPENCODE_DIR, "opencode.db");
const T0 = Date.parse("2026-10-19T09:27:05.000Z");
let server;
let BASE;
after(() => {
server?.close();
fs.rmSync(ROOT, { recursive: true, force: true });
});
function withStore(fn) {
const conn = new Database(STORE);
try {
fn(conn);
} finally {
conn.close();
}
}
const assistant = (model, tokens) => JSON.stringify({ role: "assistant", modelID: model, tokens });
const tokens = (input, output, reasoning = 0, read = 0, write = 0) => ({
input,
output,
reasoning,
cache: { read, write },
});
before(async () => {
fs.mkdirSync(path.dirname(STORE), { recursive: true });
withStore((c) => {
c.exec(`
CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, version TEXT NOT NULL,
model TEXT, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, time_archived INTEGER);
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL, data TEXT NOT NULL);
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL,
time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL);
`);
const s = c.prepare(
"INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, model, time_created, time_updated) VALUES (?, 'global', ?, 'x', ?, ?, '1.19.3', ?, ?, ?)"
);
s.run(ID, null, CWD, "Fix the login bug", '{"id":"claude-sonnet-4-5","providerID":"anthropic"}', T0, T0 + 5000);
s.run(CHILD, ID, CWD, "Explore auth (@explore subagent)", null, T0 + 1000, T0 + 3000);
const m = c.prepare("INSERT INTO message VALUES (?, ?, ?, ?, ?)");
m.run("msg_1", ID, T0 + 10, T0 + 10, JSON.stringify({ role: "user" }));
m.run("msg_2", ID, T0 + 20, T0 + 20, assistant("claude-sonnet-4-5", tokens(100, 20, 5, 1000, 50)));
m.run("msg_3", CHILD, T0 + 1100, T0 + 1100, JSON.stringify({ role: "user" }));
m.run("msg_4", CHILD, T0 + 1200, T0 + 1200, assistant("claude-haiku-4-5", tokens(10, 4)));
const p = c.prepare("INSERT INTO part VALUES (?, ?, ?, ?, ?, ?)");
p.run("prt_1", "msg_1", ID, T0 + 11, T0 + 11, JSON.stringify({ type: "text", text: "Fix the login bug" }));
p.run("prt_2", "msg_2", ID, T0 + 21, T0 + 21, JSON.stringify({ type: "reasoning", text: "Look first" }));
p.run(
"prt_3",
"msg_2",
ID,
T0 + 22,
T0 + 22,
JSON.stringify({
type: "tool",
tool: "read",
callID: "call_1",
state: { status: "completed", input: { filePath: "auth.ts" }, output: "export {}" },
})
);
p.run("prt_4", "msg_2", ID, T0 + 23, T0 + 23, JSON.stringify({ type: "step-start" }));
p.run("prt_5", "msg_3", CHILD, T0 + 1101, T0 + 1101, JSON.stringify({ type: "text", text: "Explore auth" }));
p.run("prt_6", "msg_4", CHILD, T0 + 1201, T0 + 1201, JSON.stringify({ type: "text", text: "Found it" }));
});
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
const sessionRow = () => db.prepare("SELECT * FROM sessions WHERE id = ?").get(ID);
describe("opencode usage ingest", () => {
it("creates the root session with its title, cwd, model and its tree's tokens", async () => {
const r = await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(r.files, 1);
const s = sessionRow();
assert.equal(s.tool, "opencode");
assert.equal(s.name, "Fix the login bug");
assert.equal(s.cwd, CWD);
assert.equal(s.model, "claude-sonnet-4-5");
assert.equal(s.status, "completed");
assert.equal(db.prepare("SELECT 1 FROM sessions WHERE id = ?").get(CHILD), undefined);
const usage = db
.prepare(
"SELECT model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens FROM token_usage WHERE session_id = ? ORDER BY model"
)
.all(ID);
assert.deepEqual(usage, [
{ model: "claude-haiku-4-5", input_tokens: 10, output_tokens: 4, cache_read_tokens: 0, cache_write_tokens: 0 },
{ model: "claude-sonnet-4-5", input_tokens: 100, output_tokens: 25, cache_read_tokens: 1000, cache_write_tokens: 50 },
]);
const agent = db.prepare("SELECT * FROM agents WHERE id = ?").get(`opencode-${CHILD}`);
assert.equal(agent.session_id, ID);
assert.equal(agent.type, "subagent");
assert.equal(agent.name, "Explore auth (@explore subagent)");
assert.equal(agent.status, "completed");
});
it("leaves an unchanged store unread", async () => {
const r = await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(r.files, 0);
});
it("maps the store to the transcript shape, and lists the subagent's", async () => {
const list = await (await fetch(`${BASE}/api/sessions/${ID}/transcripts`)).json();
assert.deepEqual(
list.transcripts.map((t) => [t.id, t.type, t.db_agent_id]),
[
["main", "main", null],
[CHILD, "subagent", `opencode-${CHILD}`],
]
);
const body = await (await fetch(`${BASE}/api/sessions/${ID}/transcript`)).json();
assert.deepEqual(
body.messages.map((m) => [m.type, m.sender, m.content.map((c) => c.type).join("+")]),
[
["user", "user", "text"],
["assistant", "assistant", "thinking+tool_use"],
["user", "tool", "tool_result"],
]
);
assert.equal(body.messages[2].content[0].output, "export {}");
assert.equal(body.last_line, 3);
const tail = await (await fetch(`${BASE}/api/sessions/${ID}/transcript?after=2`)).json();
assert.equal(tail.messages.length, 1);
const sub = await (
await fetch(`${BASE}/api/sessions/${ID}/transcript?agent_id=${CHILD}`)
).json();
assert.deepEqual(
sub.messages.map((m) => [m.sender, m.content[0].text]),
[
["orchestrator", "Explore auth"],
["assistant", "Found it"],
]
);
});
it("keeps a deleted session deleted until OpenCode updates it", async () => {
db.prepare("DELETE FROM sessions WHERE id = ?").run(ID);
const bump = (t) => {
withStore((c) => c.prepare("UPDATE session SET title = title WHERE id = ?").run(ID));
const at = new Date(t);
fs.utimesSync(STORE, at, at);
};
bump(Date.now() + 5000);
await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(sessionRow(), undefined);
withStore((c) => c.prepare("UPDATE session SET time_updated = ? WHERE id = ?").run(T0 + 9000, ID));
fs.utimesSync(STORE, new Date(Date.now() + 10_000), new Date(Date.now() + 10_000));
await oc.ingestOpencodeUsage({ now: T0 + 600_000 });
assert.equal(sessionRow()?.tool, "opencode");
});
});
+2 -2
View File
@@ -733,7 +733,7 @@ describe("pty-run", () => {
let savedPath;
before(() => {
fs.mkdirSync(BIN, { recursive: true });
for (const name of ["agy", "grok", "goose", "command-code", "omp"]) {
for (const name of ["agy", "septool", "goose", "command-code", "omp"]) {
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
fs.chmodSync(path.join(BIN, name), 0o755);
}
@@ -854,7 +854,7 @@ describe("pty-run", () => {
);
assert.deepEqual(argvFor("antigravity"), [at("agy"), "-i", " -x y"]);
// Behind a `--` separator the prompt is already a positional, kept as typed.
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
assert.deepEqual(argvFor("septool"), [at("septool"), "--", "-x y"]);
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", " -x y"]);
assert.deepEqual(argvFor("antigravity", { bypass: false }), [at("agy"), "-i", " -x y"]);
});
+1 -178
View File
@@ -6,9 +6,7 @@
* with its own token only while it is valid, never refreshed, and never shown
* with the usage of a token that belongs to a different account; each
* tool's plan name; the Cursor CLI's login asked of cursor.com only while
* valid; Antigravity's `agy --print /usage` pools, never on an old agy; and
* the Grok, Kimi Code and OpenCode Go logins, each sent only to its own host
* while valid, never refreshed.
* valid; and Antigravity's `agy --print /usage` pools, never on an old agy.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -22,12 +20,6 @@ process.env.XDG_CONFIG_HOME = require("node:fs").mkdtempSync(
);
// Nor run the machine's real agy: only the Antigravity test's fake one runs.
process.env.AGENTHUB_AGY_USAGE = "0";
// Nor its real Grok, Kimi or OpenCode logins.
for (const k of ["GROK_HOME", "KIMI_CODE_HOME", "XDG_DATA_HOME"]) {
process.env[k] = require("node:fs").mkdtempSync(
require("node:path").join(require("node:os").tmpdir(), "quota-home-")
);
}
test("windowLabel names the plan windows", () => {
assert.strictEqual(windowLabel(300), "5h");
@@ -415,172 +407,3 @@ function stubFetch(answers) {
};
return { asked, restore: () => (global.fetch = prev) };
}
test("Grok: the CLI's own auth.x.ai session asks cli-chat-proxy for credits + plan, never past expiry", async () => {
const fs = require("node:fs");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const auth = path.join(process.env.GROK_HOME, "auth.json");
const f = stubFetch({
"https://cli-chat-proxy.grok.com/v1/billing?format=credits": [
200,
{
config: {
creditUsagePercent: 12,
currentPeriod: { end: "2099-01-08T00:00:00Z" },
subscriptionTier: "SuperGrok",
monthlyLimit: { val: "200" },
used: { val: "50" },
billingPeriodEnd: "2099-02-01T00:00:00Z",
},
},
],
});
const grok = async () => (await quotas()).tools.find((t) => t.id === "grok");
try {
__resetCache();
assert.strictEqual(await grok(), undefined);
const later = new Date(Date.now() + 3600_000).toISOString();
fs.writeFileSync(
auth,
JSON.stringify({
"https://other.example": { key: "tok-other", expires_at: later },
"https://auth.x.ai": { key: "tok-x", user_id: "u1", email: "g@x.ai", expires_at: later },
})
);
__resetCache();
assert.deepStrictEqual(await grok(), {
id: "grok",
active: true,
account: "g@x.ai",
plan: "SuperGrok",
expiresAt: later,
windows: [
{ label: "wk", usedPercent: 12, resetsAt: "2099-01-08T00:00:00.000Z" },
{ label: "mo", usedPercent: 25, resetsAt: "2099-02-01T00:00:00.000Z" },
],
});
assert.deepStrictEqual(
f.asked.filter((a) => a.includes("grok.com")),
["https://cli-chat-proxy.grok.com/v1/billing?format=credits Bearer tok-x"]
);
fs.writeFileSync(
auth,
JSON.stringify({ "https://auth.x.ai": { key: "tok-x", expires_at: "2000-01-01T00:00:00Z" } })
);
__resetCache();
assert.strictEqual((await grok()).error, "ETOKENEXPIRED");
assert.strictEqual(f.asked.filter((a) => a.includes("grok.com")).length, 1);
} finally {
f.restore();
fs.rmSync(auth, { force: true });
__resetCache();
}
});
test("Kimi Code: the CLI's own token asks api.kimi.com/coding for 5h, week and month", async () => {
const fs = require("node:fs");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const dir = path.join(process.env.KIMI_CODE_HOME, "credentials");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "kimi-code.json");
const f = stubFetch({
"https://api.kimi.com/coding/v1/usages": [
200,
{
usages: {
limit_5h: { used_ratio: 0.3, reset_time: "2099-01-01T05:00:00Z" },
limit_7d: { used_ratio: "0.2", reset_time: "2099-01-07T00:00:00Z" },
limit_month_total: { used_ratio: 0.4, reset_time: "2099-02-01T00:00:00Z" },
limit_month_code: { used_ratio: 0.25, reset_time: "2099-02-01T00:00:00Z" },
},
},
],
});
const kimi = async () => (await quotas()).tools.find((t) => t.id === "kimi");
try {
__resetCache();
assert.strictEqual(await kimi(), undefined);
const exp = Math.floor(Date.now() / 1000) + 600;
fs.writeFileSync(
file,
JSON.stringify({ access_token: "tok-k", refresh_token: "r", expires_at: exp })
);
__resetCache();
const row = await kimi();
assert.strictEqual(row.expiresAt, new Date(exp * 1000).toISOString());
assert.deepStrictEqual(
row.windows.map((w) => [w.label, Math.round(w.usedPercent)]),
[
["5h", 30],
["wk", 20],
["mo", 40],
]
);
assert.deepStrictEqual(
f.asked.filter((a) => a.includes("kimi")),
["https://api.kimi.com/coding/v1/usages Bearer tok-k"]
);
fs.writeFileSync(file, JSON.stringify({ access_token: "tok-k", expires_at: 1 }));
__resetCache();
assert.strictEqual((await kimi()).error, "ETOKENEXPIRED");
assert.strictEqual(f.asked.filter((a) => a.includes("kimi")).length, 1);
} finally {
f.restore();
fs.rmSync(file, { force: true });
__resetCache();
}
});
test("OpenCode Go: opencode's own `opencode-go` key asks opencode.ai for its meters", async () => {
const fs = require("node:fs");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const dir = path.join(process.env.XDG_DATA_HOME, "opencode");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "auth.json");
const f = stubFetch({
"https://opencode.ai/zen/go/v1/usage": [
200,
{
usage: {
rolling: { percent: 10, resetsAt: "2099-01-01T05:00:00Z" },
weekly: { percent: 55.5, resetsAt: "2099-01-07T00:00:00Z" },
},
},
],
});
const go = async () => (await quotas()).tools.find((t) => t.id === "opencode");
try {
__resetCache();
// A login with no Go key (Copilot through opencode, say) is no row.
fs.writeFileSync(file, JSON.stringify({ "github-copilot": { type: "oauth", access: "a" } }));
assert.strictEqual(await go(), undefined);
fs.writeFileSync(
file,
JSON.stringify({
"opencode-go": { type: "api", key: "sk-go" },
other: { type: "api", key: "x" },
})
);
__resetCache();
assert.deepStrictEqual(await go(), {
id: "opencode",
active: true,
plan: "Go",
windows: [
{ label: "5h", usedPercent: 10, resetsAt: "2099-01-01T05:00:00.000Z" },
{ label: "wk", usedPercent: 55.5, resetsAt: "2099-01-07T00:00:00.000Z" },
],
});
assert.deepStrictEqual(
f.asked.filter((a) => a.includes("opencode")),
["https://opencode.ai/zen/go/v1/usage Bearer sk-go"]
);
} finally {
f.restore();
fs.rmSync(file, { force: true });
__resetCache();
}
});
+7 -3
View File
@@ -81,9 +81,13 @@ const RULES = [
// a Gitea /raw/branch/<ref>/ URL instead.
re: /raw\.githubusercontent\.com\/[\w-]+\/[\w.-]+/g,
fix: "use a same-origin path, or a Gitea /raw/branch/<ref>/ URL",
// A third-party project that really is on GitHub: the public model price
// list server/lib/pricing-sync.js reads.
skip: [/raw\.githubusercontent\.com\/BerriAI\/litellm$/],
// Third-party projects that really are on GitHub: the public model price
// list server/lib/pricing-sync.js reads, and the install script omp.sh
// redirects to (server/data/ai-tools/omp.json `install.redirect`).
skip: [
/raw\.githubusercontent\.com\/BerriAI\/litellm$/,
/raw\.githubusercontent\.com\/can1357\/oh-my-pi$/,
],
},
{
name: "GitHub Pages host for a Smartgift site",
+15
View File
@@ -82,6 +82,21 @@ describe("scope", () => {
}
});
it("narrows ownSessionWhere to the caller's own sessions whatever the role", () => {
const own = (req) => {
const w = scope.ownSessionWhere(req);
return db
.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`)
.all(...w.params)
.map((r) => r.id);
};
assert.deepEqual(own(member(1)), ["s-unstamped", "s1"]);
assert.deepEqual(own(admin), []);
assert.deepEqual(own({ user: { ...admin.user, id: 2 } }), ["s2"]);
assert.deepEqual(own(analyst), []);
assert.equal(own(local).length, 5);
});
it("answers per-id checks for lanes, sessions, agents and runs", () => {
const m = member(1);
assert.equal(scope.canSeeLane(m, 1), true);
+14 -1
View File
@@ -57,10 +57,22 @@ before(async () => {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd) VALUES ('sx', 'host work', 'active', '/w/host')"
).run();
db.prepare(
`INSERT INTO token_usage (session_id, model, output_tokens)
VALUES ('sm', 'claude-sonnet-4-5-20250929', 1000000)`
).run();
db.prepare(
`INSERT INTO token_usage_hourly (session_id, hour, model, output_tokens)
VALUES ('sm', ?, 'claude-sonnet-4-5-20250929', 1000000)`
).run(new Date().toISOString().slice(0, 13));
// A tool with no hourly rows (omp, Codex…): its total counts from its start.
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, tool) VALUES ('so', 'mia omp', 'active', '/w/mia', ?, 'omp')"
).run(lane);
db.prepare(
`INSERT INTO token_usage (session_id, model, output_tokens)
VALUES ('so', 'claude-sonnet-4-5-20250929', 500000)`
).run();
});
after(() => {
@@ -76,7 +88,8 @@ describe("staff views", () => {
assert.equal(r.status, 200);
const mia = r.body.users.find((u) => u.username === "mia");
assert.equal(mia.workspaces, 1);
assert.equal(mia.sessions, 1);
assert.equal(mia.sessions, 2);
assert.equal(mia.today.tokens, 1500000, "a session with no hourly rows counts too");
assert.ok(mia.last7d.cost > 0 && mia.last30d.cost >= mia.last7d.cost);
// This hour's spend is today's in every timezone.
assert.equal(mia.today.cost, mia.last7d.cost);
+7 -44
View File
@@ -1,7 +1,7 @@
/**
* @file server-s3-quota.test.js
* @description S3 plan, P4: plan quota per member home. `quotas({home})`
* reads Claude, Codex, Cursor, Grok, Kimi Code and OpenCode Go from that
* reads Claude, Codex and Cursor from that
* Linux account's home only — never the host's logins, never agy — and only
* files the member owns (review fix F3: a FIFO or another account's file is
* not read). `GET /api/pricing/quota` gives a developer or an analyst their
@@ -32,16 +32,12 @@ process.env.CLAUDE_HOME = path.join(HOST, ".claude");
process.env.CODEX_HOME = path.join(HOST, ".codex");
process.env.XDG_CONFIG_HOME = path.join(HOST, ".config");
process.env.AGENTHUB_CURSOR_DIR = path.join(HOST, ".cursor");
process.env.GROK_HOME = path.join(HOST, ".grok");
process.env.KIMI_CODE_HOME = path.join(HOST, ".kimi-code");
process.env.XDG_DATA_HOME = path.join(HOST, ".local", "share");
const { createApp, startServer } = require("../index");
const auth = require("../lib/auth");
const linux = require("../lib/linux-accounts");
const runAs = require("../lib/run-as");
const { quotas, __resetCache } = require("../lib/quota");
const { Database } = require("../db");
const LATER = Date.now() + 3600_000;
const write = (file, body) => {
@@ -76,26 +72,6 @@ function seedHome(home, who, { codexPercent }) {
write(path.join(home, ".cursor", "cli-config.json"), {
authInfo: { email: `${who}@cursor`, userId: "u1" },
});
write(path.join(home, ".grok", "auth.json"), {
"https://auth.x.ai": { key: `${who}-grok`, expires_at: new Date(LATER).toISOString() },
});
write(path.join(home, ".kimi-code", "credentials", "kimi-code.json"), {
access_token: `${who}-kimi`,
expires_at: Math.floor(LATER / 1000),
});
}
/** OpenCode 2 keeps its key in `opencode.db` (WAL), not `auth.json`. */
function seedOpenCodeDb(home, key) {
const dir = path.join(home, ".local", "share", "opencode");
fs.mkdirSync(dir, { recursive: true });
const db = new Database(path.join(dir, "opencode.db"));
db.pragma?.("journal_mode = WAL");
db.exec(
"CREATE TABLE credential (integration_id TEXT, value TEXT, active INT, time_created INT)"
);
db.prepare("INSERT INTO credential VALUES ('opencode-go', ?, 1, 1)").run(JSON.stringify({ key }));
db.close();
}
let asked = [];
@@ -125,11 +101,7 @@ const setUid = (uid) => {
before(async () => {
seedHome(HOST, "host", { codexPercent: 77 });
write(path.join(HOST, ".local", "share", "opencode", "auth.json"), {
"opencode-go": { type: "api", key: "host-opencode" },
});
seedHome(MIA, "mia", { codexPercent: 21 });
seedOpenCodeDb(MIA, "mia-opencode");
setUid(process.getuid());
fs.writeFileSync(
process.env.AGENTHUB_SERVER_CONF,
@@ -159,27 +131,16 @@ describe("quotas({home}) reads a member's own logins", () => {
__resetCache();
asked = [];
const { tools } = await quotas({ home: MIA });
assert.deepEqual(tools.map((t) => t.id).sort(), [
"claude",
"codex",
"cursor",
"grok",
"kimi",
"opencode",
]);
assert.deepEqual(tools.map((t) => t.id).sort(), ["claude", "codex", "cursor"]);
const codex = tools.find((t) => t.id === "codex");
assert.equal(codex.account, "mia@codex");
assert.equal(codex.windows[0].usedPercent, 21);
assert.equal(tools.find((t) => t.id === "cursor").account, "mia@cursor");
assert.ok(asked.length >= 6, asked.join(","));
assert.ok(asked.length >= 3, asked.join(","));
assert.ok(
asked.every((a) => a.includes("mia-")),
`only mia's tokens leave: ${asked.join(",")}`
);
assert.ok(
asked.some((a) => a.includes("mia-opencode")),
"OpenCode 2's db is read"
);
assert.ok(tools.every((t) => t.label === undefined || t.label === null));
});
@@ -195,8 +156,10 @@ describe("quotas({home}) reads a member's own logins", () => {
});
it("reads no file the member does not own, and never blocks on a FIFO", async () => {
fs.mkdirSync(path.join(BOB, ".grok"), { recursive: true });
require("node:child_process").execFileSync("mkfifo", [path.join(BOB, ".grok", "auth.json")]);
fs.mkdirSync(path.join(BOB, ".config", "cursor"), { recursive: true });
require("node:child_process").execFileSync("mkfifo", [
path.join(BOB, ".config", "cursor", "auth.json"),
]);
__resetCache();
assert.deepEqual((await quotas({ home: BOB })).tools, []);
// mia's files now belong to another uid: none is read.
+2 -2
View File
@@ -37,11 +37,11 @@ describe("what a publish copies", () => {
it("a file in an npm package: the package folder (scoped too)", () => {
const plain = write(
path.join(TMP, "npm", "lib", "node_modules", "opencode-ai", "bin", "oc.js"),
path.join(TMP, "npm", "lib", "node_modules", "some-cli", "bin", "oc.js"),
"#!/usr/bin/env node\n"
);
assert.deepEqual(shared.planFor(plain), {
src: path.join(TMP, "npm", "lib", "node_modules", "opencode-ai"),
src: path.join(TMP, "npm", "lib", "node_modules", "some-cli"),
entry: path.join("bin", "oc.js"),
});
const scoped = write(
+1 -1
View File
@@ -101,7 +101,7 @@ describe("todayCost", () => {
it("reads today's row and caches it", async () => {
const file = path.join(TMP, "cache-a.json");
assert.equal(await todayCost({ url, file }), 3.5);
assert.equal(lastUrl, `/api/pricing/cost?tz_offset=${new Date().getTimezoneOffset()}`);
assert.equal(lastUrl, `/api/pricing/cost?tz_offset=${new Date().getTimezoneOffset()}&mine=1`);
assert.equal(await todayCost({ url, file }), 3.5);
assert.equal(hits, 1);
// A different dashboard URL is a cache miss.
+184
View File
@@ -0,0 +1,184 @@
/**
* @file Tests for counting every token a Claude session spent: the hook
* writer adds the session's sub-agent transcripts (`lib/subagent-usage.js`)
* to its main one — total and per hour, a sub-agent on the session's own
* model included, and a sub-agent that grew alone is picked up — and the
* importer raises a session it first saw with an empty transcript once the
* transcript has grown (`importSession`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after } = require("node:test");
const assert = require("node:assert/strict");
const path = require("path");
const fs = require("fs");
const os = require("os");
const http = require("http");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-subusage-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
process.env.CLAUDE_HOME = path.join(TMP, "home");
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
const { createApp, startServer } = require("../index");
const dbModule = require("../db");
const { db } = dbModule;
const importHistory = require("../../scripts/import-history");
const { subagentUsage } = require("../lib/subagent-usage");
let server;
let BASE;
function post(body) {
return new Promise((resolve, reject) => {
const url = new URL("/api/hooks/event", BASE);
const payload = JSON.stringify(body);
const r = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
},
},
(res) => {
res.resume();
res.on("end", () => resolve(res.statusCode));
}
);
r.on("error", reject);
r.write(payload);
r.end();
});
}
const line = (o) => JSON.stringify(o) + "\n";
const assistant = (id, inTok, ts, model = "claude-sonnet-5") => ({
type: "assistant",
timestamp: ts,
message: {
id,
model,
content: [{ type: "text", text: "ok" }],
usage: { input_tokens: inTok, output_tokens: 1 },
},
});
const total = (sid) =>
db
.prepare(
"SELECT model, SUM(input_tokens + baseline_input) AS input FROM token_usage WHERE session_id = ? GROUP BY model ORDER BY model"
)
.all(sid)
.map((r) => ({ ...r }));
const hours = (sid) =>
db
.prepare(
"SELECT hour, model, SUM(input_tokens) AS input FROM token_usage_hourly WHERE session_id = ? GROUP BY hour, model ORDER BY hour, model"
)
.all(sid)
.map((r) => ({ ...r }));
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => {
server?.close();
fs.rmSync(TMP, { recursive: true, force: true });
});
describe("sub-agent usage", () => {
it("counts a session's sub-agents, own model included, in its total and its hours", async () => {
const sid = "cdcdcdcd-1111-4111-8111-cdcdcdcdcdcd";
const cwd = path.join(TMP, "work");
fs.mkdirSync(cwd, { recursive: true });
const tpath = path.join(TMP, `${sid}.jsonl`);
fs.writeFileSync(tpath, line(assistant("m1", 100, "2026-10-22T01:00:00.000Z")));
const subDir = path.join(TMP, sid, "subagents");
fs.mkdirSync(subDir, { recursive: true });
const sub1 = path.join(subDir, "agent-a1.jsonl");
fs.writeFileSync(sub1, line(assistant("s1", 40, "2026-10-22T02:00:00.000Z")));
fs.writeFileSync(
path.join(subDir, "agent-a2.jsonl"),
line(assistant("s2", 7, "2026-10-22T02:30:00.000Z", "claude-haiku-5-5"))
);
// A Workflow agent is booked by workflow-ingest under its own tier: not here.
fs.mkdirSync(path.join(subDir, "workflows", "run1"), { recursive: true });
fs.writeFileSync(
path.join(subDir, "workflows", "run1", "agent-w.jsonl"),
line(assistant("w1", 1000, "2026-10-22T02:00:00.000Z"))
);
const data = { session_id: sid, cwd, transcript_path: tpath };
assert.equal(await post({ hook_type: "PostToolUse", data }), 200);
assert.deepEqual(total(sid), [
{ model: "claude-haiku-5-5", input: 7 },
{ model: "claude-sonnet-5", input: 140 },
]);
assert.deepEqual(hours(sid), [
{ hour: "2026-10-22T01", model: "claude-sonnet-5", input: 100 },
{ hour: "2026-10-22T02", model: "claude-haiku-5-5", input: 7 },
{ hour: "2026-10-22T02", model: "claude-sonnet-5", input: 40 },
]);
// Only the sub-agent grew: the next hook still counts it, once.
fs.appendFileSync(sub1, line(assistant("s3", 10, "2026-10-22T03:00:00.000Z")));
assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "x" } }), 200);
assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "y" } }), 200);
assert.deepEqual(total(sid), [
{ model: "claude-haiku-5-5", input: 7 },
{ model: "claude-sonnet-5", input: 150 },
]);
assert.equal(hours(sid).find((h) => h.hour === "2026-10-22T03").input, 10);
// The importer's full re-read agrees and changes nothing (high-water).
await importHistory.reconcileTokens(dbModule, { exact: false });
assert.deepEqual(total(sid), [
{ model: "claude-haiku-5-5", input: 7 },
{ model: "claude-sonnet-5", input: 150 },
]);
});
it("is null for a session with no sub-agent transcript", () => {
assert.equal(subagentUsage(path.join(TMP, "nope.jsonl")), null);
assert.equal(subagentUsage(null), null);
});
it("raises an imported session first seen before its transcript had any usage", async () => {
const sid = "efefefef-2222-4222-8222-efefefefefef";
const proj = path.join(TMP, "projects", "-tmp-eval");
fs.mkdirSync(proj, { recursive: true });
const tpath = path.join(proj, `${sid}.jsonl`);
const user = {
type: "user",
sessionId: sid,
cwd: "/tmp/eval",
timestamp: "2026-10-23T03:00:00.000Z",
message: { role: "user", content: "fix the bug" },
};
fs.writeFileSync(tpath, line(user));
const parse = async () => {
const s = await importHistory.parseSessionFile(tpath);
s.parsedSubagents = [];
return s;
};
importHistory.importSession(dbModule, await parse());
assert.deepEqual(total(sid), []);
fs.appendFileSync(
tpath,
line({ ...assistant("e1", 500, "2026-10-23T03:01:00.000Z"), sessionId: sid })
);
const again = importHistory.importSession(dbModule, await parse());
assert.equal(again.backfilled, true);
assert.deepEqual(total(sid), [{ model: "claude-sonnet-5", input: 500 }]);
// Nothing new: no backfill reported, no change.
assert.equal(importHistory.importSession(dbModule, await parse()).backfilled, undefined);
assert.deepEqual(total(sid), [{ model: "claude-sonnet-5", input: 500 }]);
});
});
+1 -1
View File
@@ -13,7 +13,7 @@ id follows, as one more argv element — `pty-run.js` emits them in place of
`resume` when a start carries `resumeSessionId`, and only for an id that starts
with a letter or digit. A tool without it (and without a `launcher`) refuses a
resume by id (`EUNSUPPORTED`) rather than start fresh under a "resume".
Built-in: omp, Grok and Cursor (`--resume`), OpenCode (`--session`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_` (OpenCode's `ses_…`).
Built-in: omp and Cursor (`--resume`). omp always gets `--allow-home`: started in its account's home (a workspace there) it would otherwise move to a temp dir, and its session would name that folder, not the workspace's. A session id may carry `_`.
## Install / update (A2)
-41
View File
@@ -1,41 +0,0 @@
{
"id": "grok",
"label": "Grok Build",
"detect": [
"grok"
],
"prompt": {
"mode": "argv",
"separator": "--"
},
"model": {
"flag": "--model"
},
"resume": [
"--continue"
],
"resumeById": [
"--resume"
],
"bypass": [
"--permission-mode",
"bypassPermissions"
],
"homepageUrl": "https://x.ai/cli",
"faviconDomain": "x.ai",
"install": {
"kind": "script",
"url": "https://x.ai/cli/install.sh"
},
"update": {
"kind": "self",
"args": [
"update"
]
},
"config": [
{
"path": "~/.grok/config.toml"
}
]
}
+2 -1
View File
@@ -26,7 +26,8 @@
"faviconDomain": "omp.sh",
"install": {
"kind": "script",
"url": "https://omp.sh/install"
"url": "https://omp.sh/install",
"redirect": "https://raw.githubusercontent.com/can1357/oh-my-pi/main/scripts/install.sh"
},
"update": {
"kind": "script"
-37
View File
@@ -1,37 +0,0 @@
{
"id": "opencode",
"label": "OpenCode",
"detect": [
"opencode"
],
"prompt": {
"mode": "flag",
"flag": "--prompt"
},
"model": {
"flag": "--model"
},
"resume": [
"--continue"
],
"resumeById": [
"--session"
],
"homepageUrl": "https://opencode.ai/docs/cli/",
"install": {
"kind": "npm",
"package": "opencode-ai"
},
"update": {
"kind": "npm"
},
"config": [
{
"path": "~/.config/opencode/opencode.json",
"format": "jsonc"
},
{
"path": "~/.config/opencode/opencode.jsonc"
}
]
}
+6 -17
View File
@@ -964,9 +964,15 @@ try {
// (`captureOutputs`), not pinned by hand. `suggested_owner_id`: for a row with
// no owner, the user whose folder the session ran in — they may claim it or
// say it is not theirs (`claim`/`decline`); an admin assigns any (`assign`).
// `version`: a skill snapshot's content hash; `source_hash`: its source
// folder's hash when last taken — a skill follows its owner's folder and an
// installer whose copy differs from `version` is offered Update
// (`syncSkillSources`/`updateCheck`). NULL until first computed.
for (const [col, ddl] of [
["auto", "auto INTEGER NOT NULL DEFAULT 0"],
["suggested_owner_id", "suggested_owner_id INTEGER"],
["version", "version TEXT"],
["source_hash", "source_hash TEXT"],
]) {
try {
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
@@ -1486,23 +1492,6 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_omp_sessions_session ON omp_sessions(session_id);
`);
// Which OpenCode stores have been read at what mtime, and per root session the
// newest `time_updated` written (server/lib/opencode-usage.js) — the same
// "deleted stays deleted" rule.
db.exec(`
CREATE TABLE IF NOT EXISTS opencode_stores (
path TEXT PRIMARY KEY,
mtime_ms INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE TABLE IF NOT EXISTS opencode_sessions (
session_id TEXT PRIMARY KEY,
store TEXT NOT NULL,
time_updated INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
`);
// Which Cursor agent transcripts have been read, at what mtime
// (server/lib/cursor-usage.js) — the same "deleted stays deleted" rule.
db.exec(`
+30 -11
View File
@@ -690,6 +690,31 @@ function startBackgroundServices() {
}
}
// One-time (user_version 5) raise of every session's totals to its whole
// transcript plus its sub-agents' (raise only: `exact: false`). Before
// v2.8.23 a live session never counted a sub-agent on its own model, and a
// session imported the moment its file appeared (a `claude -p` run with no
// hooks) kept the usage of that moment, often none — both now counted as
// they happen (server/lib/subagent-usage.js, importSession). Waits for
// marker 4; written only on success, so a crash retries on the next start.
{
const dbModule = require("./db");
if (dbModule.db.pragma("user_version", { simple: true }) === 4) {
const t = setTimeout(() => {
require("../scripts/import-history")
.reconcileTokens(dbModule, { exact: false })
.then((c) => {
console.log(
`Token catch-up: re-read ${c.sessionsTouched} session(s), ${c.modelsWritten} model row(s)`
);
dbModule.db.pragma("user_version = 5");
})
.catch((err) => console.warn("token catch-up failed:", err?.message || err));
}, 15_000);
if (t.unref) t.unref();
}
}
// Per-hour tokens for sessions recorded before `token_usage_hourly`, so the
// daily cost split covers them too. Picks only sessions with no hourly rows,
// so a later start re-reads nothing already done; non-blocking.
@@ -768,17 +793,11 @@ function startBackgroundServices() {
} catch (err) {
console.warn("cursor usage poll failed to start:", err.message);
}
// Oh My Pi and OpenCode sessions, subagents and token usage, from each CLI's
// own session store (omp-usage.js, opencode-usage.js).
for (const [name, mod, start] of [
["omp", "./lib/omp-usage", "startOmpUsagePoll"],
["opencode", "./lib/opencode-usage", "startOpencodeUsagePoll"],
]) {
try {
require(mod)[start](broadcast);
} catch (err) {
console.warn(`${name} usage poll failed to start:`, err.message);
}
// Oh My Pi sessions, subagents and token usage, from its own session journals.
try {
require("./lib/omp-usage").startOmpUsagePoll(broadcast);
} catch (err) {
console.warn("omp usage poll failed to start:", err.message);
}
// Codex/Antigravity hooks the user installed forward through a script whose
// content names this build; point it here (the command in the tool's
+207
View File
@@ -0,0 +1,207 @@
/**
* @file agent-edits.js
* @description The files a Claude subagent wrote or edited, read from its own
* transcript (`agent-<id>.jsonl`), so the parent's chat can count them in its
* turn's "Edited N files" card (issue #86: a skill that hands its work to
* subagents showed only the parent's own edits). Each file's +/- lines come
* from the same `patchCounts` the chat uses; a failed call counts nothing; a
* subagent's own subagents are folded in, `MAX_DEPTH` levels down. The file is
* read through `readLines` (a member's transcript only as that member's), and
* the answer is cached by path + size + mtime, so a finished subagent is read
* once however often its parent's chat is polled. `turnCarry` does the same
* for the part of a turn the chat's page of messages cut off.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const { patchCounts, agentInfo } = require("./transcript-message");
const {
getSubagentTranscriptPath,
findSubagentTranscriptPath,
getSnapshotSubagentTranscriptPath,
} = require("./claude-home");
const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
const AGENT_TOOLS = new Set(["Agent", "Task"]);
const MAX_DEPTH = 3;
const CACHE_MAX = 500;
// path → { key: "<size>:<mtime>", edits, agents }
const cache = new Map();
/** One transcript's own edits and the subagents it started. */
async function scan(file) {
const { readLines } = require("./run-as");
const pending = new Map(); // tool_use id → file path
const edits = new Map(); // path → { added, removed, counted }
const agents = [];
for await (const line of readLines(file)) {
if (!line.trim()) continue;
let entry;
try {
entry = JSON.parse(line);
} catch {
continue;
}
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
if (entry.type === "assistant") {
for (const b of content) {
if (b?.type !== "tool_use" || !EDIT_TOOLS.has(b.name) || !b.id) continue;
const p = b.input?.file_path ?? b.input?.notebook_path;
if (typeof p === "string" && p) pending.set(b.id, p);
}
} else if (entry.type === "user") {
for (const b of content) {
if (b?.type !== "tool_result") continue;
const agent = agentInfo(entry.toolUseResult);
if (agent) agents.push(agent.id);
const p = pending.get(b.tool_use_id);
if (!p || b.is_error) continue;
const diff = patchCounts(entry.toolUseResult);
const f = edits.get(p) ?? { added: 0, removed: 0, counted: false };
f.added += diff?.added ?? 0;
f.removed += diff?.removed ?? 0;
f.counted ||= !!diff;
edits.set(p, f);
}
}
}
return { edits, agents };
}
async function scanCached(file) {
const st = await fs.promises.stat(file);
const key = `${st.size}:${st.mtimeMs}`;
const hit = cache.get(file);
if (hit && hit.key === key) return hit;
const fresh = { key, ...(await scan(file)) };
cache.delete(file);
cache.set(file, fresh);
if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
return fresh;
}
const transcriptOf = (sessionId, cwd, agentId) =>
getSubagentTranscriptPath(sessionId, cwd, agentId) ||
findSubagentTranscriptPath(sessionId, agentId, null, cwd) ||
getSnapshotSubagentTranscriptPath(sessionId, agentId);
/**
* The files subagent `agentId` of session `sessionId` (and its own subagents)
* changed, as `[{ path, added, removed, counted }]` in first-edit order; an
* empty list when its transcript cannot be found or read.
*/
async function agentEdits(sessionId, cwd, agentId, depth = 0, seen = new Set()) {
if (seen.has(agentId)) return [];
seen.add(agentId);
let file;
let got;
try {
file = transcriptOf(sessionId, cwd, agentId);
if (!file) return [];
got = await scanCached(file);
} catch {
return [];
}
const out = new Map();
const add = (p, f) => {
const o = out.get(p) ?? { added: 0, removed: 0, counted: false };
o.added += f.added;
o.removed += f.removed;
o.counted ||= f.counted;
out.set(p, o);
};
got.edits.forEach((f, p) => add(p, f));
if (depth < MAX_DEPTH) {
for (const id of got.agents) {
for (const f of await agentEdits(sessionId, cwd, id, depth + 1, seen)) add(f.path, f);
}
}
return [...out].map(([path, f]) => ({ path, ...f }));
}
/** Puts `edits` on every Agent/Task result in `messages` (in place). */
async function attachAgentEdits(messages, sessionId, cwd) {
for (const m of messages) {
for (const c of m.content ?? []) {
if (c.type !== "tool_result" || !c.agent?.id || c.is_error) continue;
const edits = await agentEdits(sessionId, cwd, c.agent.id);
if (edits.length) c.edits = edits;
}
}
}
/** A human prompt: the line that starts a turn (the chat's own rule). */
const isPrompt = (m) =>
(m.sender ?? (m.type === "assistant" ? "assistant" : "user")) === "user" &&
(m.content ?? []).some((c) => c.type === "text");
/**
* The edits of the turn a page of the chat starts in the middle of. The route
* keeps only the newest `limit` messages; every message it drops goes through
* `drop()`, which keeps the file changes since the last prompt it dropped.
* `result(window)` then answers the files that turn changed BEFORE the page's
* first message — `[{ path, added, removed, counted }]`, or null when the page
* starts on a prompt or nothing was changed — so the turn's "Edited N files"
* card is whole although its start was never sent (issue #86: a 120-message
* turn showed 1 of its files).
*/
function turnCarry() {
let pending = new Map(); // dropped Write/Edit call id → path
let pendingAgents = new Set(); // dropped Agent/Task call ids
let files = new Map();
let agents = [];
const add = (p, f) => {
const o = files.get(p) ?? { added: 0, removed: 0, counted: false };
o.added += f?.added ?? 0;
o.removed += f?.removed ?? 0;
o.counted ||= !!f && f.counted !== false;
files.set(p, o);
};
return {
drop(m) {
if (!m) return;
if (isPrompt(m)) {
pending = new Map();
pendingAgents = new Set();
files = new Map();
agents = [];
return;
}
for (const c of m.content ?? []) {
if (c.type === "tool_use" && c.id) {
if (AGENT_TOOLS.has(c.name)) pendingAgents.add(c.id);
if (!EDIT_TOOLS.has(c.name)) continue;
const p = c.input?.file_path ?? c.input?.notebook_path;
if (typeof p === "string" && p) pending.set(c.id, p);
} else if (c.type === "tool_result") {
if (c.agent?.id && !c.is_error) agents.push(c.agent.id);
pendingAgents.delete(c.id);
const p = pending.get(c.id);
if (!p) continue;
pending.delete(c.id);
if (!c.is_error) add(p, c.diff ?? null);
}
}
},
async result(window, sessionId, cwd) {
if (!window.length || isPrompt(window[0])) return null;
// A call dropped whose result is on the page: the page shows the result
// with no call to hang it on, so it counts here.
for (const m of window) {
for (const c of m.content ?? []) {
if (c.type !== "tool_result" || c.is_error) continue;
const p = pending.get(c.id);
if (p) add(p, c.diff ?? null);
if (pendingAgents.has(c.id)) for (const e of c.edits ?? []) add(e.path, e);
}
}
for (const id of agents) {
for (const e of await agentEdits(sessionId, cwd, id)) add(e.path, e);
}
return files.size ? [...files].map(([path, f]) => ({ path, ...f })) : null;
},
};
}
module.exports = { agentEdits, attachAgentEdits, turnCarry, _cache: cache };
+20 -7
View File
@@ -63,12 +63,21 @@ async function stepsFor(id, action) {
if (d.installed) throw makeErr("EINSTALLED", `${tool.label} is already installed`);
return tool.install.kind === "npm"
? [{ argv: npmArgv(tool.install.package, await versions.installPrefixArgs()) }]
: [{ script: tool.install.url }];
: [{ script: tool.install.url, redirect: tool.install.redirect }];
}
if (!tool.update) throw makeErr("ENOUPDATE", `${tool.label} has no update command`);
if (!d.installed) throw makeErr("ENOTINSTALLED", `${tool.label} is not installed`);
if (tool.update.kind === "self") return [{ argv: [d.path, ...tool.update.args] }];
if (tool.update.kind === "script") return [{ script: tool.install.url }];
if (tool.update.kind === "self") {
// An npm package's own updater (`codex update`) reinstalls into npm's
// GLOBAL prefix; one we installed under ~/.local must be updated there.
if (tool.install?.kind === "npm" && (await versions.managedBy(id)) === "user") {
return [{ argv: npmArgv(tool.install.package, ["--prefix", versions.userPrefix()]) }];
}
return [{ argv: [d.path, ...tool.update.args] }];
}
if (tool.update.kind === "script") {
return [{ script: tool.install.url, redirect: tool.install.redirect }];
}
const by = await versions.managedBy(id);
if (!by) {
throw makeErr(
@@ -85,12 +94,16 @@ function append(j, text) {
if (j.log.length > LOG_CAP) j.log = j.log.slice(-LOG_CAP);
}
/** Download a vendor script to a 0600 temp file; the final URL must stay on its site. */
async function download(url) {
/**
* Download a vendor script to a 0600 temp file; the final URL must stay on its
* site, or be exactly the catalog's `redirect`.
*/
async function download(url, redirect) {
const res = await fetch(url, { signal: AbortSignal.timeout(15_000), redirect: "follow" });
const final = new URL(res.url || url);
const site = aiTools.siteOf(new URL(url).hostname);
if (final.protocol !== "https:" || aiTools.siteOf(final.hostname) !== site) {
const onSite = final.protocol === "https:" && aiTools.siteOf(final.hostname) === site;
if (!onSite && final.href !== redirect) {
throw new Error(`script redirected off ${site}: ${final.href}`);
}
if (!res.ok) throw new Error(`script download failed: HTTP ${res.status}`);
@@ -169,7 +182,7 @@ async function runSteps(j, steps, onStepOk) {
let tmp = null;
if (step.script) {
append(j, `$ download ${step.script}\n`);
tmp = await download(step.script);
tmp = await download(step.script, step.redirect);
argv = ["bash", tmp];
}
try {
+17 -4
View File
@@ -44,18 +44,24 @@ const isStrArray = (v, re) =>
// wrong for co.uk-style hosts (none in the catalog); swap in a PSL if one comes.
const siteOf = (host) => host.toLowerCase().split(".").slice(-2).join(".");
/** The script URL, when it is https on the homepage's site; else null. */
function scriptUrl(url, homepageUrl) {
/** A plain https URL (no userinfo, no port), normalised; else null. */
function httpsUrl(url) {
try {
const u = new URL(url);
const home = new URL(homepageUrl);
if (u.protocol !== "https:" || u.username || u.password || u.port) return null;
return siteOf(u.hostname) === siteOf(home.hostname) ? u.href : null;
return u;
} catch {
return null;
}
}
/** The script URL, when it is https on the homepage's site; else null. */
function scriptUrl(url, homepageUrl) {
const u = httpsUrl(url);
const home = httpsUrl(homepageUrl);
return u && home && siteOf(u.hostname) === siteOf(home.hostname) ? u.href : null;
}
/** `{install, update}` normalised, or null when either is malformed. */
function installFields(doc) {
let install = null;
@@ -67,6 +73,13 @@ function installFields(doc) {
const url = scriptUrl(i.url, doc.homepageUrl);
if (!url) return null;
install = { kind: "script", url };
// The ONE exact URL the download may redirect to off the homepage's site
// (omp.sh/install → raw.githubusercontent.com); never a site wildcard.
if (i.redirect !== undefined) {
const to = typeof i.redirect === "string" && httpsUrl(i.redirect);
if (!to) return null;
install.redirect = to.href;
}
} else return null;
}
let update = null;

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