feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt

- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the
  Artifact tool's hook), pinned workspace files, skills and plugins, each
  kept as a snapshot; shared ones appear in a Hub and a skill or plugin
  installs into the viewer's own account (server/lib/artifacts.js,
  /api/artifacts, docs/plans/artifacts/).
- Workspace extra folders: clone one or more related repos beside the
  workspace and add them in one step (POST /api/lanes/:id/sources/clone).
- A pane that ended by itself while tmux kept running is no longer offered
  for resume (tmux.serverStartedAt in reconcileOrphans).
- Changes panel: a folder that is not a git repo opens on Files, and its
  files open from there.
- Plus the parallel sessions' work in this tree: File Explorer button in the
  panel header, user delete, RSA SSH keys, chat/terminal and pet fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 16:04:03 +07:00
parent e94038481e
commit 22de5009ff
79 changed files with 4617 additions and 231 deletions
+846
View File
@@ -0,0 +1,846 @@
/**
* @file Artifacts.tsx
* @description `/artifacts` — the Artifacts hub (`/api/artifacts`,
* server/lib/artifacts.js): claude.ai pages sessions published, pinned
* workspace files, skills and plugins. **My library** lists the caller's
* own; in server mode the **Hub** tab lists what every user shared, and a
* shared skill or plugin is installed into the viewer's own account from
* there (after a confirmation that lists what will be written or run). An
* admin's switch turns Hub installs off for everyone. Previews of a file are
* the server's sandboxed `raw` route in a sandboxed iframe, so nothing a
* shared HTML file carries runs on the dashboard's origin.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Download,
ExternalLink,
Eye,
FileText,
Globe,
Package,
Pencil,
Plus,
Share2,
Sparkles,
Trash2,
X,
} from "lucide-react";
import {
api,
type Artifact,
type ArtifactCandidate,
type ArtifactKind,
type ArtifactSettings,
} from "../lib/api";
import type { Lane } from "../lib/types";
import { eventBus } from "../lib/eventBus";
import { toast } from "../lib/toast";
import { confirmDialog } from "../lib/confirm";
import { useDialog } from "../hooks/useDialog";
import { Switch } from "../components/Switch";
const KIND_ICON = { page: Globe, file: FileText, skill: Sparkles, plugin: Package } as const;
const KINDS: ArtifactKind[] = ["page", "file", "skill", "plugin"];
function fmtBytes(n: number | null): string {
if (!n) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i;
export function Artifacts() {
const { t } = useTranslation("artifacts");
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
const [tab, setTab] = useState<"mine" | "hub">("mine");
const [kind, setKind] = useState<ArtifactKind | "">("");
const [q, setQ] = useState("");
const [items, setItems] = useState<Artifact[] | null>(null);
const [adding, setAdding] = useState<ArtifactKind | null>(null);
const [addMenu, setAddMenu] = useState(false);
const [preview, setPreview] = useState<{ item: Artifact; path: string } | null>(null);
useEffect(() => {
api.artifacts
.settings()
.then(setSettings)
.catch(() => setSettings(null));
}, []);
const load = useCallback(() => {
api.artifacts
.list({ scope: tab, kind: kind || undefined, q: q.trim() || undefined })
.then((r) => setItems(r.items))
.catch((err) => {
setItems([]);
toast.error(err);
});
}, [tab, kind, q]);
useEffect(() => {
const id = window.setTimeout(load, q ? 250 : 0);
return () => window.clearTimeout(id);
}, [load, q]);
useEffect(
() =>
eventBus.subscribe((msg) => {
if (msg.type === "artifact_changed") load();
}),
[load]
);
const server = !!settings?.serverMode;
const share = async (a: Artifact) => {
try {
await api.artifacts.update(a.id, { shared: !a.shared });
toast.success(t(a.shared ? "unshareDone" : "shareDone"));
load();
} catch (err) {
toast.error(err);
}
};
const [renaming, setRenaming] = useState<Artifact | null>(null);
const remove = async (a: Artifact) => {
if (
!(await confirmDialog({
title: t("deleteTitle", { title: a.title }),
message: t("deleteBody"),
confirmLabel: t("deleteConfirm"),
}))
)
return;
try {
await api.artifacts.remove(a.id);
load();
} catch (err) {
toast.error(err);
}
};
const install = async (a: Artifact) => {
const message =
a.kind === "skill"
? `${t("installSkillBody", { name: a.source })} ${a.files.map((f) => f.path).join(", ")}`
: t("installPluginBody", { market: a.market, name: a.source });
if (
!(await confirmDialog({
title: t("installTitle", { title: a.title }),
message,
confirmLabel: t("installConfirm"),
destructive: false,
}))
)
return;
const done = (r: { installed: string }) =>
toast.success(
a.kind === "skill"
? t("installed", { path: r.installed })
: t("installedPlugin", { name: r.installed })
);
try {
done(await api.artifacts.install(a.id));
} catch (err) {
const code = (err as { code?: string }).code;
if (code !== "EEXISTS") return toast.error(err);
if (
!(await confirmDialog({
title: t("replaceTitle", { name: a.source }),
message: t("replaceBody"),
confirmLabel: t("replaceConfirm"),
}))
)
return;
try {
done(await api.artifacts.install(a.id, true));
} catch (e) {
toast.error(e);
}
}
load();
};
return (
<div className="space-y-4" data-testid="artifacts-page">
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
<p className="text-sm text-fg-muted">{t(server ? "subtitleServer" : "subtitle")}</p>
</div>
<div className="flex items-center gap-3">
{server && settings?.canManage && (
<label
className="flex items-center gap-2 text-xs text-fg-secondary"
title={t("installsSwitchHint")}
>
<Switch
checked={settings.installsEnabled}
label={t("installsSwitch")}
onChange={async (v) => {
try {
const r = await api.artifacts.setInstallsEnabled(v);
setSettings({ ...settings, installsEnabled: r.installsEnabled });
} catch (err) {
toast.error(err);
}
}}
/>
{t("installsSwitch")}
</label>
)}
<div className="relative">
<button
type="button"
className="btn-primary flex items-center gap-1.5 text-sm"
data-testid="artifacts-add"
onClick={() => setAddMenu((v) => !v)}
>
<Plus className="h-4 w-4" />
{t("add")}
</button>
{addMenu && (
<div className="absolute right-0 z-20 mt-1 w-56 rounded-lg border border-border bg-surface-1 p-1 shadow-xl">
{KINDS.map((k) => {
const Icon = KIND_ICON[k];
return (
<button
key={k}
type="button"
onClick={() => {
setAdding(k);
setAddMenu(false);
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
>
<Icon className="h-4 w-4" />
{t(`add.${k}`)}
</button>
);
})}
</div>
)}
</div>
</div>
</header>
<div className="flex flex-wrap items-center gap-2">
{server && (
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
{(["mine", "hub"] as const).map((v) => (
<button
key={v}
type="button"
aria-pressed={tab === v}
onClick={() => setTab(v)}
className={`rounded px-3 py-1 text-xs font-medium ${
tab === v
? "bg-accent-solid text-white"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{t(`tab.${v}`)}
</button>
))}
</div>
)}
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
{(["", ...KINDS] as const).map((k) => (
<button
key={k || "all"}
type="button"
aria-pressed={kind === k}
onClick={() => setKind(k)}
className={`rounded px-2.5 py-1 text-xs ${
kind === k
? "bg-surface-4 text-fg-primary"
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{t(k ? `kind.${k}` : "kind.all")}
</button>
))}
</div>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t("search")}
aria-label={t("search")}
className="ml-auto w-56 rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary"
/>
</div>
{items === null ? (
<p className="text-sm text-fg-muted">{t("loading")}</p>
) : items.length === 0 ? (
<p className="rounded-lg border border-dashed border-border px-4 py-10 text-center text-sm text-fg-muted">
{t(tab === "hub" ? "empty.hub" : "empty.mine")}
</p>
) : (
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3" data-testid="artifacts-list">
{items.map((a) => (
<ArtifactCard
key={a.id}
item={a}
server={server}
canInstall={!!settings?.installsEnabled}
onShare={() => void share(a)}
onRename={() => setRenaming(a)}
onDelete={() => void remove(a)}
onInstall={() => void install(a)}
onPreview={(path) => setPreview({ item: a, path })}
/>
))}
</ul>
)}
{adding && (
<AddDialog
kind={adding}
onClose={() => setAdding(null)}
onAdded={() => {
toast.success(t("added"));
load();
}}
/>
)}
{preview && <PreviewDialog {...preview} onClose={() => setPreview(null)} />}
{renaming && (
<RenameDialog
item={renaming}
onClose={() => setRenaming(null)}
onSaved={() => {
setRenaming(null);
load();
}}
/>
)}
</div>
);
}
function ArtifactCard({
item: a,
server,
canInstall,
onShare,
onRename,
onDelete,
onInstall,
onPreview,
}: {
item: Artifact;
server: boolean;
canInstall: boolean;
onShare: () => void;
onRename: () => void;
onDelete: () => void;
onInstall: () => void;
onPreview: (path: string) => void;
}) {
const { t } = useTranslation("artifacts");
const Icon = KIND_ICON[a.kind];
const btn =
"flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
return (
<li
className="flex flex-col gap-2 rounded-lg border border-border bg-surface-1 p-3"
data-testid={`artifact-${a.id}`}
>
<div className="flex items-start gap-2">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-fg-primary" title={a.title}>
{a.title}
</p>
<p className="truncate text-[11px] text-fg-muted" title={a.source || a.url || ""}>
{t(`kindOne.${a.kind}`)}
{a.source ? ` · ${a.source}` : ""}
{a.size ? ` · ${fmtBytes(a.size)}` : ""}
</p>
</div>
{a.shared && (
<span className="shrink-0 rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
{t("shared")}
</span>
)}
</div>
{a.description && (
<p className="line-clamp-2 text-xs text-fg-secondary">{a.description}</p>
)}
<p className="text-[11px] text-fg-muted">
{server && a.owner && !a.mine ? `${t("by", { name: a.owner })} · ` : ""}
{new Date(a.createdAt).toLocaleString()}
{a.installs > 0 ? ` · ${t("installs", { count: a.installs })}` : ""}
</p>
<div className="mt-auto flex flex-wrap items-center gap-1 border-t border-border-light pt-2">
{a.kind === "page" && a.url && (
<a href={a.url} target="_blank" rel="noopener noreferrer" className={btn}>
<ExternalLink className="h-3 w-3" />
{t("open")}
</a>
)}
{(a.kind === "file" || a.kind === "skill") && a.files.length > 0 && (
<button
type="button"
className={btn}
onClick={() => onPreview(a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? ""))}
>
<Eye className="h-3 w-3" />
{t("preview")}
</button>
)}
{a.kind === "file" && a.files.length > 0 && (
<a href={api.artifacts.rawUrl(a.id, a.files[0]?.path, true)} className={btn}>
<Download className="h-3 w-3" />
{t("download")}
</a>
)}
{(a.kind === "skill" || a.kind === "plugin") && (
<button
type="button"
className={btn}
disabled={!canInstall}
title={canInstall ? undefined : t("installsOff")}
onClick={onInstall}
>
<Download className="h-3 w-3" />
{t("install")}
</button>
)}
<span className="ml-auto flex items-center gap-1">
{server && a.canChange && (
<button type="button" className={btn} onClick={onShare}>
<Share2 className="h-3 w-3" />
{t(a.shared ? "unshare" : "share")}
</button>
)}
{a.canChange && (
<>
<button type="button" className={btn} aria-label={t("rename")} onClick={onRename}>
<Pencil className="h-3 w-3" />
</button>
<button type="button" className={btn} aria-label={t("delete")} onClick={onDelete}>
<Trash2 className="h-3 w-3" />
</button>
</>
)}
</span>
</div>
</li>
);
}
function Modal({
title,
onClose,
children,
wide = false,
}: {
title: string;
onClose: () => void;
children: React.ReactNode;
wide?: boolean;
}) {
const { t } = useTranslation("artifacts");
const panel = useRef<HTMLDivElement>(null);
useDialog(true, panel, onClose);
return (
<div
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
role="presentation"
onClick={onClose}
>
<div
ref={panel}
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
className={`flex max-h-[85vh] w-full ${wide ? "max-w-5xl" : "max-w-lg"} flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl`}
>
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
<h2 className="truncate text-sm font-semibold text-fg-primary">{title}</h2>
<button
type="button"
aria-label={t("close")}
onClick={onClose}
className="text-fg-muted hover:text-fg-primary"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
</div>
</div>
);
}
function RenameDialog({
item,
onClose,
onSaved,
}: {
item: Artifact;
onClose: () => void;
onSaved: () => void;
}) {
const { t } = useTranslation("artifacts");
const [title, setTitle] = useState(item.title);
const [description, setDescription] = useState(item.description);
const field =
"w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
return (
<Modal title={t("rename")} onClose={onClose}>
<form
className="space-y-2"
onSubmit={async (e) => {
e.preventDefault();
try {
await api.artifacts.update(item.id, { title, description });
onSaved();
} catch (err) {
toast.error(err);
}
}}
>
<input
className={field}
aria-label={t("form.title")}
value={title}
autoFocus
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className={field}
rows={3}
aria-label={t("form.description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="flex justify-end">
<button type="submit" className="btn-primary text-sm" disabled={!title.trim()}>
{t("form.saveEdit")}
</button>
</div>
</form>
</Modal>
);
}
function PreviewDialog({
item,
path,
onClose,
}: {
item: Artifact;
path: string;
onClose: () => void;
}) {
const [current, setCurrent] = useState(path);
const url = api.artifacts.rawUrl(item.id, current);
return (
<Modal title={item.title} onClose={onClose} wide>
<div className="flex h-[70vh] gap-3">
{item.files.length > 1 && (
<ul className="w-56 shrink-0 space-y-0.5 overflow-auto">
{item.files.map((f) => (
<li key={f.path}>
<button
type="button"
onClick={() => setCurrent(f.path)}
className={`w-full truncate rounded px-1.5 py-0.5 text-left font-mono text-[11px] ${
f.path === current
? "bg-surface-3 text-fg-primary"
: "text-fg-secondary hover:bg-surface-2"
}`}
title={f.path}
>
{f.path}
</button>
</li>
))}
</ul>
)}
<div className="min-w-0 flex-1 rounded border border-border bg-white">
{IMAGE.test(current) && !/\.svg$/i.test(current) ? (
<img src={url} alt={current} className="h-full w-full object-contain" />
) : (
// No allow-* tokens: a shared page renders, nothing in it runs.
<iframe
title={current}
src={url}
sandbox=""
className="h-full w-full"
data-testid="artifact-preview"
/>
)}
</div>
</div>
</Modal>
);
}
function AddDialog({
kind,
onClose,
onAdded,
}: {
kind: ArtifactKind;
onClose: () => void;
onAdded: () => void;
}) {
const { t } = useTranslation("artifacts");
const [busy, setBusy] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [url, setUrl] = useState("");
const [key, setKey] = useState("");
const [market, setMarket] = useState("");
const [lanes, setLanes] = useState<Lane[]>([]);
const [laneId, setLaneId] = useState<number | null>(null);
const [candidates, setCandidates] = useState<ArtifactCandidate[] | null>(null);
const [skills, setSkills] = useState<{ name: string; from: "user" | "workspace" }[] | null>(
null
);
useEffect(() => {
if (kind !== "file" && kind !== "skill") return;
api.lanes
.list()
.then((r) => setLanes(r.lanes))
.catch(() => setLanes([]));
}, [kind]);
useEffect(() => {
if (kind === "file") {
setCandidates(null);
if (laneId)
api.artifacts
.candidates(laneId)
.then((r) => setCandidates(r.items))
.catch((err) => {
setCandidates([]);
toast.error(err);
});
}
if (kind === "skill") {
api.artifacts
.skills(laneId ?? undefined)
.then((r) => setSkills(r.items))
.catch(() => setSkills([]));
}
}, [kind, laneId]);
const run = async (fn: () => Promise<unknown>, close = true) => {
setBusy(true);
try {
await fn();
onAdded();
if (close) onClose();
} catch (err) {
toast.error(err);
} finally {
setBusy(false);
}
};
const field =
"w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
const meta = (
<>
<input
className={field}
placeholder={t("form.title")}
aria-label={t("form.title")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
className={field}
rows={2}
placeholder={t("form.description")}
aria-label={t("form.description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</>
);
const workspacePicker = (
<select
className={field}
aria-label={t("form.workspace")}
value={laneId ?? ""}
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
>
<option value="">{t("form.pickWorkspace")}</option>
{lanes.map((l) => (
<option key={l.id} value={l.id}>
{l.title || l.cwd}
</option>
))}
</select>
);
return (
<Modal title={t(`add.${kind}`)} onClose={onClose}>
<div className="space-y-2" data-testid={`artifacts-add-${kind}`}>
{kind === "page" && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(() => api.artifacts.addPage({ url, title, description }));
}}
>
<input
className={field}
placeholder="https://claude.ai/…"
aria-label={t("form.url")}
value={url}
autoFocus
onChange={(e) => setUrl(e.target.value)}
/>
{meta}
<div className="flex justify-end">
<button type="submit" className="btn-primary text-sm" disabled={busy || !url.trim()}>
{t("form.save")}
</button>
</div>
</form>
)}
{kind === "file" && (
<>
{workspacePicker}
{laneId && candidates === null && (
<p className="text-xs text-fg-muted">{t("loading")}</p>
)}
{candidates && candidates.length === 0 && (
<p className="text-xs text-fg-muted">{t("form.noCandidates")}</p>
)}
{candidates && candidates.length > 0 && (
<ul className="max-h-80 space-y-0.5 overflow-auto">
{candidates.map((c) => (
<li key={c.path} className="flex items-center gap-2 text-xs">
<span className="min-w-0 flex-1 truncate font-mono text-fg-secondary" title={c.path}>
{c.path}
</span>
{!c.exists ? (
<span className="text-fg-muted">{t("form.missing")}</span>
) : c.pinned ? (
<span className="text-fg-muted">{t("form.pinned")}</span>
) : (
<button
type="button"
className="btn-ghost text-xs"
disabled={busy}
onClick={() =>
void run(async () => {
await api.artifacts.pinFile({
laneId: laneId!,
path: c.path,
sessionId: c.sessionId,
});
setCandidates((list) =>
(list ?? []).map((x) => (x.path === c.path ? { ...x, pinned: true } : x))
);
}, false)
}
>
{t("form.pin")}
</button>
)}
</li>
))}
</ul>
)}
</>
)}
{kind === "skill" && (
<>
{workspacePicker}
{skills && skills.length === 0 && (
<p className="text-xs text-fg-muted">{t("form.noSkills")}</p>
)}
{skills && skills.length > 0 && (
<ul className="max-h-72 space-y-0.5 overflow-auto">
{skills.map((s) => (
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
<span className="text-fg-muted">
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
</span>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy}
onClick={() =>
void run(() =>
api.artifacts.addSkill({
name: s.name,
...(s.from === "workspace" && laneId ? { laneId } : {}),
title: title || undefined,
description: description || undefined,
})
)
}
>
{t("form.save")}
</button>
</li>
))}
</ul>
)}
{meta}
</>
)}
{kind === "plugin" && (
<form
className="space-y-2"
onSubmit={(e) => {
e.preventDefault();
void run(() => api.artifacts.addPlugin({ key, market, title, description }));
}}
>
<input
className={field}
placeholder="my-plugin@my-marketplace"
aria-label={t("form.pluginKey")}
value={key}
autoFocus
onChange={(e) => setKey(e.target.value)}
/>
<input
className={field}
placeholder="owner/repo"
aria-label={t("form.market")}
value={market}
onChange={(e) => setMarket(e.target.value)}
/>
{meta}
<div className="flex justify-end">
<button
type="submit"
className="btn-primary text-sm"
disabled={busy || !key.trim() || !market.trim()}
>
{t("form.save")}
</button>
</div>
</form>
)}
</div>
</Modal>
);
}
+22 -14
View File
@@ -3,9 +3,10 @@
* @description `/files` — the file manager (`FileBrowser`) as a page of its
* own, reached from the sidebar's **Files** entry. The location is kept in the
* URL (`?home=<me|user>&path=` or `?lane=<id>&path=`), so Back and a shared
* link land in the same folder. With neither it opens the user's own HOME
* (their Linux home under S3), or — where they have none — the last
* workspace shown (`agenthub.workspace.selected`), else the first.
* link land in the same folder. With neither it opens the workspace last
* shown (`agenthub.workspace.selected`) — reached from a workspace, Files is
* about THAT workspace — else the user's own HOME (their Linux home under S3),
* else the first workspace.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -31,24 +32,31 @@ export function FilesPage() {
const [params, setParams] = useSearchParams();
const askedLane = Number(params.get("lane"));
const askedHome = params.get("home");
const [start, setStart] = useState<Start | null>(
askedHome
? { home: askedHome }
: Number.isInteger(askedLane) && askedLane > 0
? { lane: askedLane }
: null
);
// The URL wins on every render: a place picked in the browser (another
// user's home) lands in the URL, and a frozen start would send the next
// folder opened there back to the first place.
const asked: Start | null = askedHome
? { home: askedHome }
: Number.isInteger(askedLane) && askedLane > 0
? { lane: askedLane }
: null;
const [fallback, setStart] = useState<Start | null>(null);
const start = asked ?? fallback;
const [none, setNone] = useState(false);
useEffect(() => {
if (start) return;
void (async () => {
const homes = await api.files.homes().catch(() => ({ homes: [] }));
if (homes.homes.some((h) => h.id === "me")) return setStart({ home: "me" });
// The workspace last shown first: Files reached from a workspace opens
// THAT workspace (from PhuNT → AC_SHOP it opened your own home, empty
// but for hidden files); the home is the fallback.
const { lanes } = await api.lanes.list().catch(() => ({ lanes: [] }));
const last = lastWorkspace();
const pick = lanes.find((l) => l.id === last) ?? lanes[0];
if (pick) setStart({ lane: pick.id });
const shown = lanes.find((l) => l.id === last);
if (shown) return setStart({ lane: shown.id });
const homes = await api.files.homes().catch(() => ({ homes: [] }));
if (homes.homes.some((h) => h.id === "me")) return setStart({ home: "me" });
if (lanes[0]) setStart({ lane: lanes[0].id });
else setNone(true);
})();
}, [start]);
+7 -4
View File
@@ -242,7 +242,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
};
}, [location.key, location.hash]);
const { isAdmin } = usePermissions();
const { isAdmin, local } = usePermissions();
const authState = useAuth().state;
const signedIn = authState.mode === "server" && !!authState.user;
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
@@ -394,15 +394,17 @@ export function SettingsHub({ section }: { section?: string } = {}) {
...s("git"),
render: () => (
<div className="space-y-6">
{/* Git logins are the host's own: an admin's. */}
{isAdmin && (
{/* Git logins are the host's own: an admin's. Not in local mode,
nor the identity below: git uses this machine's own config,
SSH keys and credential helper there. */}
{isAdmin && !local && (
<section id="git-credentials" className="scroll-mt-24">
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
<GitCredentialsConfig />
</section>
)}
{/* The user's own SSH key / HTTPS logins (S3: their own home). */}
{(isAdmin || signedIn) && (
{!local && (isAdmin || signedIn) && (
<section id="git-identity" className="scroll-mt-24">
<h2 className="mb-2 text-sm font-semibold text-fg-primary">
{t("gitIdentity.title")}
@@ -434,6 +436,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
...SETTINGS_INDEX.filter(
(e) =>
(isAdmin || !e.adminOnly) &&
(!local || !e.serverOnly) &&
(e.section === sec.id ||
(e.section === "agents/*" &&
sec.id.startsWith("agents/") &&
+39 -1
View File
@@ -64,12 +64,14 @@ import {
PanelLeftClose,
PanelRightClose,
Maximize2,
FolderOpen,
Minimize2,
ChartColumn,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { api } from "../lib/api";
import { usePermissions } from "../components/AuthGate";
import type {
CwdSuggestion,
LaneSource,
@@ -130,6 +132,7 @@ import PipelineMap from "../components/lanes/PipelineMap";
import LaneCard, { since } from "../components/lanes/LaneCard";
import { AddLaneModal } from "../components/lanes/AddLaneModal";
import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
import { FileExplorer } from "../components/lanes/FileExplorer";
import { WorkspaceContext } from "../components/lanes/WorkspaceContext";
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
@@ -659,6 +662,7 @@ function ConsoleArea({
export function Workspace() {
const { t: tLanes } = useTranslation("lanes");
const { local } = usePermissions();
const [searchParams, setSearchParams] = useSearchParams();
// Lane state: the list is the shared store the sidebar reads too; the
@@ -773,6 +777,10 @@ export function Workspace() {
// Whether the Changes panel is open. A viewer preference, so it lives in
// localStorage and never reaches the server — same rule as the lane strip's
// order (client/src/lib/laneOrderStorage.ts).
// The File Explorer dialog, opened from the Changes panel header.
const [explorerOpen, setExplorerOpen] = useState(false);
// Bumped when the File Explorer changed files: the Files tree reads again.
const [filesChanged, setFilesChanged] = useState(0);
const [changesOpen, setChangesOpen] = useState(() => {
try {
return window.localStorage.getItem(CHANGES_OPEN_KEY) === "1";
@@ -1511,6 +1519,35 @@ export function Workspace() {
>
<ChartColumn className="h-4 w-4" />
</button>
{/* The File Explorer, beside the window controls — on this
workspace (the session's own worktree when it has one).
Not in local mode: the desktop's file manager is there. */}
{local ? (
<span className="ml-auto" />
) : (
<>
<button
type="button"
data-testid="panel-file-explorer"
disabled={!panelAt}
onClick={() => setExplorerOpen(true)}
aria-label={tLanes("changes.openExplorer")}
title={tLanes("changes.openExplorer")}
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40"
>
<FolderOpen className="h-4 w-4" />
</button>
{panelAt && (
<FileExplorer
open={explorerOpen}
laneId={panelAt.laneId}
session={panelAt.session}
onChanged={() => setFilesChanged((n) => n + 1)}
onClose={() => setExplorerOpen(false)}
/>
)}
</>
)}
{/* A window's two controls: Full screen (Restore at full
screen, to the last dragged width) and Collapse. */}
<button
@@ -1519,7 +1556,7 @@ export function Workspace() {
onClick={() => setChangesSize(changesFull ? lastDraggedChanges() : "full")}
aria-label={changesSizeLabel}
title={changesSizeLabel}
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
className="flex h-6 w-6 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary"
>
{changesFull ? (
<Minimize2 className="h-4 w-4" />
@@ -1568,6 +1605,7 @@ export function Workspace() {
wide={changesRatio(changesSize) >= 0.45}
onDirtyChange={dirtyFor(key)}
openRequest={source ? null : changesRequest}
filesChanged={source ? 0 : filesChanged}
// 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.
@@ -0,0 +1,128 @@
/**
* @file Tests for the `/artifacts` page: the library lists what the server
* answers, the Hub tab and Share appear only in server mode, a skill is
* installed only after a confirmation (and a taken name asks to replace),
* and a file preview is a sandboxed iframe with no `allow-*` token.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { Artifacts } from "../Artifacts";
import { api, type Artifact } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
vi.mock("../../lib/confirm", () => ({ confirmDialog: vi.fn() }));
vi.mock("../../lib/api", () => ({
api: {
lanes: { list: vi.fn() },
artifacts: {
settings: vi.fn(),
list: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
install: vi.fn(),
setInstallsEnabled: vi.fn(),
rawUrl: (id: number, path?: string) => `/api/artifacts/${id}/raw?path=${path ?? ""}`,
},
},
}));
const item = (over: Partial<Artifact>): Artifact => ({
id: 1,
kind: "file",
title: "report.html",
description: "",
ownerId: 1,
owner: "vi",
mine: true,
canChange: true,
laneId: 1,
sessionId: null,
source: "out/report.html",
market: null,
url: null,
files: [{ path: "report.html", size: 10 }],
size: 10,
shared: false,
sharedAt: null,
installs: 0,
createdAt: "2026-10-14T00:00:00Z",
updatedAt: "2026-10-14T00:00:00Z",
...over,
});
const open = () =>
render(
<MemoryRouter>
<Artifacts />
</MemoryRouter>
);
beforeEach(() => {
vi.mocked(api.artifacts.settings).mockResolvedValue({
serverMode: false,
canShare: false,
installsEnabled: true,
canManage: true,
});
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({}),
item({ id: 2, kind: "skill", title: "deploy", source: "deploy", files: [{ path: "SKILL.md", size: 3 }] }),
],
});
vi.mocked(confirmDialog).mockReset();
vi.mocked(api.artifacts.install).mockReset();
});
describe("Artifacts", () => {
it("lists the library, with no Hub or Share in local mode", async () => {
open();
expect(await screen.findByText("report.html")).toBeInTheDocument();
expect(screen.getByText("deploy")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Hub" })).toBeNull();
expect(screen.queryByRole("button", { name: /Share/ })).toBeNull();
});
it("shows the Hub and Share in server mode", async () => {
vi.mocked(api.artifacts.settings).mockResolvedValue({
serverMode: true,
canShare: true,
installsEnabled: true,
canManage: false,
});
open();
expect(await screen.findByRole("button", { name: "Hub" })).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /^Share$/ }).length).toBe(2);
});
it("installs a skill only after a confirmation, and asks before replacing", async () => {
const user = userEvent.setup();
open();
await screen.findByText("deploy");
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(screen.getByRole("button", { name: "Install" }));
expect(api.artifacts.install).not.toHaveBeenCalled();
vi.mocked(confirmDialog).mockResolvedValue(true);
vi.mocked(api.artifacts.install)
.mockRejectedValueOnce(Object.assign(new Error("taken"), { code: "EEXISTS" }))
.mockResolvedValueOnce({ installed: "/h/.claude/skills/deploy", replaced: true });
await user.click(screen.getByRole("button", { name: "Install" }));
await waitFor(() => expect(api.artifacts.install).toHaveBeenLastCalledWith(2, true));
const calls = vi.mocked(confirmDialog).mock.calls;
expect(calls[calls.length - 1]?.[0].title).toMatch(/Replace/);
});
it("previews a file in a sandboxed iframe that may run nothing", async () => {
const user = userEvent.setup();
open();
await screen.findByText("report.html");
await user.click(screen.getAllByRole("button", { name: "Preview" })[0]!);
const frame = await screen.findByTestId("artifact-preview");
expect(frame.getAttribute("sandbox")).toBe("");
expect(frame.getAttribute("src")).toBe("/api/artifacts/1/raw?path=report.html");
});
});
@@ -0,0 +1,89 @@
/**
* @file Tests for the `/files` page's starting place: the workspace last
* shown wins (from PhuNT → AC_SHOP, Files opens AC_SHOP — not the viewer's
* own home, empty but for hidden files), the home is the fallback, and an
* explicit `?home=` / `?lane=` is obeyed — also when it changes after the
* page opened (an admin opening another user's home, then a folder in it).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, render, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { FilesPage } from "../FilesPage";
import { api } from "../../lib/api";
type Props = {
laneId?: number | null;
initialHome?: string | null;
onNavigate?: (place: unknown, path: string) => void;
};
const seen: Array<{ laneId?: number | null; initialHome?: string | null }> = [];
let nav: Props["onNavigate"];
vi.mock("../../components/lanes/FileExplorer", () => ({
FileBrowser: (p: Props) => {
seen.push({ laneId: p.laneId, initialHome: p.initialHome });
nav = p.onNavigate;
return null;
},
}));
vi.mock("../../lib/api", () => ({
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
api: {
lanes: { list: vi.fn() },
files: { homes: vi.fn() },
},
}));
const open = (url = "/files") =>
render(
<MemoryRouter initialEntries={[url]}>
<FilesPage />
</MemoryRouter>
);
beforeEach(() => {
seen.length = 0;
localStorage.clear();
vi.mocked(api.lanes.list).mockResolvedValue({
lanes: [
{ id: 1, title: "AgentHub", cwd: "/a" },
{ id: 2, title: "AC_SHOP", cwd: "/srv/agenthub/users/phunt/AC_SHOP" },
],
counts: {},
} as never);
vi.mocked(api.files.homes).mockResolvedValue({
homes: [{ id: "me", label: "Vi", readOnly: false }],
});
});
describe("FilesPage", () => {
it("opens the workspace last shown, not the viewer's own home", async () => {
localStorage.setItem("agenthub.workspace.selected", "2");
open();
await waitFor(() => expect(seen[seen.length - 1]).toEqual({ laneId: 2, initialHome: null }));
});
it("falls back to the home when no workspace was shown", async () => {
open();
await waitFor(() => expect(seen[seen.length - 1]).toEqual({ laneId: null, initialHome: "me" }));
});
it("obeys an explicit ?home= or ?lane=", async () => {
localStorage.setItem("agenthub.workspace.selected", "2");
open("/files?home=me");
await waitFor(() => expect(seen[seen.length - 1]).toEqual({ laneId: null, initialHome: "me" }));
seen.length = 0;
open("/files?lane=1");
await waitFor(() => expect(seen[seen.length - 1]).toEqual({ laneId: 1, initialHome: null }));
});
it("follows a place picked in the browser, so a folder opened there stays there", async () => {
open("/files?home=me");
await waitFor(() => expect(nav).toBeTruthy());
act(() => nav!({ home: "phunt" }, ""));
act(() => nav!({ home: "phunt" }, "AC_SHOP"));
await waitFor(() =>
expect(seen[seen.length - 1]).toEqual({ laneId: null, initialHome: "phunt" })
);
});
});
@@ -20,6 +20,15 @@ import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import type { AiTool, AiToolsView } from "../../lib/api";
// Local mode unless a test says otherwise; still an admin either way.
const perms = vi.hoisted(() => ({ local: true }));
vi.mock("../../components/AuthGate", async (importOriginal) => {
const m = await importOriginal<typeof import("../../components/AuthGate")>();
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) };
});
vi.mock("../../components/settings/GitIdentitySettings", () => ({
GitIdentitySettings: () => <div data-testid="git-identity" />,
}));
vi.mock("../Settings", () => ({
Settings: ({ section }: { section: string }) => (
<div data-testid={`settings-${section}`} data-settings-anchor={`${section}-row`} />
@@ -108,6 +117,7 @@ function renderAt(at: string) {
const nav = () => screen.getByRole("navigation", { name: "Settings sections" });
beforeEach(() => {
perms.local = true;
__resetAiTools();
view = {
default: "claude",
@@ -288,9 +298,19 @@ describe("SettingsHub", () => {
});
it("shows git logins and worktree suggestions on one Git page", async () => {
perms.local = false;
renderAt("/settings/git");
expect(await screen.findByRole("heading", { level: 1, name: "Git" })).toBeInTheDocument();
expect(screen.getByTestId("git-credentials")).toBeInTheDocument();
expect(screen.getByTestId("git-identity")).toBeInTheDocument();
expect(screen.getByTestId("worktree-visibility")).toBeInTheDocument();
});
it("keeps AgentHub's own git logins and identity off a local Git page", async () => {
renderAt("/settings/git");
expect(await screen.findByRole("heading", { level: 1, name: "Git" })).toBeInTheDocument();
expect(screen.queryByTestId("git-credentials")).toBeNull();
expect(screen.queryByTestId("git-identity")).toBeNull();
expect(screen.getByTestId("worktree-visibility")).toBeInTheDocument();
});
@@ -56,6 +56,12 @@ const { HOME, LANE_A, LANE_B } = vi.hoisted(() => {
// The workspace's home (its own suite) fetches sources and memory; these
// tests are about the session view it hides, so it is a stub here.
// Local mode unless a test says otherwise.
const perms = vi.hoisted(() => ({ local: true }));
vi.mock("../../components/AuthGate", async (importOriginal) => {
const m = await importOriginal<typeof import("../../components/AuthGate")>();
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) };
});
vi.mock("../../components/lanes/WorkspaceHome", () => ({
WorkspaceHome: () => <div data-testid="workspace-home" />,
paneRunId: (id: number, slot: number) =>
@@ -108,6 +114,12 @@ vi.mock("../../lib/api", async (importOriginal) => {
features: { list: r({ features: [] }), show: r({ feature: null }) },
proof: { list: r({ features: [] }), imageUrl: () => "" },
},
// The File Explorer opened from the panel header.
files: {
homes: r({ homes: [] }),
list: r({ path: "", parent: null, entries: [], truncated: false }),
trash: r({ items: [] }),
},
aiProxies: { list: r({ items: [] }) },
sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) },
},
@@ -147,6 +159,7 @@ async function settle() {
}
beforeEach(() => {
perms.local = true;
i18n.changeLanguage("en");
});
@@ -203,6 +216,22 @@ describe("Workspace — the Changes panel's width", () => {
expect(card().className).toContain("w-[25%]");
});
it("opens the File Explorer from the button beside Full screen", async () => {
perms.local = false;
await openPanel();
const btn = screen.getByTestId("panel-file-explorer");
// Right before the window controls, in the same header strip.
expect(btn.nextElementSibling).toBe(screen.getByTestId("lane-changes-resize"));
fireEvent.click(btn);
expect(await screen.findByTestId("file-explorer")).toBeInTheDocument();
});
it("has no File Explorer button in local mode", async () => {
await openPanel();
expect(screen.queryByTestId("panel-file-explorer")).toBeNull();
expect(screen.getByTestId("lane-changes-resize")).toBeInTheDocument();
});
it("remembers the width across a reload, and never asks the server", async () => {
await openPanel();
fireEvent.click(screen.getByTestId("lane-changes-resize"));
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.7.7 (rebuild client to match)
UI build v2.7.8 (rebuild client to match)
</span>
</p>
</div>