Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b602c76b8 | |||
| f2f1cd9b6c | |||
| c59e196e41 | |||
| 72aef2426c | |||
| 244f999538 | |||
| 127da516ac | |||
| c0832182f2 | |||
| 9ea7758abb | |||
| f51a6a895e | |||
| 5ccbaf5c23 | |||
| c8bc4a95a9 | |||
| 65a001dad4 | |||
| 572d615d4e | |||
| 9dd97a369c | |||
| 62b4520507 |
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,7 +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; "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
|
||||
@@ -184,6 +189,8 @@ export function UpdateNotifier() {
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [fixing, setFixing] = useState(false);
|
||||
/** The running plugin chain's step, from the polls while it runs. */
|
||||
const [progress, setProgress] = useState<UpdateStatusPayload["plugin_update_progress"]>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
||||
@@ -238,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;
|
||||
|
||||
@@ -312,6 +320,7 @@ export function UpdateNotifier() {
|
||||
api.updates
|
||||
.status()
|
||||
.then((s) => {
|
||||
setProgress(s.plugin_update_progress ?? null);
|
||||
const v = s.last_plugin_update;
|
||||
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
|
||||
// A failed step, or nothing to pull, leaves the OLD server up: it
|
||||
@@ -339,9 +348,23 @@ 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);
|
||||
setProgress(null);
|
||||
setApplying(true);
|
||||
try {
|
||||
const result = await api.updates.apply();
|
||||
@@ -392,6 +415,54 @@ export function UpdateNotifier() {
|
||||
}
|
||||
};
|
||||
|
||||
const stepText = progress?.step
|
||||
? t(`pluginStep.${progress.step}`, {
|
||||
index: progress.index,
|
||||
total: progress.total,
|
||||
defaultValue: progress.step,
|
||||
})
|
||||
: null;
|
||||
|
||||
// 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"
|
||||
data-testid="update-chip"
|
||||
onClick={() => setClosedKey(null)}
|
||||
title={t("chipHint")}
|
||||
className="fixed bottom-24 right-4 z-[60] w-72 rounded-lg border border-accent/30 bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
|
||||
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" aria-hidden />
|
||||
<span className="truncate">
|
||||
{status?.latest_release
|
||||
? t("chipTitle", { version: status.latest_release })
|
||||
: t("updating")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-[11px] text-fg-muted">
|
||||
{stepText || (applying ? t("updating") : t("restartingShort"))}
|
||||
</span>
|
||||
{progress && progress.total > 0 ? (
|
||||
<span
|
||||
className="mt-1.5 block h-1 overflow-hidden rounded bg-surface-2"
|
||||
role="progressbar"
|
||||
aria-valuenow={progress.index}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={progress.total}
|
||||
>
|
||||
<span
|
||||
className="block h-full bg-accent-solid transition-all"
|
||||
style={{ width: `${(Math.max(progress.index - 1, 0) / progress.total) * 100}%` }}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!show || !status) return null;
|
||||
|
||||
const refLabel = status.remote_ref || "origin";
|
||||
@@ -520,7 +591,14 @@ export function UpdateNotifier() {
|
||||
{/* The plugin chain pulls a new cache directory and reinstalls
|
||||
dependencies before it restarts, so it takes minutes, not
|
||||
seconds — saying "restarting" there would look hung. */}
|
||||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||||
<span>
|
||||
{pluginManaged ? t("pluginUpdating") : t("restarting")}
|
||||
{stepText ? (
|
||||
<span className="block mt-0.5 font-medium" data-testid="update-step">
|
||||
{stepText}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -312,6 +312,91 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotifier — hidden while it runs", () => {
|
||||
it("keeps a chip with the chain's step in sight and re-opens from it", async () => {
|
||||
const PLUGIN: 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",
|
||||
};
|
||||
statusMock.mockResolvedValue(PLUGIN);
|
||||
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
|
||||
statusMock.mockResolvedValue({
|
||||
...PLUGIN,
|
||||
plugin_update_progress: {
|
||||
step: "plugin_update",
|
||||
index: 2,
|
||||
total: 3,
|
||||
started_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
const step = i18n.t("updates:pluginStep.plugin_update", { index: 2, total: 3 });
|
||||
expect(await screen.findByTestId("update-step", {}, { timeout: 4000 })).toHaveTextContent(step);
|
||||
|
||||
await userEvent.keyboard("{Escape}");
|
||||
const chip = await screen.findByTestId("update-chip");
|
||||
expect(chip).toHaveTextContent(i18n.t("updates:chipTitle", { version: "v1.6.0" }));
|
||||
expect(chip).toHaveTextContent(step);
|
||||
await userEvent.click(chip);
|
||||
expect(
|
||||
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }))
|
||||
).toBeInTheDocument();
|
||||
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"));
|
||||
await userEvent.keyboard("{Escape}");
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||
);
|
||||
expect(screen.queryByTestId("update-chip")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotifier — Auto fix", () => {
|
||||
it("offers no Not now button, and Auto fix only once an update failed", async () => {
|
||||
applyMock.mockRejectedValue(new Error("Command failed: git merge --ff-only v2.8.0"));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
* folder's account with that account's own SSH key / HTTPS login — and opens
|
||||
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
|
||||
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
|
||||
* A running clone shows `CloneProgress` and never holds the page: closing the
|
||||
* dialog only hides it behind a small progress chip (click to reopen), the
|
||||
* request keeps going, and its outcome arrives as a toast — a failure is kept
|
||||
* for the next open so a login row is still there to fill in.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -18,6 +22,8 @@ import { api } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { cloneDirName } from "./AddLaneModal";
|
||||
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
@@ -26,12 +32,15 @@ export function CloneHereDialog({
|
||||
parent,
|
||||
onClose,
|
||||
onCloned,
|
||||
onReopen,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** The absolute folder the clone goes into. */
|
||||
parent: string;
|
||||
onClose: () => void;
|
||||
onCloned: (lane: Lane, path: string) => void;
|
||||
/** Show the dialog again from the background chip. */
|
||||
onReopen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -42,10 +51,23 @@ export function CloneHereDialog({
|
||||
const [sshRefused, setSshRefused] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useDialog(open, panelRef, busy ? () => {} : onClose);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
/** Where the running clone goes (the open folder may change meanwhile). */
|
||||
const [target, setTarget] = useState("");
|
||||
// Read by the request's own continuation and by the reset below.
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
const busyRef = useRef(false);
|
||||
/** A clone failed while hidden: the next open shows that, not a blank form. */
|
||||
const keepRef = useRef(false);
|
||||
useDialog(open, panelRef, onClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!open || busyRef.current) return;
|
||||
if (keepRef.current) {
|
||||
keepRef.current = false;
|
||||
return;
|
||||
}
|
||||
setUrl("");
|
||||
setName("");
|
||||
setLogin(null);
|
||||
@@ -54,14 +76,39 @@ export function CloneHereDialog({
|
||||
setError(null);
|
||||
}, [open, parent]);
|
||||
|
||||
if (!open) return null;
|
||||
const folder = name.trim() || cloneDirName(url);
|
||||
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||
const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||
|
||||
if (!open) {
|
||||
if (!busy || !token) return null;
|
||||
return createPortal(
|
||||
<button
|
||||
type="button"
|
||||
data-testid="fx-clone-chip"
|
||||
onClick={onReopen}
|
||||
title={into}
|
||||
className="fixed bottom-10 right-4 z-[60] w-72 space-y-1 rounded-lg border border-border bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
|
||||
<FolderGit2 className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("clone.cloning")} {into.split("/").pop()}
|
||||
</span>
|
||||
</span>
|
||||
<CloneProgress token={token} />
|
||||
</button>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
const u = url.trim();
|
||||
if (!u || busy) return;
|
||||
const progressToken = newCloneToken();
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setToken(progressToken);
|
||||
setTarget(into);
|
||||
setError(null);
|
||||
setSshRefused(false);
|
||||
try {
|
||||
@@ -69,6 +116,7 @@ export function CloneHereDialog({
|
||||
url: u,
|
||||
parent,
|
||||
name: name.trim() || undefined,
|
||||
progressToken,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
@@ -79,12 +127,16 @@ export function CloneHereDialog({
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
setToken(null);
|
||||
onCloned(result.lane, result.path);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || u;
|
||||
let message: string;
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
||||
message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
|
||||
setLogin((l) => ({
|
||||
host,
|
||||
username: l?.username ?? "",
|
||||
@@ -93,18 +145,25 @@ export function CloneHereDialog({
|
||||
}));
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
setSshRefused(true);
|
||||
setError(t("clone.sshRefused", { host }));
|
||||
message = t("clone.sshRefused", { host });
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
message = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
setError(message);
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
setToken(null);
|
||||
if (!openRef.current) {
|
||||
keepRef.current = true;
|
||||
toast.error(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||
onClick={busy ? undefined : onClose}
|
||||
onClick={onClose}
|
||||
// A portal still bubbles through React: keep keys away from the File
|
||||
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
@@ -119,7 +178,10 @@ export function CloneHereDialog({
|
||||
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<h2
|
||||
id={titleId}
|
||||
className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<FolderGit2 className="h-4 w-4" />
|
||||
{t("fx.cloneRepo")}
|
||||
</h2>
|
||||
@@ -162,7 +224,10 @@ export function CloneHereDialog({
|
||||
</p>
|
||||
</div>
|
||||
{login && (
|
||||
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
|
||||
<div
|
||||
data-testid="fx-clone-login"
|
||||
className="space-y-2 rounded-md border border-border p-2"
|
||||
>
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
@@ -193,6 +258,7 @@ export function CloneHereDialog({
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{busy && token && <CloneProgress token={token} />}
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}{" "}
|
||||
@@ -204,8 +270,8 @@ export function CloneHereDialog({
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
|
||||
{t("fx.cancel")}
|
||||
<button type="button" onClick={onClose} className="btn-ghost text-xs">
|
||||
{busy ? t("clone.background") : t("fx.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -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;
|
||||
@@ -974,6 +975,7 @@ export function FileBrowser({
|
||||
open={cloning}
|
||||
parent={cloneParent}
|
||||
onClose={() => setCloning(false)}
|
||||
onReopen={() => setCloning(true)}
|
||||
onCloned={(lane, at) => {
|
||||
setCloning(false);
|
||||
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
||||
@@ -1881,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 }),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
lanes: {
|
||||
list: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
cloneProgress: vi.fn(),
|
||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||
},
|
||||
files: {
|
||||
@@ -553,12 +554,72 @@ describe("FileExplorer — New → Clone repository", () => {
|
||||
url: "git@host:o/repo.git",
|
||||
parent: "/w/one/src",
|
||||
name: undefined,
|
||||
progressToken: expect.stringMatching(/^[0-9a-f]{32}$/),
|
||||
})
|
||||
);
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows progress and lets the page go on while the clone runs", async () => {
|
||||
let finish: (v: unknown) => void = () => {};
|
||||
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((r) => (finish = r)) as never);
|
||||
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||
state: "running",
|
||||
position: null,
|
||||
running: 1,
|
||||
waiting: 0,
|
||||
limit: 2,
|
||||
phase: "receiving",
|
||||
label: null,
|
||||
percent: 45,
|
||||
detail: "450/1000",
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
expect(await within(dialog).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||
await user.click(within(dialog).getByRole("button", { name: "Run in background" }));
|
||||
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||
const chip = await screen.findByTestId("fx-clone-chip");
|
||||
expect(within(chip).getByText(/repo/)).toBeInTheDocument();
|
||||
await user.click(chip);
|
||||
expect(screen.getByTestId("fx-clone-dialog")).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
finish({
|
||||
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
|
||||
created: true,
|
||||
path: "/w/one/repo",
|
||||
});
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId("fx-clone-chip")).toBeNull();
|
||||
});
|
||||
|
||||
it("toasts a failure that lands while hidden and keeps it for the next open", async () => {
|
||||
let fail: (e: unknown) => void = () => {};
|
||||
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((_, r) => (fail = r)) as never);
|
||||
vi.mocked(api.lanes.cloneProgress).mockRejectedValue(new Error("404"));
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const dialog = await openClone(user);
|
||||
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
|
||||
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||
await user.keyboard("{Escape}");
|
||||
fail(
|
||||
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||
);
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
const again = await openClone(user);
|
||||
expect(within(again).getByTestId("fx-clone-login")).toBeInTheDocument();
|
||||
expect(within(again).getByLabelText("Repository URL")).toHaveValue(
|
||||
"https://git.example/o/r.git"
|
||||
);
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
||||
vi.mocked(api.lanes.clone)
|
||||
.mockRejectedValueOnce(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,9 @@
|
||||
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
|
||||
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
|
||||
* each tied to the one key it uses (the SSH part is split into tabs: one per
|
||||
* kind of host, the extra keys, the other hosts) — and, for
|
||||
* kind of host, then the other hosts; a named key is a card of its own under
|
||||
* its type's default one, with the same steps, and "Add another key" makes
|
||||
* the next one below) — and, for
|
||||
* a user with their own Linux account (S3), their own HTTPS
|
||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||
* table above it, so this lists them only for a member. The private key and
|
||||
@@ -24,17 +26,7 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Cloud,
|
||||
Copy,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
KeySquare,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
|
||||
import { api, type GitIdentity } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
@@ -96,17 +88,19 @@ function Code({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
||||
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
|
||||
|
||||
/**
|
||||
* One kind of git host and the key it takes: no key → a Generate button;
|
||||
* a key → step 1 (copy its public half) followed by the host's own steps.
|
||||
* One SSH key and the steps of the host it is for: no key → a Generate
|
||||
* button; a key → step 1 (copy its public half) followed by the host's own
|
||||
* steps. The default key of a type and every named one share it.
|
||||
*/
|
||||
function KeyCard({
|
||||
title,
|
||||
sub,
|
||||
type,
|
||||
label,
|
||||
k,
|
||||
id,
|
||||
writable,
|
||||
busy,
|
||||
onGenerate,
|
||||
onRemove,
|
||||
@@ -114,12 +108,13 @@ function KeyCard({
|
||||
}: {
|
||||
title: string;
|
||||
sub: string;
|
||||
type: "ed25519" | "rsa";
|
||||
/** What names the public key field ("RSA", "ED25519" or the key's name). */
|
||||
label: string;
|
||||
k: Key | undefined;
|
||||
id: GitIdentity;
|
||||
writable: boolean;
|
||||
busy: boolean;
|
||||
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
|
||||
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
|
||||
onGenerate: () => Promise<void>;
|
||||
onRemove: () => Promise<void>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
@@ -130,23 +125,19 @@ function KeyCard({
|
||||
<div className="text-xs font-medium text-fg-primary">{title}</div>
|
||||
<div className="text-[10px] text-fg-muted">{sub}</div>
|
||||
</div>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => void onGenerate(type)}
|
||||
>
|
||||
{writable && (
|
||||
<button type="button" className={BTN} disabled={busy} onClick={() => void onGenerate()}>
|
||||
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
||||
</button>
|
||||
)}
|
||||
{k && id.writable && (
|
||||
{k && writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
onClick={() => void onRemove(type)}
|
||||
aria-label={k.name ? t("gitIdentity.removeKeyNamed", { name: k.name }) : undefined}
|
||||
onClick={() => void onRemove()}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.remove")}
|
||||
@@ -165,9 +156,9 @@ function KeyCard({
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${label}`}
|
||||
value={k.publicKey}
|
||||
rows={type === "rsa" ? 3 : 2}
|
||||
rows={k.type === "rsa" ? 3 : 2}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
|
||||
/>
|
||||
@@ -182,6 +173,104 @@ function KeyCard({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of an AWS key: the SSH key ID AWS shows for it, in one region. The
|
||||
* default key's login is the region's plain host; a named key's is saved
|
||||
* under an account name (its own name unless another is typed), tied to it.
|
||||
*/
|
||||
function AwsLoginForm({
|
||||
busy,
|
||||
named,
|
||||
onSave,
|
||||
}: {
|
||||
busy: boolean;
|
||||
/** The named key's name; undefined for the default key. */
|
||||
named?: string;
|
||||
onSave: (v: { keyId: string; region: string; alias: string }) => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" });
|
||||
return (
|
||||
<form
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" }));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.awsKeyId")}
|
||||
placeholder="APKA…"
|
||||
autoComplete="off"
|
||||
value={v.keyId}
|
||||
onChange={(e) => setV({ ...v, keyId: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.awsRegion")}
|
||||
value={v.region}
|
||||
onChange={(e) => setV({ ...v, region: e.target.value })}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
{AWS_REGIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={named ?? t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={v.alias}
|
||||
onChange={(e) => setV({ ...v, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<button type="submit" className={BTN} disabled={busy || !v.keyId.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of a named GitHub/GitLab key: the host it is for. Saved as the login
|
||||
* `ssh://<key name>/` → `git@<host>`, tied to this key, so a clone through it
|
||||
* offers only this key — the default key would log in as the other account.
|
||||
*/
|
||||
function GitHostForm({
|
||||
busy,
|
||||
onSave,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onSave: (host: string) => Promise<boolean>;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [host, setHost] = useState("github.com");
|
||||
return (
|
||||
<form
|
||||
className="mt-1 flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void onSave(host.trim());
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.otherHost")}
|
||||
placeholder="github.com"
|
||||
autoComplete="off"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<button type="submit" className={BTN} disabled={busy || !host.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitIdentitySettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [id, setId] = useState<GitIdentity | null>(null);
|
||||
@@ -229,19 +318,42 @@ export function GitIdentitySettings() {
|
||||
const keyOf = (type: "ed25519" | "rsa") =>
|
||||
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
|
||||
const named = keys.filter((k) => k.name);
|
||||
const namedOf = (type: "ed25519" | "rsa") => named.filter((k) => (k.type ?? "ed25519") === type);
|
||||
const keyLabel = (keyId: string | null | undefined) => {
|
||||
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
|
||||
if (!k) return t("gitIdentity.anyKey");
|
||||
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
|
||||
};
|
||||
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
|
||||
const makeNamed = async () => {
|
||||
if (!newKey) return;
|
||||
const ok = await run(
|
||||
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
|
||||
// "Add another key" in a tab makes the next key of that tab's type at once,
|
||||
// named `aws-2`, `aws-3`… / `git-2`… — the key is what step 1 needs, and an
|
||||
// account name of the user's own goes in its step 3.
|
||||
const makeNamed = (type: "ed25519" | "rsa") => {
|
||||
const prefix = type === "rsa" ? "aws" : "git";
|
||||
let n = 2;
|
||||
while (keys.some((k) => k.name === `${prefix}-${n}`)) n++;
|
||||
return run(
|
||||
() => api.gitIdentity.generateKey(false, type, `${prefix}-${n}`),
|
||||
t("gitIdentity.keyMade")
|
||||
);
|
||||
};
|
||||
const addKey = (type: "ed25519" | "rsa") => (
|
||||
<button type="button" className={BTN} disabled={busy} onClick={() => void makeNamed(type)}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.addKey")}
|
||||
</button>
|
||||
);
|
||||
const replaceNamed = async (k: Key) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.replaceConfirm"),
|
||||
confirmLabel: t("gitIdentity.replace"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(
|
||||
() => api.gitIdentity.generateKey(true, k.type ?? "ed25519", k.name ?? undefined),
|
||||
t("gitIdentity.keyMade")
|
||||
);
|
||||
if (ok) setNewKey(null);
|
||||
};
|
||||
const removeNamed = async (keyId: string) => {
|
||||
if (
|
||||
@@ -304,29 +416,60 @@ export function GitIdentitySettings() {
|
||||
return m ? [{ ...r, region: m[1] ?? "" }] : [];
|
||||
});
|
||||
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
|
||||
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
|
||||
const saveAws = async () => {
|
||||
let user = aws.keyId.trim();
|
||||
let host = `git-codecommit.${aws.region}.amazonaws.com`;
|
||||
// A login is shown under the key it names; one naming no key (or one gone)
|
||||
// under the default key.
|
||||
const namedIds = new Set(named.map((k) => k.id));
|
||||
const rowsOf = <R extends { key?: string | null }>(rows: R[], k?: Key) =>
|
||||
k ? rows.filter((r) => r.key === k.id) : rows.filter((r) => !r.key || !namedIds.has(r.key));
|
||||
const saveAws = (v: { keyId: string; region: string; alias: string }, k?: Key) => {
|
||||
let user = v.keyId.trim();
|
||||
let host = `git-codecommit.${v.region}.amazonaws.com`;
|
||||
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
|
||||
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
|
||||
if (m?.[1] && m[2]) {
|
||||
user = m[1];
|
||||
host = m[2].toLowerCase();
|
||||
}
|
||||
const alias = aws.alias.trim().toLowerCase();
|
||||
const ok = await run(
|
||||
// A named key's account goes by the key's name unless another is typed.
|
||||
const alias = v.alias.trim().toLowerCase() || (k?.name ?? "");
|
||||
return run(
|
||||
() =>
|
||||
api.gitIdentity.setSshUser({
|
||||
host,
|
||||
user,
|
||||
...(alias ? { alias } : {}),
|
||||
...(aws.key ? { key: aws.key } : {}),
|
||||
...(k?.id ? { key: k.id } : {}),
|
||||
}),
|
||||
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
||||
);
|
||||
if (ok) setAws({ ...aws, keyId: "", alias: "" });
|
||||
};
|
||||
const saveGitHost = (host: string, k: Key) => {
|
||||
const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host;
|
||||
return run(
|
||||
() => api.gitIdentity.setSshUser({ host: h, user: "git", alias: k.name!, key: k.id! }),
|
||||
t("gitIdentity.otherSaved", { host: k.name })
|
||||
);
|
||||
};
|
||||
/** The logins of one key, each with its delete button. */
|
||||
const loginRows = <R extends SshUser>(rows: R[], text: (r: R) => string) =>
|
||||
rows.map((r) => (
|
||||
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{text(r)}
|
||||
</span>
|
||||
{id?.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
|
||||
const saveOther = async () => {
|
||||
let host = other.host.trim();
|
||||
@@ -346,7 +489,7 @@ export function GitIdentitySettings() {
|
||||
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
|
||||
};
|
||||
/** The key picker of a login form: every key, or one. */
|
||||
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
|
||||
const keySelect = (value: string, onChange: (v: string) => void) => (
|
||||
<select
|
||||
aria-label={t("gitIdentity.loginKey")}
|
||||
value={value}
|
||||
@@ -355,7 +498,7 @@ export function GitIdentitySettings() {
|
||||
>
|
||||
<option value="">{t("gitIdentity.anyKey")}</option>
|
||||
{keys
|
||||
.filter((k) => k.id && (!only || k.type === only))
|
||||
.filter((k) => k.id)
|
||||
.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{keyLabel(k.id)}
|
||||
@@ -396,17 +539,6 @@ export function GitIdentitySettings() {
|
||||
const sshTabs = [
|
||||
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
|
||||
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
|
||||
...(named.length > 0 || id.writable
|
||||
? [
|
||||
{
|
||||
id: "more",
|
||||
label: named.length
|
||||
? `${t("gitIdentity.tabMore")} (${named.length})`
|
||||
: t("gitIdentity.tabMore"),
|
||||
Icon: KeySquare,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
|
||||
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
|
||||
: []),
|
||||
@@ -498,228 +630,116 @@ export function GitIdentitySettings() {
|
||||
onSelect={setSshTab}
|
||||
>
|
||||
{activeSshTab === "git" && (
|
||||
<KeyCard
|
||||
title={t("gitIdentity.gitHubTitle")}
|
||||
sub={t("gitIdentity.gitHubSub")}
|
||||
type="ed25519"
|
||||
k={keyOf("ed25519")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.gitHubStepClone")}
|
||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||
</Step>
|
||||
</KeyCard>
|
||||
<div className="space-y-2">
|
||||
<KeyCard
|
||||
title={t("gitIdentity.gitHubTitle")}
|
||||
sub={t("gitIdentity.gitHubSub")}
|
||||
label="ED25519"
|
||||
k={keyOf("ed25519")}
|
||||
writable={id.writable}
|
||||
busy={busy}
|
||||
onGenerate={() => generate("ed25519")}
|
||||
onRemove={() => removeKey("ed25519")}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.gitHubStepClone")}
|
||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||
</Step>
|
||||
</KeyCard>
|
||||
{namedOf("ed25519").map((k) => {
|
||||
const rows = rowsOf(otherRows, k);
|
||||
return (
|
||||
<KeyCard
|
||||
key={k.id}
|
||||
title={k.name!}
|
||||
sub={t("gitIdentity.namedGitSub")}
|
||||
label={k.name!}
|
||||
k={k}
|
||||
writable={id.writable}
|
||||
busy={busy}
|
||||
onGenerate={() => replaceNamed(k)}
|
||||
onRemove={() => removeNamed(k.id!)}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.namedGitStepHost", { name: k.name })}
|
||||
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
|
||||
{id.writable && rows.length === 0 && (
|
||||
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
|
||||
)}
|
||||
{rows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/<org>/<repo>.git
|
||||
</Code>
|
||||
))}
|
||||
</Step>
|
||||
</KeyCard>
|
||||
);
|
||||
})}
|
||||
{id.writable &&
|
||||
keys.some((k) => (k.type ?? "ed25519") === "ed25519") &&
|
||||
addKey("ed25519")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSshTab === "aws" && (
|
||||
<KeyCard
|
||||
title={t("gitIdentity.awsTitle")}
|
||||
sub={t("gitIdentity.awsSub")}
|
||||
type="rsa"
|
||||
k={keyOf("rsa")}
|
||||
id={id}
|
||||
busy={busy}
|
||||
onGenerate={generate}
|
||||
onRemove={removeKey}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.awsStepKeyId")}
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{awsRows.map((r) => (
|
||||
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||
{r.alias ? `${r.alias} · ` : ""}
|
||||
{r.region} · {r.user} · {keyLabel(r.key)}
|
||||
</span>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||
onClick={() =>
|
||||
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{[keyOf("rsa"), ...namedOf("rsa")].map((k, i) => {
|
||||
const isNamed = i > 0 && !!k;
|
||||
const rows = rowsOf(awsRows, isNamed ? k : undefined);
|
||||
return (
|
||||
<KeyCard
|
||||
key={k?.id ?? "id_rsa"}
|
||||
title={isNamed ? k!.name! : t("gitIdentity.awsTitle")}
|
||||
sub={isNamed ? t("gitIdentity.namedAwsSub") : t("gitIdentity.awsSub")}
|
||||
label={isNamed ? k!.name! : "RSA"}
|
||||
k={k}
|
||||
writable={id.writable}
|
||||
busy={busy}
|
||||
onGenerate={() => (isNamed ? replaceNamed(k!) : generate("rsa"))}
|
||||
onRemove={() => (isNamed ? removeNamed(k!.id!) : removeKey("rsa"))}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.awsStepKeyId")}
|
||||
{id.sshUsers === undefined ? null : (
|
||||
<>
|
||||
{loginRows(
|
||||
rows,
|
||||
(r) =>
|
||||
`${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
|
||||
)}
|
||||
{id.writable && (
|
||||
<AwsLoginForm
|
||||
busy={busy}
|
||||
named={isNamed ? k!.name! : undefined}
|
||||
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
{rows.length > 0 && (
|
||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.awsReady")}
|
||||
{rows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||
</Code>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{id.writable && (
|
||||
<form
|
||||
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveAws();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.awsKeyId")}
|
||||
placeholder="APKA…"
|
||||
autoComplete="off"
|
||||
value={aws.keyId}
|
||||
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.awsRegion")}
|
||||
value={aws.region}
|
||||
onChange={(e) => setAws({ ...aws, region: e.target.value })}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
{AWS_REGIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
aria-label={t("gitIdentity.alias")}
|
||||
title={t("gitIdentity.aliasHint")}
|
||||
placeholder={t("gitIdentity.aliasPlaceholder")}
|
||||
autoComplete="off"
|
||||
value={aws.alias}
|
||||
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
|
||||
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
|
||||
{t("gitIdentity.awsSave")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
{awsRows.length > 0 && (
|
||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.awsReady")}
|
||||
{awsRows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||
</Code>
|
||||
))}
|
||||
</div>
|
||||
</KeyCard>
|
||||
);
|
||||
})}
|
||||
{id.writable && keys.some((k) => k.type === "rsa") && (
|
||||
<>
|
||||
{addKey("rsa")}
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||
</>
|
||||
)}
|
||||
</KeyCard>
|
||||
)}
|
||||
|
||||
{activeSshTab === "more" && (
|
||||
<section
|
||||
aria-label={t("gitIdentity.moreKeysTitle")}
|
||||
className="space-y-2 rounded border border-border p-2.5"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-medium text-fg-primary">
|
||||
{t("gitIdentity.moreKeysTitle")}
|
||||
</div>
|
||||
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
|
||||
</div>
|
||||
{id.writable && !newKey && (
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => setNewKey({ name: "", type: "rsa" })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.newKey")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{newKey && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void makeNamed();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitIdentity.keyName")}
|
||||
placeholder={t("gitIdentity.keyNamePlaceholder")}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
value={newKey.name}
|
||||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<select
|
||||
aria-label={t("gitIdentity.keyType")}
|
||||
value={newKey.type}
|
||||
onChange={(e) =>
|
||||
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
|
||||
}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="rsa">RSA 4096 (AWS)</option>
|
||||
<option value="ed25519">ED25519</option>
|
||||
</select>
|
||||
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
|
||||
{t("gitIdentity.generate")}
|
||||
</button>
|
||||
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{named.map((k) => (
|
||||
<div
|
||||
key={k.id}
|
||||
className="space-y-1 rounded border border-border-light p-2"
|
||||
data-testid="git-named-key"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-[11px] font-medium text-fg-primary">
|
||||
{k.name}
|
||||
</span>
|
||||
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
|
||||
{k.type === "rsa" ? "RSA" : "ED25519"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
||||
{k.fingerprint}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => void copyText(k.publicKey)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
|
||||
onClick={() => void removeNamed(k.id!)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
|
||||
value={k.publicKey}
|
||||
rows={2}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSshTab === "other" && (
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,9 @@
|
||||
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
|
||||
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
|
||||
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
||||
* the sections never mix; a named key
|
||||
* is made beside the default ones, and a second AWS account is saved under
|
||||
* its own name with the one key it uses.
|
||||
* the sections never mix; "Add another key" makes a named key as one more
|
||||
* card in its own tab, a second AWS account is saved from that card under
|
||||
* the key's name with that key, and a second GitHub key is tied to its host.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -216,9 +216,9 @@ describe("GitIdentitySettings", () => {
|
||||
fingerprint: "SHA256:r",
|
||||
};
|
||||
const PROD = {
|
||||
id: "id_rsa_aws-prod",
|
||||
id: "id_rsa_aws-2",
|
||||
type: "rsa" as const,
|
||||
name: "aws-prod",
|
||||
name: "aws-2",
|
||||
publicKey: "ssh-rsa B prod",
|
||||
fingerprint: "SHA256:p",
|
||||
};
|
||||
@@ -230,45 +230,78 @@ describe("GitIdentitySettings", () => {
|
||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||
view({
|
||||
sshKeys: [RSA, PROD],
|
||||
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }],
|
||||
sshUsers: [{ host: H, user: "APKA2", alias: "aws-2", key: "id_rsa_aws-2" }],
|
||||
})
|
||||
);
|
||||
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
||||
view({ sshKeys: [RSA], sshUsers: [] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
// Only an RSA key: the AWS tab opens first.
|
||||
// Only an RSA key: the AWS tab opens first; the new key is made inside it.
|
||||
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
|
||||
await tab("More keys");
|
||||
let more = screen.getByRole("region", { name: "More SSH keys" });
|
||||
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
|
||||
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
|
||||
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
|
||||
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
|
||||
expect(screen.queryByRole("tab", { name: /More keys/ })).toBeNull();
|
||||
// One click makes the key — nothing to type before it.
|
||||
await userEvent.click(screen.getByRole("button", { name: "Add another key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-2");
|
||||
const prod = await screen.findByRole("region", { name: "aws-2" });
|
||||
expect(within(prod).getByLabelText("Public key aws-2")).toHaveValue("ssh-rsa B prod");
|
||||
// Another key can be added below it.
|
||||
expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument();
|
||||
|
||||
await tab("AWS CodeCommit");
|
||||
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
|
||||
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
|
||||
await userEvent.selectOptions(within(aws).getByLabelText("Key"), "id_rsa_aws-prod");
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||
// Its own step 3: the account name defaults to the key's name.
|
||||
await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2");
|
||||
await userEvent.click(within(prod).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: H,
|
||||
user: "APKA2",
|
||||
alias: "aws-prod",
|
||||
key: "id_rsa_aws-prod",
|
||||
alias: "aws-2",
|
||||
key: "id_rsa_aws-2",
|
||||
});
|
||||
expect(
|
||||
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod")
|
||||
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
|
||||
).toBeInTheDocument();
|
||||
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument();
|
||||
expect(within(prod).getByText("git clone ssh://aws-2/v1/repos/<repo>")).toBeInTheDocument();
|
||||
// The default key's card does not list the named key's login.
|
||||
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(within(def).queryByText(/APKA2/)).toBeNull();
|
||||
|
||||
await tab("More keys (1)");
|
||||
more = screen.getByRole("region", { name: "More SSH keys" });
|
||||
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
|
||||
await waitFor(() =>
|
||||
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
|
||||
await userEvent.click(within(prod).getByRole("button", { name: "Delete key aws-2" }));
|
||||
await waitFor(() => expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-2"));
|
||||
});
|
||||
|
||||
it("adds a second GitHub key in its tab and ties it to its host", async () => {
|
||||
const ED = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
|
||||
const WORK = {
|
||||
id: "id_ed25519_git-2",
|
||||
type: "ed25519" as const,
|
||||
name: "git-2",
|
||||
publicKey: "ssh-ed25519 W work",
|
||||
fingerprint: "SHA256:w",
|
||||
};
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [ED], sshUsers: [] }));
|
||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
|
||||
view({ sshKeys: [ED, WORK], sshUsers: [] })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||
view({
|
||||
sshKeys: [ED, WORK],
|
||||
sshUsers: [{ host: "github.com", user: "git", alias: "git-2", key: "id_ed25519_git-2" }],
|
||||
})
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Add another key" }));
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519", "git-2");
|
||||
const work = await screen.findByRole("region", { name: "git-2" });
|
||||
expect(within(work).getByLabelText("Public key git-2")).toHaveValue("ssh-ed25519 W work");
|
||||
await userEvent.click(within(work).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||
host: "github.com",
|
||||
user: "git",
|
||||
alias: "git-2",
|
||||
key: "id_ed25519_git-2",
|
||||
});
|
||||
expect(
|
||||
await within(work).findByText("git clone ssh://git-2/<org>/<repo>.git")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
@@ -183,5 +182,46 @@
|
||||
"stat.updated": "Updated",
|
||||
"detail.runAs": "Run it as",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Added {{date}}"
|
||||
"detail.added": "Added {{date}}",
|
||||
"tab.unowned": "Unowned",
|
||||
"tab.suggested": "Is this yours?",
|
||||
"auto": "Auto",
|
||||
"autoHint": "Added when its session stopped: a document the session created or changed",
|
||||
"unowned.hint": "Items no workspace owns that were made in your folders — confirm whether they are yours",
|
||||
"unowned.hintAdmin": "Items with no owner: sessions outside any workspace, run as the host account",
|
||||
"unowned.mine": "It's mine",
|
||||
"unowned.takeIt": "Take it",
|
||||
"unowned.notMine": "Not mine",
|
||||
"unowned.assign": "Assign to…",
|
||||
"unowned.claimed": "Added to your library",
|
||||
"unowned.declined": "Removed from your list",
|
||||
"unowned.assigned": "Owner assigned",
|
||||
"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}}"
|
||||
}
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"clone.parentHint": "The repository is cloned to {{path}}",
|
||||
"clone.nameLabel": "Folder name",
|
||||
"clone.cloning": "Cloning…",
|
||||
"clone.background": "Run in background",
|
||||
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
|
||||
"clone.loginRejected": "{{host}} rejected that username or token.",
|
||||
"clone.username": "Username",
|
||||
@@ -656,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"
|
||||
},
|
||||
|
||||
@@ -908,16 +908,14 @@
|
||||
"alias": "Account name",
|
||||
"aliasPlaceholder": "name (optional)",
|
||||
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
||||
"awsMany": "Several AWS accounts? Make one more RSA key in the More keys tab for each, upload it to that account, then save its SSH key ID here with an account name and that key. Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||
"moreKeysTitle": "More SSH keys",
|
||||
"moreKeysSub": "One per extra account — another AWS account, a second GitHub user. A login that names its key uses only that key.",
|
||||
"newKey": "New key",
|
||||
"keyName": "Key name",
|
||||
"keyNamePlaceholder": "e.g. aws-prod",
|
||||
"keyType": "Key type",
|
||||
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3 (the account name is the key’s unless you type one). Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabMore": "More keys",
|
||||
"tabOther": "Other hosts"
|
||||
"tabOther": "Other hosts",
|
||||
"addKey": "Add another key",
|
||||
"namedGitSub": "ED25519 · for one more GitHub/GitLab account.",
|
||||
"namedGitStepHost": "Tie it to its host, so a clone through ssh://{{name}}/… offers only this key (git@host would log in with the default key’s account):",
|
||||
"namedAwsSub": "RSA · for one more AWS account.",
|
||||
"removeKeyNamed": "Delete key {{name}}"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
@@ -1077,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.",
|
||||
@@ -1136,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.",
|
||||
|
||||
@@ -36,5 +36,10 @@
|
||||
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
||||
"autoFixWorkspace": "AgentHub update",
|
||||
"autoFixError": "Could not start the Auto fix session",
|
||||
"backgroundNote": "The update keeps running in the background — the page reloads itself when the new version is up."
|
||||
"backgroundNote": "The update keeps running in the background — the page reloads itself when the new version is up.",
|
||||
"chipTitle": "Updating to {{version}}…",
|
||||
"chipHint": "The update runs in the background — click to show it",
|
||||
"pluginStep.marketplace_update": "Step {{index}}/{{total}}: refreshing the marketplace",
|
||||
"pluginStep.plugin_update": "Step {{index}}/{{total}}: downloading the new version",
|
||||
"pluginStep.bootstrap": "Step {{index}}/{{total}}: installing it and restarting the dashboard"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"role": "Role",
|
||||
"workspaces": "Workspaces",
|
||||
"sessions": "Sessions",
|
||||
"today": "Today",
|
||||
"last7d": "7 days",
|
||||
"last30d": "30 days",
|
||||
"storage": "Storage",
|
||||
|
||||
@@ -183,5 +183,43 @@
|
||||
"stat.updated": "Cập nhật",
|
||||
"detail.runAs": "Gọi bằng",
|
||||
"detail.market": "Marketplace",
|
||||
"detail.added": "Thêm lúc {{date}}"
|
||||
"detail.added": "Thêm lúc {{date}}",
|
||||
"tab.unowned": "Vô chủ",
|
||||
"tab.suggested": "Có phải của bạn?",
|
||||
"auto": "Tự động",
|
||||
"autoHint": "Tự thêm khi phiên làm việc dừng: file tài liệu phiên đã tạo hoặc sửa",
|
||||
"unowned.hint": "Những mục không thuộc workspace nào nhưng được tạo trong thư mục của bạn — xác nhận có phải của bạn không",
|
||||
"unowned.hintAdmin": "Những mục chưa có chủ: phiên không thuộc workspace nào, chạy bằng tài khoản host",
|
||||
"unowned.mine": "Của tôi",
|
||||
"unowned.takeIt": "Nhận về tôi",
|
||||
"unowned.notMine": "Không phải của tôi",
|
||||
"unowned.assign": "Gán cho…",
|
||||
"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ủ.",
|
||||
"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}}"
|
||||
}
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
|
||||
"clone.nameLabel": "Tên thư mục",
|
||||
"clone.cloning": "Đang clone…",
|
||||
"clone.background": "Chạy nền",
|
||||
"clone.loginNeeded": "{{host}} cần đăng nhập: username và mật khẩu hoặc access token.",
|
||||
"clone.loginRejected": "{{host}} từ chối username hoặc token này.",
|
||||
"clone.username": "Username",
|
||||
@@ -656,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"
|
||||
},
|
||||
|
||||
@@ -896,16 +896,14 @@
|
||||
"alias": "Tên tài khoản",
|
||||
"aliasPlaceholder": "tên (tuỳ chọn)",
|
||||
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
||||
"awsMany": "Nhiều tài khoản AWS? Tạo thêm một RSA key ở tab Key khác cho mỗi tài khoản, upload lên tài khoản đó, rồi lưu SSH key ID ở đây kèm tên tài khoản và key đó. Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
||||
"moreKeysTitle": "Thêm SSH key",
|
||||
"moreKeysSub": "Mỗi key cho một tài khoản phụ — tài khoản AWS khác, user GitHub thứ hai. Tài khoản nào gắn key thì chỉ dùng đúng key đó.",
|
||||
"newKey": "Key mới",
|
||||
"keyName": "Tên key",
|
||||
"keyNamePlaceholder": "vd aws-prod",
|
||||
"keyType": "Loại key",
|
||||
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó (tên tài khoản lấy theo tên key nếu bạn không nhập). Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabMore": "Key khác",
|
||||
"tabOther": "Host khác"
|
||||
"tabOther": "Host khác",
|
||||
"addKey": "Thêm key khác",
|
||||
"namedGitSub": "ED25519 · cho thêm một tài khoản GitHub/GitLab.",
|
||||
"namedGitStepHost": "Gắn key với host của nó, để clone qua ssh://{{name}}/… chỉ dùng đúng key này (git@host sẽ đăng nhập bằng tài khoản của key mặc định):",
|
||||
"namedAwsSub": "RSA · cho thêm một tài khoản AWS.",
|
||||
"removeKeyNamed": "Xoá key {{name}}"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
@@ -1065,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.",
|
||||
@@ -1124,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.",
|
||||
|
||||
@@ -36,5 +36,10 @@
|
||||
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
||||
"autoFixWorkspace": "Cập nhật AgentHub",
|
||||
"autoFixError": "Không mở được phiên Tự sửa",
|
||||
"backgroundNote": "Bản cập nhật vẫn chạy ngầm — trang sẽ tự tải lại khi phiên bản mới sẵn sàng."
|
||||
"backgroundNote": "Bản cập nhật vẫn chạy ngầm — trang sẽ tự tải lại khi phiên bản mới sẵn sàng.",
|
||||
"chipTitle": "Đang cập nhật lên {{version}}…",
|
||||
"chipHint": "Bản cập nhật đang chạy ngầm — bấm để xem",
|
||||
"pluginStep.marketplace_update": "Bước {{index}}/{{total}}: làm mới marketplace",
|
||||
"pluginStep.plugin_update": "Bước {{index}}/{{total}}: tải phiên bản mới",
|
||||
"pluginStep.bootstrap": "Bước {{index}}/{{total}}: cài đặt và khởi động lại bảng điều khiển"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"role": "Vai trò",
|
||||
"workspaces": "Workspace",
|
||||
"sessions": "Phiên",
|
||||
"today": "Hôm nay",
|
||||
"last7d": "7 ngày",
|
||||
"last30d": "30 ngày",
|
||||
"storage": "Dung lượng",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+91
-11
@@ -538,6 +538,8 @@ export type ServerUserUsage = AuthUser & {
|
||||
disabled: boolean;
|
||||
workspaces: number;
|
||||
sessions: number;
|
||||
/** Since the viewer's local midnight (absent from an older server). */
|
||||
today?: { cost: number; tokens: number };
|
||||
last7d: { cost: number; tokens: number };
|
||||
last30d: { cost: number; tokens: number };
|
||||
/** Active login sessions (cookies not expired). */
|
||||
@@ -893,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 {
|
||||
@@ -905,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}` : ""}`;
|
||||
}
|
||||
@@ -1003,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: {
|
||||
@@ -1083,7 +1100,8 @@ export const api = {
|
||||
avatarUrl: (id: number, avatarAt?: string | null) =>
|
||||
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||
list: () => request<{ users: ServerUserUsage[] }>("/users"),
|
||||
list: () =>
|
||||
request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`),
|
||||
/** GET /api/users/audit — staff reads of members' sessions and admins'
|
||||
* account changes, newest first. */
|
||||
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
|
||||
@@ -2141,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.
|
||||
@@ -2151,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);
|
||||
@@ -3016,7 +3038,9 @@ export const api = {
|
||||
},
|
||||
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
||||
artifacts: {
|
||||
list: (params: { scope?: "mine" | "hub" | "all"; kind?: ArtifactKind; q?: string } = {}) => {
|
||||
list: (
|
||||
params: { scope?: "mine" | "hub" | "all" | "unowned"; kind?: ArtifactKind; q?: string } = {}
|
||||
) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.scope) qs.set("scope", params.scope);
|
||||
if (params.kind) qs.set("kind", params.kind);
|
||||
@@ -3092,6 +3116,18 @@ export const api = {
|
||||
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
||||
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
|
||||
},
|
||||
/** An unowned row: its suggested user (or an admin) takes it. */
|
||||
claim: (id: number) =>
|
||||
request<{ item: Artifact }>(`/artifacts/${id}/claim`, { method: "POST" }),
|
||||
/** An unowned row offered to the caller is not theirs. */
|
||||
decline: (id: number) =>
|
||||
request<{ id: number; declined: true }>(`/artifacts/${id}/decline`, { method: "POST" }),
|
||||
/** Admin: give an unowned row to a user. */
|
||||
assign: (id: number, userId: number) =>
|
||||
request<{ item: Artifact }>(`/artifacts/${id}/assign`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ userId }),
|
||||
}),
|
||||
update: (
|
||||
id: number,
|
||||
body: {
|
||||
@@ -3118,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`, {
|
||||
@@ -3318,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) }),
|
||||
@@ -4157,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`, {
|
||||
@@ -4382,7 +4453,7 @@ export interface Artifact {
|
||||
market: string | null;
|
||||
/** page: the claude.ai link. */
|
||||
url: string | null;
|
||||
files: { path: string; size: number }[];
|
||||
files: { path: string; size: number; mtime?: number }[];
|
||||
size: number | null;
|
||||
shared: boolean;
|
||||
sharedAt: string | null;
|
||||
@@ -4391,6 +4462,15 @@ 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. */
|
||||
unowned?: boolean;
|
||||
/** Unowned and offered to the caller to claim or decline. */
|
||||
suggestedForMe?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+20
-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<{
|
||||
@@ -1458,6 +1457,14 @@ export interface UpdateStatusPayload {
|
||||
plugin_root?: string;
|
||||
finished_at?: string;
|
||||
} | null;
|
||||
/** The step a running plugin-update chain is on (null between runs or on
|
||||
* a server that predates it). Polled while an update runs. */
|
||||
plugin_update_progress?: {
|
||||
step: string;
|
||||
index: number;
|
||||
total: number;
|
||||
started_at: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// ───── Terminal run status ─────
|
||||
@@ -3228,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>;
|
||||
@@ -3312,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);
|
||||
}
|
||||
+823
-244
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>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* @description The Users tab of the Administration page (`/admin`, which
|
||||
* `/users` redirects to — `Administration.tsx`), server mode only and
|
||||
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
||||
* role, state, workspaces, sessions, the tokens/$ it used in the last 7 and
|
||||
* 30 days (`GET /api/users`) and how much disk its home folder takes
|
||||
* role, state, workspaces, sessions, the tokens/$ it used today (the
|
||||
* browser's local day) and in the last 7 and 30 days (`GET /api/users`) and how much disk its home folder takes
|
||||
* (`GET /api/users/storage`, measured in the background — polled while the
|
||||
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for
|
||||
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||
@@ -214,13 +214,14 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
)}
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full min-w-[860px]" data-testid="users-table">
|
||||
<table className="w-full min-w-[960px]" data-testid="users-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("name")}</th>
|
||||
<th className={th}>{t("role")}</th>
|
||||
<th className={`${th} text-right`}>{t("workspaces")}</th>
|
||||
<th className={`${th} text-right`}>{t("sessions")}</th>
|
||||
<th className={`${th} text-right`}>{t("today")}</th>
|
||||
<th className={`${th} text-right`}>{t("last7d")}</th>
|
||||
<th className={`${th} text-right`}>{t("last30d")}</th>
|
||||
<th className={`${th} text-right`}>{t("storage")}</th>
|
||||
@@ -299,6 +300,7 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{u.sessions}
|
||||
</td>
|
||||
<UsageCell usage={u.today ?? { cost: 0, tokens: 0 }} />
|
||||
<UsageCell usage={u.last7d} />
|
||||
<UsageCell usage={u.last30d} />
|
||||
<StorageCell
|
||||
|
||||
+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.20 (rebuild client to match)
|
||||
UI build v2.8.25 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+54
-14
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
|
||||
|
||||
+3
-2
@@ -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 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
|
||||
@@ -738,7 +739,7 @@ What runs as the member, and how:
|
||||
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
||||
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
||||
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **More keys** (named keys for extra accounts) and **Other hosts** (advanced); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — clone `ssh://<name>/v1/repos/<repo>`; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
||||
- the workspace prompt and its context files;
|
||||
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
||||
|
||||
+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
|
||||
|
||||
@@ -8,8 +8,8 @@ One place to keep what agents PRODUCE, and (in server mode) to share it:
|
||||
|
||||
| Kind | What it is | Where it comes from |
|
||||
|---|---|---|
|
||||
| `page` | A claude.ai Artifact a session published (`claude.ai/…/artifact/…`) | the `Artifact` tool's PostToolUse hook, or a link pasted by hand |
|
||||
| `file` | A file an agent wrote (HTML, Markdown, PDF, image, CSV, Office…) | Write/Edit/NotebookEdit hook events, kept on the user's pin; or uploaded from the computer |
|
||||
| `page` | A claude.ai Artifact a session published (`claude.ai/…/artifact/…`) | the `Artifact` tool's (or a Claude Docs doc-creating `batch`'s) PostToolUse hook, or a link pasted by hand |
|
||||
| `file` | A file an agent wrote (HTML, Markdown, PDF, image, CSV, Office…) | Write/Edit/NotebookEdit hook events, kept on the user's pin; a pptx/docx/xlsx/pdf/html the session created or changed, captured at its Stop (`auto`); or uploaded from the computer |
|
||||
| `skill` | A Claude Code skill folder (`SKILL.md` + files) | the user's own `~/.claude/skills/<name>` or a workspace's `.claude/skills/<name>`; or uploaded (a Markdown file, files, a folder, a .zip/.skill — several skills at once too) or written in the dialog, under a name the user chooses |
|
||||
| `plugin` | A plugin by marketplace source + name (no files) | picked from the list a marketplace link answers (`POST /api/artifacts/plugin/inspect`), or typed as `name@marketplace` |
|
||||
|
||||
|
||||
+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.20",
|
||||
"version": "2.8.25",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.20",
|
||||
"version": "2.8.25",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.20",
|
||||
"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;
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
* refused on STDOUT (`bootstrap: locked`), and execFileSync's own message is
|
||||
* only "Command failed: <argv>".
|
||||
*
|
||||
* While a step runs, `plugin-update-progress.json` beside it names that step
|
||||
* (`progress`), so the dashboard can say how far the chain got rather than
|
||||
* only that it is busy; a final verdict removes it.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -43,6 +47,10 @@ const [, , marketplace, plugin, oldRoot, runtimeDirArg] = process.argv;
|
||||
// main() — which a require never runs — writes these.
|
||||
const RESULT_FILE = path.join(runtimeDirArg || "", "plugin-update.json");
|
||||
const LOG_FILE = path.join(runtimeDirArg || "", "plugin-update.log");
|
||||
const PROGRESS_FILE = path.join(runtimeDirArg || "", "plugin-update-progress.json");
|
||||
|
||||
/** The chain's steps, in order, as `progress` reports them. */
|
||||
const STEPS = ["marketplace_update", "plugin_update", "bootstrap"];
|
||||
|
||||
// Names come from the local cache PATH, never from an HTTP body, but they still
|
||||
// reach an argv — so they are re-validated here rather than trusted across the
|
||||
@@ -59,13 +67,34 @@ function log(line) {
|
||||
}
|
||||
}
|
||||
|
||||
function record(result) {
|
||||
/** Every verdict but the pre-written handover one (`final: false`) ends the
|
||||
* chain, so its progress record goes with it. */
|
||||
function record(result, { final = true } = {}) {
|
||||
try {
|
||||
fs.mkdirSync(runtimeDirArg, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
RESULT_FILE,
|
||||
JSON.stringify({ ...result, finished_at: new Date().toISOString() }, null, 2)
|
||||
);
|
||||
if (final) fs.rmSync(PROGRESS_FILE, { force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Say which step is running now — a courtesy for the UI, never a verdict. */
|
||||
function progress(step) {
|
||||
try {
|
||||
fs.mkdirSync(runtimeDirArg, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
PROGRESS_FILE,
|
||||
JSON.stringify({
|
||||
step,
|
||||
index: STEPS.indexOf(step) + 1,
|
||||
total: STEPS.length,
|
||||
started_at: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
@@ -182,6 +211,7 @@ function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
progress("marketplace_update");
|
||||
try {
|
||||
run(claude, ["plugin", "marketplace", "update", marketplace], 300_000);
|
||||
} catch (err) {
|
||||
@@ -189,6 +219,7 @@ function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
progress("plugin_update");
|
||||
try {
|
||||
run(claude, ["plugin", "update", `${plugin}@${marketplace}`, "-y"], 600_000);
|
||||
} catch (err) {
|
||||
@@ -219,12 +250,16 @@ function main() {
|
||||
// `last_plugin_update: null` for an update that had in fact succeeded. A
|
||||
// pre-written record is overwritten by the real outcome when this survives,
|
||||
// and is the outcome when it does not.
|
||||
record({
|
||||
ok: true,
|
||||
step: "bootstrap",
|
||||
plugin_root: newRoot,
|
||||
note: "the new plugin version is installed; the server is being handed over",
|
||||
});
|
||||
record(
|
||||
{
|
||||
ok: true,
|
||||
step: "bootstrap",
|
||||
plugin_root: newRoot,
|
||||
note: "the new plugin version is installed; the server is being handed over",
|
||||
},
|
||||
{ final: false }
|
||||
);
|
||||
progress("bootstrap");
|
||||
try {
|
||||
// Foreground and blocking: this stops the old server (us), waits for the
|
||||
// ports, then starts the new one. It must finish before we record success.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
* file, a folder, a zip, several skills at once — name and frontmatter set,
|
||||
* 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, and same-origin writes.
|
||||
* developer, the admin's installs switch, same-origin writes, Claude Docs
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -120,6 +124,42 @@ describe("pages", () => {
|
||||
assert.equal(artifacts.captureFromHook("s1", { tool_response: "no link" }), null);
|
||||
});
|
||||
|
||||
it("records a Claude Docs doc at its birth, never on a later batch", () => {
|
||||
const tool = "mcp__claude_ai_Claude_Docs__batch";
|
||||
assert.equal(artifacts.isPageTool(tool), true);
|
||||
assert.equal(artifacts.isPageTool("mcp__claude_ai_Claude_Docs__update"), false);
|
||||
assert.equal(artifacts.isPageTool("Artifact"), true);
|
||||
const row = artifacts.captureFromHook("s1", {
|
||||
tool_name: tool,
|
||||
tool_input: { container: { kind: "project", create: { name: "Q3 plan" } }, batch: [] },
|
||||
tool_response: JSON.stringify({ frame: { url: "https://claude.ai/artifact/DocAbc123" } }),
|
||||
});
|
||||
assert.equal(row.url, "https://claude.ai/artifact/DocAbc123");
|
||||
assert.equal(row.title, "Q3 plan");
|
||||
const edit = artifacts.captureFromHook("s1", {
|
||||
tool_name: tool,
|
||||
tool_input: { container: { kind: "project", id: "x" }, batch: [] },
|
||||
tool_response: JSON.stringify({ frame: { url: "https://claude.ai/artifact/DocOther9" } }),
|
||||
});
|
||||
assert.equal(edit, null);
|
||||
// The doc's own link wins over one its content cites earlier in the ack.
|
||||
const cited = artifacts.captureFromHook("s1", {
|
||||
tool_name: tool,
|
||||
tool_input: { container: { kind: "project", create: { name: "Notes" } }, batch: [] },
|
||||
tool_response: [
|
||||
{
|
||||
type: "text",
|
||||
text:
|
||||
JSON.stringify({
|
||||
acks: [{ xml: "see https://claude.ai/artifact/CitedOne1" }],
|
||||
frame: { artifactUrl: "https://claude.ai/artifact/OwnDoc22" },
|
||||
}) + "\n\nThe doc's viewer is at https://claude.ai/artifact/OwnDoc22 — open it.",
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(cited.url, "https://claude.ai/artifact/OwnDoc22");
|
||||
});
|
||||
|
||||
it("gives a page from a session no workspace owns to the member who sent the hook", () => {
|
||||
const uid = db
|
||||
.prepare("INSERT INTO users (username, password_hash) VALUES ('ann', 'x')")
|
||||
@@ -300,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 });
|
||||
@@ -711,3 +814,101 @@ describe("search, frontmatter and marketplace limits", () => {
|
||||
assert.ok(pulled < 30, `read ${pulled} chunks`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("outputs captured at a session's stop", () => {
|
||||
it("snapshots new documents once, refreshes a changed one, ignores the rest", async () => {
|
||||
const past = new Date(Date.now() - 3600 * 1000);
|
||||
fs.utimesSync(path.join(ws, "out", "report.html"), past, past); // older than the session
|
||||
const deck = path.join(ws, "out", "deck.pptx");
|
||||
fs.writeFileSync(deck, "PK-deck-v1");
|
||||
fs.writeFileSync(path.join(ws, "notes.md"), "not an output");
|
||||
const soon = new Date(Date.now() + 1000); // even a future mtime is captured once
|
||||
fs.utimesSync(deck, soon, soon);
|
||||
|
||||
const first = await artifacts.captureOutputs("s1");
|
||||
assert.deepEqual(
|
||||
first.map((r) => r.source),
|
||||
["out/deck.pptx"]
|
||||
);
|
||||
assert.equal(first[0].auto, 1);
|
||||
assert.equal(first[0].lane_id, lane.id);
|
||||
assert.equal((await artifacts.captureOutputs("s1")).length, 0, "unchanged: not again");
|
||||
|
||||
fs.writeFileSync(deck, "PK-deck-v2");
|
||||
const later = new Date(Date.now() + 60 * 1000);
|
||||
fs.utimesSync(deck, later, later);
|
||||
const again = await artifacts.captureOutputs("s1");
|
||||
assert.equal(again.length, 1);
|
||||
assert.equal(again[0].id, first[0].id, "same row, new snapshot");
|
||||
assert.notEqual(again[0].snapshot, first[0].snapshot);
|
||||
assert.equal(
|
||||
artifacts.list(null, { kind: "file" }).find((i) => i.id === first[0].id).auto,
|
||||
true
|
||||
);
|
||||
assert.deepEqual(await artifacts.captureOutputs("no-such-session"), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unowned rows", () => {
|
||||
it("are suggested, claimed, declined and assigned only by the right people", () => {
|
||||
const addUser = (name, role) =>
|
||||
Number(
|
||||
db
|
||||
.prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', ?)")
|
||||
.run(name, role).lastInsertRowid
|
||||
);
|
||||
const bobId = addUser("bob-u", "developer");
|
||||
const amyId = addUser("amy-u", "developer");
|
||||
const adminId = addUser("root-u", "admin");
|
||||
const bob = { method: "POST", user: { id: bobId, role: "developer", username: "bob-u" } };
|
||||
const amy = { method: "POST", user: { id: amyId, role: "developer", username: "amy-u" } };
|
||||
const admin = { method: "POST", user: { id: adminId, role: "admin", username: "root-u" } };
|
||||
db.prepare("UPDATE lanes SET owner_id = ? WHERE id = ?").run(bobId, lane.id);
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
try {
|
||||
// A host session with no workspace, run inside bob's workspace folder.
|
||||
const row = artifacts.captureFromHook(
|
||||
"loose-host",
|
||||
{
|
||||
tool_input: { action: "publish" },
|
||||
tool_response: "https://claude.ai/artifact/un1",
|
||||
cwd: ws,
|
||||
},
|
||||
{ kind: "host" }
|
||||
);
|
||||
assert.equal(row.owner_id, null);
|
||||
assert.equal(row.suggested_owner_id, bobId);
|
||||
assert.ok(
|
||||
artifacts.list(bob, { scope: "unowned" }).some((i) => i.id === row.id && i.suggestedForMe)
|
||||
);
|
||||
assert.ok(
|
||||
artifacts.list(admin, { scope: "unowned" }).some((i) => i.id === row.id && i.unowned)
|
||||
);
|
||||
assert.equal(artifacts.list(amy, { scope: "unowned" }).length, 0);
|
||||
assert.equal(artifacts.canSeeId(amy, row.id), false);
|
||||
assert.throws(() => artifacts.claim(amy, row.id), { code: "ENOTFOUND" });
|
||||
|
||||
const claimed = artifacts.claim(bob, row.id);
|
||||
assert.equal(claimed.ownerId, bobId);
|
||||
assert.throws(() => artifacts.claim(bob, row.id), { code: "EOWNED" });
|
||||
|
||||
const other = artifacts.captureFromHook(
|
||||
"loose-host-2",
|
||||
{
|
||||
tool_input: { action: "publish" },
|
||||
tool_response: "https://claude.ai/artifact/un2",
|
||||
cwd: ws,
|
||||
},
|
||||
{ kind: "host" }
|
||||
);
|
||||
assert.deepEqual(artifacts.decline(bob, other.id), { id: other.id, declined: true });
|
||||
assert.equal(artifacts.canSeeId(bob, other.id), false, "declined: no longer offered");
|
||||
assert.throws(() => artifacts.assign(bob, other.id, bobId), { code: "ENOTFOUND" });
|
||||
assert.throws(() => artifacts.assign(admin, other.id, 999999), { code: "EBADFIELD" });
|
||||
assert.equal(artifacts.assign(admin, other.id, amyId).ownerId, amyId);
|
||||
} finally {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
}
|
||||
assert.equal(artifacts.list(null, { scope: "unowned" }).length, 0, "local mode has no owners");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,12 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const { readLastPluginUpdate, schedulePluginUpdate, runtimeDir } = require("../lib/plugin-update");
|
||||
const {
|
||||
readLastPluginUpdate,
|
||||
readPluginUpdateProgress,
|
||||
schedulePluginUpdate,
|
||||
runtimeDir,
|
||||
} = require("../lib/plugin-update");
|
||||
|
||||
const HELPER = path.join(__dirname, "..", "..", "scripts", "plugin-update-helper.js");
|
||||
|
||||
@@ -84,6 +89,43 @@ describe("readLastPluginUpdate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("readPluginUpdateProgress", () => {
|
||||
const write = (name, body) => {
|
||||
const rt = path.join(tmpDir, name);
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
if (body !== undefined) fs.writeFileSync(path.join(rt, "plugin-update-progress.json"), body);
|
||||
return rt;
|
||||
};
|
||||
|
||||
it("is null with no file and for malformed JSON", () => {
|
||||
assert.equal(readPluginUpdateProgress(write("prog-none")), null);
|
||||
assert.equal(readPluginUpdateProgress(write("prog-bad", "{nope")), null);
|
||||
});
|
||||
|
||||
it("reads the running step", () => {
|
||||
const started_at = new Date().toISOString();
|
||||
const rt = write(
|
||||
"prog-ok",
|
||||
JSON.stringify({ step: "plugin_update", index: 2, total: 3, started_at })
|
||||
);
|
||||
assert.deepEqual(readPluginUpdateProgress(rt), {
|
||||
step: "plugin_update",
|
||||
index: 2,
|
||||
total: 3,
|
||||
started_at,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a file left by a helper that died long ago", () => {
|
||||
const old = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
const rt = write(
|
||||
"prog-stale",
|
||||
JSON.stringify({ step: "bootstrap", index: 3, total: 3, started_at: old })
|
||||
);
|
||||
assert.equal(readPluginUpdateProgress(rt), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schedulePluginUpdate", () => {
|
||||
it("clears a stale verdict before spawning, so an old success can't be read as this run's result", () => {
|
||||
// Redirect the runtime dir into the temp tree — this must not touch the
|
||||
@@ -152,6 +194,20 @@ describe("plugin-update-helper name guard", () => {
|
||||
assert.equal(v.step, "validate");
|
||||
});
|
||||
|
||||
it("removes a progress record with its final verdict", () => {
|
||||
const rt = path.join(tmpDir, "guard-progress");
|
||||
fs.mkdirSync(rt, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(rt, "plugin-update-progress.json"),
|
||||
JSON.stringify({ step: "plugin_update", started_at: new Date().toISOString() })
|
||||
);
|
||||
execFileSync(process.execPath, [HELPER, "../evil", "agenthub", "/nonexistent/root", rt], {
|
||||
stdio: "ignore",
|
||||
timeout: 30_000,
|
||||
});
|
||||
assert.equal(fs.existsSync(path.join(rt, "plugin-update-progress.json")), false);
|
||||
});
|
||||
|
||||
it("stamps every verdict with a finish time", () => {
|
||||
// The failure mode this file exists to prevent is a helper that dies
|
||||
// silently: the tab would poll a server that never returns and say nothing.
|
||||
|
||||
@@ -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,8 +88,13 @@ 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);
|
||||
const east = await call("/api/users?tz_offset=-840", admin);
|
||||
assert.equal(east.body.users.find((u) => u.username === "mia").today.tokens, mia.today.tokens);
|
||||
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
|
||||
assert.equal((await call("/api/users", await login("ana", "analyst-pass-1"))).status, 200);
|
||||
const member = await call("/api/users", await login("mia", "member-pass-1"));
|
||||
|
||||
@@ -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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user