Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b602c76b8 | |||
| f2f1cd9b6c | |||
| c59e196e41 | |||
| 72aef2426c | |||
| 244f999538 | |||
| 127da516ac | |||
| c0832182f2 | |||
| 9ea7758abb | |||
| f51a6a895e | |||
| 5ccbaf5c23 | |||
| c8bc4a95a9 | |||
| 65a001dad4 | |||
| 572d615d4e |
Binary file not shown.
|
Before Width: | Height: | Size: 411 KiB After Width: | Height: | Size: 465 KiB |
@@ -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 |
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -88,5 +88,6 @@
|
||||
"now": "now",
|
||||
"users": "Users",
|
||||
"admin": "Administration",
|
||||
"files": "Files"
|
||||
"files": "Files",
|
||||
"snapshot": "Snapshot"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}}"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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(/[\\/]$/, "")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+780
-308
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+1
-1
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+4
-7
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.21",
|
||||
"version": "2.8.25",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
+19
-20
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "grok",
|
||||
"label": "Grok",
|
||||
"detect": ["grok"],
|
||||
"id": "septool",
|
||||
"label": "Separator tool",
|
||||
"detect": ["septool"],
|
||||
"prompt": {
|
||||
"mode": "argv",
|
||||
"separator": "--"
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"] });
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user