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
+19 -2
View File
@@ -64,6 +64,7 @@ import { GlobalTooltip } from "./components/Tooltip";
import { Toaster } from "./components/Toaster";
import { ConfirmHost } from "./components/ConfirmHost";
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
import { usePermissions } from "./components/AuthGate";
import { NotFound } from "./pages/NotFound";
import { useWebSocket } from "./hooks/useWebSocket";
import { useNotifications } from "./hooks/useNotifications";
@@ -89,6 +90,7 @@ const SnapshotView = lazy(() =>
);
const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
const Artifacts = lazy(() => import("./pages/Artifacts").then((m) => ({ default: m.Artifacts })));
function PageFallback() {
const { t } = useTranslation();
@@ -108,6 +110,11 @@ function Page({ children }: { children: ReactNode }) {
);
}
/** A page only a shared server needs (the File Explorer): local mode goes home. */
function ServerOnly({ children }: { children: ReactNode }) {
return usePermissions().local ? <Navigate to="/" replace /> : <>{children}</>;
}
/** Keyed by the id so moving to another session starts from fresh state. */
function SessionDetailRoute() {
const { id } = useParams();
@@ -212,13 +219,23 @@ export default function App() {
}
/>
<Route
path="files"
path="artifacts"
element={
<Page>
<FilesPage />
<Artifacts />
</Page>
}
/>
<Route
path="files"
element={
<ServerOnly>
<Page>
<FilesPage />
</Page>
</ServerOnly>
}
/>
<Route
path="users"
element={
+4
View File
@@ -63,6 +63,10 @@ export function usePermissions() {
const isAdmin = local || user?.role === "admin";
return {
isAdmin,
// Local mode: one person on their own machine — what only serves a
// shared server (File Explorer, uploads, AgentHub's own git logins) is
// hidden; their desktop and their own git config already do it.
local,
seesAll: local || (!!user && user.role !== "developer"),
// A developer only ever sees their own rows, so everything shown is theirs.
canEditLane: (lane?: { owner_id?: number | null } | null) =>
+40 -27
View File
@@ -149,15 +149,21 @@ export function usePrefersReducedMotion(): boolean {
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
* into view instead of being cropped.
*/
/** `front`: above the mascot itself — the pet menu, which may overlap it. */
/**
* `front`: above the mascot itself — the pet menu, which may overlap it.
* `beside`: at the mascot's side instead of above it — the chat window, on the
* screen side away from the edge it docks to, its bottom level with the pet's.
*/
function SagiFlyout({
anchor,
children,
front = false,
beside = false,
}: {
anchor: Anchor;
children: ReactNode;
front?: boolean;
beside?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
@@ -170,6 +176,18 @@ function SagiFlyout({
const vw = window.innerWidth;
const vh = window.innerHeight;
if (beside) {
let left =
anchor.side === "left"
? anchor.left + anchor.size + FLYOUT_GAP
: anchor.left - w - FLYOUT_GAP;
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
let top = anchor.top + anchor.size - h;
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
setStyle({ left, top, visibility: "visible" });
return;
}
// Horizontal: hug the avatar's docked edge, then clamp on-screen.
let left = anchor.side === "left" ? anchor.left : anchor.left + anchor.size - w;
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
@@ -182,7 +200,7 @@ function SagiFlyout({
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
setStyle({ left, top, visibility: "visible" });
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp]);
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp, beside]);
useLayoutEffect(() => {
place();
@@ -431,37 +449,32 @@ function SagiBody() {
onClearAlerts={brain.clearAlerts}
onNavigate={onNavigate}
onAsk={onAsk}
onOpenChat={() => {
setOpen(false);
setChatOpen(true);
}}
onClose={() => setOpen(false)}
/>
</SagiFlyout>
)}
{/* Kept mounted while dragging (only hidden): unmounting would detach
its terminal and lose a half-typed message. */}
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
it 1:1), and kept mounted: unmounting would detach its terminal and
lose a half-typed message. */}
{chatOpen && (
<div style={place.dragging ? { display: "none" } : undefined}>
<SagiFlyout anchor={anchor}>
<PetChat
name={name}
initialPrompt={chatPrompt}
onPromptTaken={() => setChatPrompt(null)}
onBack={() => {
setChatOpen(false);
setOpen(true);
}}
onClose={() => setChatOpen(false)}
onOpenWorkspace={(laneId, runId) => {
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
setChatOpen(false);
}}
onBusy={onBusy}
/>
</SagiFlyout>
</div>
<SagiFlyout anchor={anchor} beside>
<PetChat
name={name}
initialPrompt={chatPrompt}
onPromptTaken={() => setChatPrompt(null)}
onBack={() => {
setChatOpen(false);
setOpen(true);
}}
onClose={() => setChatOpen(false)}
onOpenWorkspace={(laneId, runId) => {
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
setChatOpen(false);
}}
onBusy={onBusy}
/>
</SagiFlyout>
)}
{!place.dragging && menu && (
-11
View File
@@ -60,7 +60,6 @@ import {
Radio,
Heart,
ChevronRight,
MessageCircle,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -89,8 +88,6 @@ interface SagiPanelProps {
onNavigate: (route: string) => void;
/** Returns an answer to display, or null when the query was handed off. */
onAsk: (query: string) => string | null;
/** Opens the chat window (PetChat). */
onOpenChat: () => void;
onClose: () => void;
}
@@ -108,7 +105,6 @@ export function SagiPanel({
onClearAlerts,
onNavigate,
onAsk,
onOpenChat,
onClose,
}: SagiPanelProps) {
const { t } = useTranslation("settings");
@@ -282,13 +278,6 @@ export function SagiPanel({
{/* quick actions */}
<div className="mb-3 grid grid-cols-2 gap-1.5">
<div className="col-span-2">
<ActionButton
icon={MessageCircle}
label={t("sagi.panel.chat", { name })}
onClick={onOpenChat}
/>
</div>
<ActionButton
icon={muted ? BellOff : Bell}
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
@@ -144,10 +144,14 @@ describe("Sagi widget", () => {
expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument();
});
it("opens the chat from the panel and goes back to it", () => {
it("has no Chat button — Ask opens the chat — and Back returns to the panel", () => {
localStorage.setItem("agenthub.defaultTool", "claude");
renderSagi();
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
fireEvent.click(screen.getByRole("button", { name: /chat with sagi/i }));
expect(screen.queryByRole("button", { name: /chat with sagi/i })).not.toBeInTheDocument();
const input = screen.getByLabelText(/ask sagi/i);
fireEvent.change(input, { target: { value: "write me a haiku" } });
fireEvent.submit(input.closest("form")!);
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Back" }));
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
+7 -2
View File
@@ -57,6 +57,7 @@ import {
LayoutDashboard,
FolderOpen,
HardDrive,
Gem,
BarChart3,
Settings,
Wifi,
@@ -91,6 +92,8 @@ const NAV_KEYS = [
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
// The file manager (FilesPage): browse, upload and download workspace files.
{ to: "/files", icon: HardDrive, key: "nav:files" },
// What agents produced, and (server mode) the Hub others shared (Artifacts.tsx).
{ to: "/artifacts", icon: Gem, key: "nav:artifacts" },
] as const;
const STORAGE_KEY = "sidebar-collapsed";
@@ -197,7 +200,8 @@ export function Sidebar({
location.pathname.startsWith("/settings") ||
new URLSearchParams(location.search).has("settings");
// Checking for (and applying) an update acts on the host: an admin's.
const canUpdate = usePermissions().isAdmin;
const perms = usePermissions();
const canUpdate = perms.isAdmin;
const asideRef = useRef<HTMLElement>(null);
const drawerOpen = Boolean(drawer?.open);
useDialog(drawerOpen, asideRef, drawer?.onClose);
@@ -472,7 +476,8 @@ export function Sidebar({
<div className="flex-1 min-h-0 relative flex">
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
{[
...NAV_KEYS,
// The file manager serves a shared server; locally it is the desktop's.
...NAV_KEYS.filter((n) => !(perms.local && n.to === "/files")),
// Server mode: the Users page, for an admin or analyst.
...(auth.state.mode === "server" &&
auth.state.user &&
@@ -95,6 +95,8 @@ describe("Sidebar", () => {
expect(hrefs).toContain("/sessions");
expect(hrefs).toContain("/settings");
expect(hrefs).not.toContain("/activity");
// The file manager serves a shared server; a local dashboard has none.
expect(hrefs).not.toContain("/files");
});
describe("update status line, in the connection details modal", () => {
@@ -4,9 +4,11 @@
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
* and auto-linked URLs. An absolute file path (bare or in inline code) opens
* through `OpenFileContext` when the page provides one; `linkify` does the
* same links for plain text. To make a reply easy to scan, bold is coloured
* and auto-linked URLs. A file path — absolute, or relative with a folder and
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
* inline code or as a markdown link's target — opens through `OpenFileContext`
* when the page provides one (the page resolves a relative one against its own
* folder); `linkify` does the same links for plain text. To make a reply easy to scan, bold is coloured
* (accent), standalone numbers (87%, 3.5s, v2.7.1, dates) are tinted and
* tabular, and a flag label ("Lưu ý:", "Warning:", "Lỗi:") takes the colour
* of how serious it is.
@@ -256,7 +258,19 @@ const LINK_CLASS =
"text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all";
const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
// Two segments at least, so "/" or "/s" stays text; a trailing full stop is prose.
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
// A `:12` or `:12:5` line suffix belongs to the link (Claude writes `path:line`).
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?)?/;
// A relative path needs a folder AND an extension starting with a letter
// (`docs/API.md`, `./a/b.ts:9`), so "and/or" and "3/4.5" stay text; a first segment with an inner dot is a host
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
const REL_PATH_RE =
/^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u;
/** A path at the start of `text`, absolute or relative, or null. */
const matchPath = (text: string) => PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
// A markdown link target that is a page, not a file.
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
// A link starts a word: "and/or" or "a/b/c" stays text.
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
// A number worth spotting: 42, 1,234.5, 87%, $1.20, 3.5s, 12MB, v2.7.1, 2026-10-12, 12:30,
@@ -284,6 +298,17 @@ function UrlLink({ url }: { url: string }) {
);
}
/** A markdown link's file target as a path: `%20` decoded, `#L12` → `:12`. */
function decodeHref(href: string): string {
let p = href.replace(/^file:\/\//i, "").replace(/#L(\d+)(?:-L?\d+)?$/, ":$1").replace(/#.*$/, "");
try {
p = decodeURIComponent(p);
} catch {
/* a stray % stays as written */
}
return p;
}
/** A path as a button that opens it, or as it was when nothing can. */
function PathLink({ path, children }: { path: string; children: React.ReactNode }) {
const open = React.useContext(OpenFileContext);
@@ -292,7 +317,7 @@ function PathLink({ path, children }: { path: string; children: React.ReactNode
<button
type="button"
title={path}
onClick={() => open(path)}
onClick={() => open(stripLine(path))}
className={`${LINK_CLASS} text-left`}
>
{children}
@@ -307,7 +332,7 @@ export function linkify(text: string): React.ReactNode[] {
for (let i = 0; i < text.length;) {
const rest = text.slice(i);
const url = atWordStart(text, i) && URL_RE.exec(rest)?.[0];
const path = !url && atWordStart(text, i) && PATH_RE.exec(rest)?.[0];
const path = !url && atWordStart(text, i) && matchPath(rest);
const hit = url || path;
if (!hit) {
buf += text[i++];
@@ -359,7 +384,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
</code>
);
push(
PATH_RE.exec(codeM[1]!)?.[0] === codeM[1] ? (
matchPath(codeM[1]!) === codeM[1] ? (
<PathLink path={codeM[1]!}>{code}</PathLink>
) : (
code
@@ -408,9 +433,23 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
// Markdown link
const linkM = rest.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/);
if (linkM) {
const href = linkM[2]!;
// `[x.ts](src/x.ts#L12)` names a file: open it like a bare path.
if (
/^file:\/\//i.test(href) ||
(!SCHEME_RE.test(href) && !href.startsWith("#") && !href.startsWith("//"))
) {
push(
<PathLink path={decodeHref(href)}>
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
</PathLink>
);
i += linkM[0].length;
continue;
}
push(
<a
href={linkM[2]!}
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
@@ -431,10 +470,10 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
}
// An absolute path at a word start
const pathM = atWordStart(text, i) ? PATH_RE.exec(rest) : null;
const pathM = atWordStart(text, i) ? matchPath(rest) : null;
if (pathM) {
push(<PathLink path={pathM[0]}>{pathM[0]}</PathLink>);
i += pathM[0].length;
push(<PathLink path={pathM}>{pathM}</PathLink>);
i += pathM.length;
continue;
}
@@ -7,8 +7,52 @@
*/
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MarkdownContent } from "../MarkdownContent";
import { vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { MarkdownContent, OpenFileContext } from "../MarkdownContent";
import { resolveIn } from "../../../lib/resolvePath";
function withOpen(text: string) {
const open = vi.fn();
render(
<OpenFileContext.Provider value={open}>
<MarkdownContent text={text} />
</OpenFileContext.Provider>
);
return open;
}
describe("file links", () => {
it("opens a relative path with its line suffix stripped", () => {
const open = withOpen("See server/lib/x.js:42 for it.");
fireEvent.click(screen.getByRole("button", { name: "server/lib/x.js:42" }));
expect(open).toHaveBeenCalledWith("server/lib/x.js");
});
it("opens an absolute path with a line suffix and one in inline code", () => {
const open = withOpen("At /a/b/c.ts:9 and `docs/API.md`.");
fireEvent.click(screen.getByRole("button", { name: "/a/b/c.ts:9" }));
fireEvent.click(screen.getByRole("button", { name: "docs/API.md" }));
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
});
it("opens a markdown link to a file instead of navigating", () => {
const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
expect(open).toHaveBeenCalledWith("client/src/A B.tsx");
expect(screen.getByRole("link", { name: "web" })).toHaveAttribute("href", "https://x.dev");
});
it("leaves prose with slashes as text", () => {
withOpen("and/or, 3/4.5, github.com/x.js, TCP/IP.");
expect(screen.queryAllByRole("button")).toHaveLength(0);
});
it("resolves a relative path against the pane's folder", () => {
expect(resolveIn("/w/repo/", "./server/../docs/API.md")).toBe("/w/repo/docs/API.md");
expect(resolveIn("/w/repo", "/etc/x.conf")).toBe("/etc/x.conf");
});
});
describe("<MarkdownContent />", () => {
it("renders fenced code blocks with the language label", () => {
@@ -0,0 +1,188 @@
/**
* @file CloneSourcesForm — a workspace's "Or clone repositories" box, under
* its Folders card: one URL per line, each cloned BESIDE the workspace folder
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
* added as an extra folder, one after another. A host that wants a login
* (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
* URLs with it, like Add workspace → Clone.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Loader2, X } from "lucide-react";
import { api, type LaneSourcesView } from "../../lib/api";
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string };
type Login = { host: string; username: string; token: string; remember: boolean };
export function CloneSourcesForm({
laneId,
parent,
onAdded,
}: {
laneId: number;
/** Where the clones land: the workspace folder's parent, shown only. */
parent: string;
onAdded: (view: LaneSourcesView) => void;
}) {
const { t } = useTranslation("lanes");
const [text, setText] = useState("");
const [rows, setRows] = useState<Row[]>([]);
const [busy, setBusy] = useState(false);
const [login, setLogin] = useState<Login | null>(null);
const urls = (s: string) => [
...new Set(
s
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
),
];
const cloneAll = async (list: string[]) => {
if (busy || !list.length) return;
setBusy(true);
const next: Row[] = list.map((url) => ({ url, state: "wait" }));
const show = () => setRows([...next]);
show();
let asked: Login | null = null;
for (const row of next) {
row.state = "busy";
show();
try {
const view = await api.lanes.cloneFolder(laneId, {
url: row.url,
...(login?.username.trim() && login.token
? {
credentials: {
username: login.username.trim(),
password: login.token,
remember: login.remember,
},
}
: {}),
});
row.state = "done";
onAdded(view);
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || row.url;
row.state = "failed";
if (e.code === "ECREDENTIALS") {
row.note = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
asked ??= {
host,
username: login?.username ?? "",
token: "",
remember: login?.remember ?? true,
};
} else if (e.code === "ESSHAUTH") {
row.note = t("clone.sshRefused", { host });
} else {
row.note = e.message || String(err);
}
}
show();
}
if (asked) setLogin(asked);
// What is left to do stays in the box; what was added leaves it.
setText(
next
.filter((r) => r.state !== "done")
.map((r) => r.url)
.join("\n")
);
setBusy(false);
};
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
return (
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
<textarea
data-testid="workspace-clone-input"
aria-label={t("home.cloneFolders")}
placeholder={t("home.clonePlaceholder")}
rows={3}
value={text}
onChange={(e) => setText(e.target.value)}
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
{login && (
<div className="space-y-1.5 rounded border border-border-light p-2">
<div className="flex gap-2">
<input
aria-label={t("clone.username")}
placeholder={t("clone.username")}
value={login.username}
onChange={(e) => setLogin({ ...login, username: e.target.value })}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
/>
<input
type="password"
aria-label={t("clone.token")}
placeholder={t("clone.token")}
value={login.token}
onChange={(e) => setLogin({ ...login, token: e.target.value })}
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
/>
</div>
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
<input
type="checkbox"
checked={login.remember}
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
/>
{t("clone.remember")}
</label>
</div>
)}
<div className="flex justify-end gap-2">
{failed.length > 0 && !busy && (
<button type="button" onClick={() => void cloneAll(failed)} className="btn-ghost text-xs">
{t("home.cloneRetry")}
</button>
)}
<button
type="button"
data-testid="workspace-clone-run"
disabled={busy || !urls(text).length}
onClick={() => void cloneAll(urls(text))}
className="btn-ghost text-xs"
>
{busy ? t("clone.cloning") : t("home.cloneRun")}
</button>
</div>
{rows.length > 0 && (
<ul className="space-y-1" data-testid="workspace-clone-rows">
{rows.map((r) => (
<li key={r.url} className="text-[11px]">
<span className="flex items-center gap-1.5">
{r.state === "busy" ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-fg-muted" />
) : r.state === "done" ? (
<Check
className="h-3 w-3 shrink-0 text-status-success"
aria-label={t("home.cloneDone")}
/>
) : r.state === "failed" ? (
<X className="h-3 w-3 shrink-0 text-status-danger" />
) : (
<span className="h-3 w-3 shrink-0" />
)}
<span className="min-w-0 truncate font-mono text-fg-secondary" title={r.url}>
{r.url}
</span>
</span>
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
</li>
))}
</ul>
)}
</div>
);
}
+13 -3
View File
@@ -270,9 +270,13 @@ export function FileBrowser({
const folderInput = useRef<HTMLInputElement>(null);
const ctx = useContextMenu<FsEntry | null>();
// A new caller target (another workspace's Files tab, the page's URL).
// A new caller target (another workspace's Files tab, the page's URL) —
// not the echo of our own `go()` through the page's URL, which would wipe
// Back's history on every folder opened.
useEffect(() => {
setTarget(placeOf());
const next = placeOf();
if (samePlace(next, target) && initialPath === path) return;
setTarget(next);
setPathState(initialPath);
setHistory({ back: [], fwd: [] });
setSelected(new Set());
@@ -1145,7 +1149,13 @@ export function FileBrowser({
{listing && !entries.length && !creating && !error && (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-fg-muted">
<Folder className="h-16 w-16 opacity-40" />
<p className="text-sm">{query ? t("fx.noMatch") : t("fx.empty")}</p>
<p className="max-w-md text-center text-sm">
{query
? t("fx.noMatch")
: isHomePlace(target) && !path
? t("fx.homeEmpty")
: t("fx.empty")}
</p>
</div>
)}
{loading && !listing && <p className="p-6 text-sm text-fg-muted">{t("fx.loading")}</p>}
@@ -102,9 +102,9 @@ import {
Sparkles,
Trash2,
UnfoldVertical,
FolderOpen,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { usePermissions } from "../AuthGate";
import { pasteToLane } from "../../lib/panePaste";
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
@@ -810,8 +810,12 @@ export function LaneChangesPanel({
wide = full,
onDirtyChange,
openRequest = null,
filesChanged = 0,
}: {
laneId: number;
/** Bumped when files changed outside this panel (the header's File
* Explorer): the tree is read again, its open folders kept open. */
filesChanged?: number;
/** Open this file (relative to the panel's folder); a new `nonce` asks again. */
openRequest?: { path: string; nonce: number } | null;
/** The whole-file editor holds unsaved text (true) or not (false). */
@@ -855,6 +859,9 @@ export function LaneChangesPanel({
// history and the tree too, not just the diff `load` re-reads.
const [reread, setReread] = useState(0);
const [range, setRange] = useState<LaneChangeRange>("work");
// Local mode: the files are already on this machine — no upload, download
// or File Explorer (the desktop's file manager does all three).
const { local } = usePermissions();
const [mode, setMode] = useState<Mode>("overview");
const [openFile, setOpenFile] = useState<string | null>(null);
// An image opens as a picture, never as text or a binary diff.
@@ -870,6 +877,16 @@ export function LaneChangesPanel({
const [mdPreview, setMdPreview] = useState(false);
useEffect(() => setMdPreview(false), [openFile]);
const [data, setData] = useState<LaneChanges | null>(null);
// A folder that is not a repo (an empty new workspace, a parent of clones)
// has no changes, review or history — only its files, which Files reads
// from the disk. Open there instead of on a page that only says "no git".
// Both reads must say so: the facts alone also read "unavailable" on a
// network error or an older server.
const notRepo = git?.available === false && data?.available === false;
useEffect(() => {
if (notRepo && (mode === "overview" || mode === "review" || mode === "history"))
setMode(full ? "overview" : "browse");
}, [notRepo, mode, full]);
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
// A folder's children, keyed by its path ("" is the root) — fetched once,
// on first expand, and kept so re-collapsing doesn't re-fetch.
@@ -1078,19 +1095,32 @@ export function LaneChangesPanel({
}, [mode, laneId, query, reread, full, treeRootLoaded]);
// A folder's children are fetched lazily, the first time it is expanded.
const readDir = (path: string) =>
api.lanes
.gitTree(target, path)
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
const toggleDir = (path: string) => {
const opening = !expandedDirs.has(path);
setExpandedDirs((prev) => {
const next = new Set(prev);
if (!next.delete(path)) next.add(path);
return next;
});
if (path in treeChildren) return;
void api.lanes
.gitTree(target, path)
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
// Every OPEN reads the folder again (the cached level shows meanwhile):
// files land in it from outside the panel — an agent, the terminal, the
// File Explorer — and a level cached while it was empty stayed empty.
if (opening) void readDir(path);
};
// Files changed outside the panel: read the root and every open folder again.
useEffect(() => {
if (!filesChanged) return;
void readDir("");
for (const p of expandedDirs) void readDir(p);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesChanged]);
const [treeBusy, setTreeBusy] = useState(false);
// "Expand all" is ONE read of the whole tree: a request per folder meant a
@@ -1624,15 +1654,17 @@ export function LaneChangesPanel({
<MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}>
{t("changes.showHistory")}
</MenuItem>
<MenuItem
testId="lane-changes-menu-download"
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
>
{t("changes.download")}
</MenuItem>
{!local && (
<MenuItem
testId="lane-changes-menu-download"
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
>
{t("changes.download")}
</MenuItem>
)}
</>
)}
{treeCtx.menu.item.dir && (
{!local && treeCtx.menu.item.dir && (
<MenuItem
testId="lane-changes-menu-upload"
onSelect={pick(treeCtx.close, () => pickUpload(treeCtx.menu!.item.path))}
@@ -1640,7 +1672,7 @@ export function LaneChangesPanel({
{t("changes.uploadHere")}
</MenuItem>
)}
{treeCtx.menu.item.dir && (
{!local && treeCtx.menu.item.dir && (
<MenuItem
testId="lane-changes-menu-explorer"
onSelect={pick(treeCtx.close, () => setExplorer({ path: treeCtx.menu!.item.path }))}
@@ -2134,9 +2166,9 @@ export function LaneChangesPanel({
aria-label={t("changes.quickOpen")}
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<IconButton label={t("changes.openExplorer")} onClick={() => setExplorer({ path: "" })}>
<FolderOpen className="h-3.5 w-3.5" />
</IconButton>
{/* The File Explorer's button lives in the panel header now (beside
Full screen); this instance serves the tree's "Open in File
Explorer". */}
<FileExplorer
open={!!explorer}
laneId={laneId}
@@ -2265,14 +2297,16 @@ export function LaneChangesPanel({
<ChevronLeft className="h-3 w-3" />
{t("changes.back")}
</button>
) : (
) : notRepo && full ? null : (
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
{/* Files is the whole repo's tree; at full width it is the
sidebar already, so no button. */}
{(full
? (["overview", "review", "history"] as const)
: (["overview", "browse", "review", "history"] as const)
).map((m) => (
{(notRepo
? (["browse"] as const)
: full
? (["overview", "review", "history"] as const)
: (["overview", "browse", "review", "history"] as const)
).map((m: Mode) => (
<button
key={m}
type="button"
@@ -2627,7 +2661,9 @@ export function LaneChangesPanel({
browsePane
) : !data ? (
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
) : !data.available ? (
) : !data.available && mode !== "file" ? (
// A file still opens from Files: its whole-file read is the disk's,
// not git's.
<p className="text-[11px] text-fg-muted">
{t(source ? "changes.unavailableFolder" : "changes.unavailable")}
</p>
@@ -2878,7 +2914,7 @@ export function LaneChangesPanel({
{mdPreview ? t("changes.raw") : t("changes.preview")}
</button>
)}
{range !== "commit" && openFile && (
{!local && range !== "commit" && openFile && (
<button
type="button"
data-testid="lane-file-download"
@@ -2970,7 +3006,9 @@ export function LaneChangesPanel({
collapse={!whole}
revertLabel={t("changes.revertBlock")}
value={draft}
original={baseText ?? draft}
// Outside a repo there is no HEAD: the edits are
// shown against the text as it was opened.
original={(notRepo ? text?.lines.join("\n") : baseText) ?? draft}
lang={langFor(openFile ?? "")}
label={t("changes.editorLabel", { path: openFile })}
onChange={setDraft}
@@ -4,7 +4,8 @@
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
* workspace home's right column and, `compact`, folded at the bottom of the
* session view's Changes panel. Folder suggestions are the repo's untracked
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the
* same form clones related repos beside the workspace (`CloneSourcesForm`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -21,6 +22,7 @@ import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
import { MarkdownPreview } from "../MarkdownPreview";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { PathPickButton } from "../PathPickButton";
import { CloneSourcesForm } from "./CloneSourcesForm";
function fmtBytes(n: number | null): string {
if (!n) return "";
@@ -436,6 +438,16 @@ function ContextBody({
)}
</form>
)}
{folderOpen && (
<CloneSourcesForm
laneId={lane.id}
parent={lane.cwd.replace(/\/+$/, "").replace(/\/[^/]*$/, "") || "/"}
onAdded={(next) => {
setView(next);
onSourcesChange?.(next);
}}
/>
)}
</section>
</aside>
@@ -0,0 +1,61 @@
/**
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
* an extra folder, a host that wants a login opens the login row, and Retry
* re-runs only the failed URLs with that login.
* @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 "../../../i18n";
vi.mock("../../../lib/api", () => ({
api: { lanes: { cloneFolder: vi.fn() } },
}));
import { api, type LaneSourcesView } from "../../../lib/api";
import { CloneSourcesForm } from "../CloneSourcesForm";
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
describe("CloneSourcesForm", () => {
beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset());
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
const user = userEvent.setup();
const onAdded = vi.fn();
vi.mocked(api.lanes.cloneFolder).mockImplementation(async (_id, body) => {
if (body?.url.includes("private") && !body.credentials)
throw Object.assign(new Error("login"), {
code: "ECREDENTIALS",
auth: { protocol: "https", host: "private.test" },
});
return view;
});
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
await user.type(
screen.getByTestId("workspace-clone-input"),
"https://h/a.git\nhttps://private.test/b.git\nhttps://h/a.git"
);
await user.click(screen.getByTestId("workspace-clone-run"));
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(2));
expect(onAdded).toHaveBeenCalledTimes(1);
expect(await screen.findByText(/private\.test needs a login/)).toBeInTheDocument();
// Only what is left stays in the box.
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("https://private.test/b.git");
await user.type(screen.getByLabelText("Username"), "dev");
await user.type(screen.getByLabelText("Password or access token"), "tok");
await user.click(screen.getByRole("button", { name: "Retry the failed ones" }));
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
url: "https://private.test/b.git",
credentials: { username: "dev", password: "tok", remember: true },
});
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
});
});
@@ -24,6 +24,12 @@ import type { LaneChanges } from "../../../lib/types";
// The themed confirm needs its host (App); answer it here.
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
// Local mode unless a test says otherwise.
const perms = vi.hoisted(() => ({ local: true }));
vi.mock("../../AuthGate", async (importOriginal) => {
const m = await importOriginal<typeof import("../../AuthGate")>();
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) };
});
vi.mock("../DiffEditor", () => ({
default: (p: {
value: string;
@@ -146,6 +152,7 @@ function discardOf(path: string) {
}
beforeEach(() => {
perms.local = true;
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
vi.mocked(api.lanes.changes).mockReset();
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
@@ -310,16 +317,29 @@ describe("LaneChangesPanel", () => {
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" }));
});
it("says so, quietly, when the lane's directory is not a git repo", async () => {
it("says so, quietly, beside the tree at full width, when the folder is not a repo", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue({
available: false,
range: "work",
files: [],
});
render(<LaneChangesPanel laneId={7} full />);
expect(
await screen.findByText("This workspace's directory is not a readable git repository.")
).toBeInTheDocument();
});
it("opens on Files, its only tab, when the workspace folder is not a repo", async () => {
vi.mocked(api.lanes.changes).mockResolvedValue({
available: false,
range: "work",
files: [],
});
render(<LaneChangesPanel laneId={7} />);
expect(
await screen.findByText("This workspace's directory is not a readable git repository.")
).toBeInTheDocument();
expect(await screen.findByTestId("lane-changes-browse")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Files" })).toHaveAttribute("aria-pressed", "true");
for (const name of ["Changes", "Review", "History"])
expect(screen.queryByRole("button", { name })).not.toBeInTheDocument();
});
it("adds an unversioned file to VCS, for that path alone, and ticks it", async () => {
@@ -548,7 +568,38 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
});
it("reads a folder again on every open, so files added since show (not a stale empty level)", async () => {
let filled = false;
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
available: true,
path,
entries:
path === "src"
? filled
? [{ name: "app.ts", path: "src/app.ts", dir: false }]
: []
: [{ name: "src", path: "src", dir: true }],
truncated: false,
}));
const user = userEvent.setup();
const { rerender } = render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.click(await within(sidebar).findByText("src"));
await waitFor(() => expect(api.lanes.gitTree).toHaveBeenCalledWith(7, "src"));
expect(within(sidebar).queryByText("app.ts")).toBeNull();
// Source arrives from outside the panel (the File Explorer, a terminal).
filled = true;
await user.click(within(sidebar).getByText("src")); // close
await user.click(within(sidebar).getByText("src")); // open again → read again
expect(await within(sidebar).findByText("app.ts")).toBeInTheDocument();
// And the header's File Explorer reports a change: open folders re-read.
filled = false;
rerender(<LaneChangesPanel laneId={7} full filesChanged={1} />);
await waitFor(() => expect(within(sidebar).queryByText("app.ts")).toBeNull());
});
it("uploads files into a folder from the tree menu, and downloads a file (issue #78)", async () => {
perms.local = false;
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
available: true,
path,
@@ -586,6 +637,25 @@ describe("LaneChangesPanel", () => {
picked.mockRestore();
});
it("offers no upload, download or File Explorer in local mode", async () => {
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
available: true,
path,
entries:
path === "out"
? [{ name: "report.md", path: "out/report.md", dir: false }]
: [{ name: "out", path: "out", dir: true }],
truncated: false,
}));
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} full />);
const sidebar = await screen.findByTestId("lane-changes-sidebar");
await user.pointer({ keys: "[MouseRight]", target: await within(sidebar).findByText("out") });
expect(screen.getByTestId("lane-changes-tree-menu")).toBeInTheDocument();
expect(screen.queryByTestId("lane-changes-menu-upload")).toBeNull();
expect(screen.queryByTestId("lane-changes-menu-explorer")).toBeNull();
});
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
vi.mocked(api.lanes.gitTree).mockResolvedValue({
available: true,
@@ -1728,6 +1798,24 @@ describe("LaneChangesPanel", () => {
expect(screen.getByTestId("lane-file-view")).toBeInTheDocument();
});
it("opens a file from Files when the workspace folder is not a repo", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.changes).mockResolvedValue({
available: false,
range: "work",
files: [],
});
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-browse");
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
await user.click(await screen.findByText("src/main.ts"));
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
expect(
screen.queryByText("This workspace's directory is not a readable git repository.")
).toBeNull();
});
it("keeps the overview rows mounted across re-renders (typing in the commit box)", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -9,7 +9,8 @@
* `~/.claude` (a memory file, a plugin's skill) through `GET /api/cc-config/file`,
* an attached image or text file through `GET /api/run/drop/:name`, an image
* the session read anywhere else through `GET /api/sessions/:id/image`, and a
* text file it read or wrote anywhere else through `GET /api/sessions/:id/file`.
* text file it read, wrote or named (a generated report) anywhere else through
* `GET /api/sessions/:id/file`.
* Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout.
* `SkillListModal` is the same dialog over the session's skills, each one's
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
+4 -1
View File
@@ -65,6 +65,7 @@ import { addAttachments, dragAttachments } from "../../lib/promptAttach";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
import { resolveIn } from "../../lib/resolvePath";
import { Select } from "../Select";
import { Divider, showTerminal } from "./PaneGrid";
import {
@@ -471,7 +472,9 @@ export const PaneChat = memo(function PaneChat({
// (ConversationView is memoized).
const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]);
const openFile = useCallback(
(path: string) => {
(named: string) => {
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
const path = resolveIn(cwd, named);
// The workspace's Changes panel when the file is in its folder;
// else this view's own preview.
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
+16 -2
View File
@@ -69,6 +69,7 @@ import {
addAttachments,
droppedAttachments,
droppedSkill,
droppedText,
SKILL_MIME,
rememberUploads,
} from "../../lib/promptAttach";
@@ -412,7 +413,8 @@ export function PromptEditor({
// else a `bare` editor's parent, the box every caller draws around it and
// its toolbar — not only on the textarea: a panel row (`ATTACH_MIME`)
// becomes an `@path` chip, a skill (`SKILL_MIME`) the prompt's leading
// `/command`, a file is uploaded.
// `/command`, a file is uploaded, a local `file://` list an `@path` chip
// and any other text is inserted.
const rootRef = useRef<HTMLDivElement | null>(null);
const dropRef = useRef<(e: DragEvent) => void>(() => {});
dropRef.current = (e) => {
@@ -425,6 +427,17 @@ export function PromptEditor({
e.preventDefault();
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
else {
// Only text (a file manager's or an editor's `file://` list, a
// selection, a link): what the terminal beneath would paste.
// On the textarea itself the browser inserts text at the drop point.
const dropped = droppedText(e.dataTransfer);
if (!dropped || ("text" in dropped && e.target === taRef.current)) return;
e.preventDefault();
if ("attachments" in dropped)
onImagesChange((prev) => addAttachments(prev, dropped.attachments));
else insert(dropped.text);
}
};
useEffect(() => {
const zone =
@@ -433,7 +446,8 @@ export function PromptEditor({
if (!zone) return;
const over = (e: DragEvent) => {
const types = e.dataTransfer?.types ?? [];
if (![ATTACH_MIME, SKILL_MIME, "Files"].some((m) => types.includes(m))) return;
const accepts = [ATTACH_MIME, SKILL_MIME, "Files", "text/uri-list", "text/plain"];
if (!accepts.some((m) => types.includes(m))) return;
e.preventDefault();
e.dataTransfer!.dropEffect = "copy";
};
@@ -406,6 +406,16 @@ export const TerminalView = memo(function TerminalView({
}, 0);
return false;
}
// Ctrl+Z: a terminal sends ^Z (SIGTSTP), which SUSPENDS claude or
// codex — the pane drops to its shell and the session looks closed,
// with nothing on screen saying `fg` brings it back. In a browser
// Ctrl+Z means undo, so it sends Ctrl+_ — undo in Claude Code's prompt
// and in bash/readline — and never suspends the program.
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "z") {
ev.preventDefault();
term.input("\x1f", true);
return false;
}
if (ev.key === "Tab" && tabMovesRef.current) return false;
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
return true;
@@ -434,6 +434,45 @@ describe("PaneChat — the unsent prompt", () => {
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
});
// A drag from outside the dashboard (a file manager, VS Code, a selection)
// carries only text: the terminal beneath pastes it, so the chat takes it too.
const textDrop = (data: Record<string, string>) => {
const drop = new Event("drop", { bubbles: true, cancelable: true });
Object.defineProperty(drop, "dataTransfer", {
value: { types: Object.keys(data), getData: (t: string) => data[t] ?? "", files: [] },
});
act(() => {
screen.getByTestId("pane-chat").dispatchEvent(drop);
});
return drop;
};
it("attaches the local files of a dropped file:// list", async () => {
renderChat();
const drop = textDrop({
"text/uri-list": "file:///home/u/My%20Docs/spec.md\r\nfile:///home/u/b.ts",
"text/plain": "/home/u/My Docs/spec.md",
});
expect(drop.defaultPrevented).toBe(true);
expect(await screen.findByText("spec.md")).toBeInTheDocument();
expect(screen.getByText("b.ts")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("Write a message…"), {
target: { value: "read" },
});
fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith(
"agenthub-lane-1",
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts'
);
});
it("inserts dropped plain text into the prompt", () => {
renderChat();
const drop = textDrop({ "text/plain": "src/lib/x.ts" });
expect(drop.defaultPrevented).toBe(true);
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("src/lib/x.ts");
});
// Issue #73: before the session starts the CLI may be asking in the terminal
// (trust this folder? — "No, exit" preselected); an Enter sent from here
// would answer it, so a send asks first.
@@ -520,9 +520,15 @@ describe("TerminalView", () => {
"application/x-agenthub-attach": '{"path":"@src/a.ts","name":"a.ts"}',
"text/plain": "src/a.ts",
};
const dataTransfer = { types: Object.keys(data), files: [], getData: (k: string) => data[k] ?? "" };
const dataTransfer = {
types: Object.keys(data),
files: [],
getData: (k: string) => data[k] ?? "",
};
fireEvent.dragEnter(screen.getByTestId("terminal-view"), { dataTransfer });
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop to paste into the terminal");
expect(screen.getByTestId("terminal-drop")).toHaveTextContent(
"Drop to paste into the terminal"
);
fireEvent.drop(screen.getByTestId("terminal-view"), { dataTransfer });
expect(pasteMock).toHaveBeenCalledWith("src/a.ts ");
expect(screen.queryByTestId("terminal-drop")).toBeNull();
@@ -582,6 +588,24 @@ describe("TerminalView", () => {
vi.useRealTimers();
});
// ^Z suspended claude: the pane fell to its shell and the session looked
// closed. Ctrl+Z is undo in a browser, so it sends Ctrl+_ instead.
it("turns Ctrl+Z into undo (Ctrl+_) and never sends ^Z, which would suspend the session", () => {
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
const ev = new KeyboardEvent("keydown", { key: "z", ctrlKey: true, cancelable: true });
expect(handler(ev)).toBe(false);
expect(ev.defaultPrevented).toBe(true);
expect(inputMock).toHaveBeenCalledWith("\x1f", true);
expect(inputMock).not.toHaveBeenCalledWith("\x1a", expect.anything());
// Other chords with Z are left to xterm (Ctrl+Shift+Z, plain z).
expect(handler(new KeyboardEvent("keydown", { key: "Z", ctrlKey: true, shiftKey: true }))).toBe(
true
);
expect(handler(new KeyboardEvent("keydown", { key: "z" }))).toBe(true);
});
// Safari + macOS's own Vietnamese Telex: the first letter is an insertText,
// every later key REPLACES the word — the event order captured on a Mac.
it("forwards Safari Telex's word replacements as backspaces plus the new tail", () => {
@@ -64,6 +64,8 @@ export function GitIdentitySettings() {
}
};
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
const [keyType, setKeyType] = useState<"ed25519" | "rsa">("ed25519");
const generate = async () => {
if (
id?.ssh &&
@@ -73,7 +75,10 @@ export function GitIdentitySettings() {
}))
)
return;
await run(() => api.gitIdentity.generateKey(Boolean(id?.ssh)), t("gitIdentity.keyMade"));
await run(
() => api.gitIdentity.generateKey(Boolean(id?.ssh), keyType),
t("gitIdentity.keyMade")
);
};
const removeKey = async () => {
@@ -203,6 +208,18 @@ export function GitIdentitySettings() {
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.sshTitle")}
</h3>
{id.writable && (
<select
aria-label={t("gitIdentity.keyType")}
value={keyType}
disabled={busy}
onChange={(e) => setKeyType(e.target.value === "rsa" ? "rsa" : "ed25519")}
className="select !py-1 !text-xs"
>
<option value="ed25519">{t("gitIdentity.keyEd25519")}</option>
<option value="rsa">{t("gitIdentity.keyRsa")}</option>
</select>
)}
{id.writable && (
<button type="button" className={BTN} disabled={busy} onClick={() => void generate()}>
{id.ssh ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
@@ -222,6 +239,7 @@ export function GitIdentitySettings() {
/>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
{id.ssh.type === "rsa" ? "RSA 4096 · " : id.ssh.type ? "ED25519 · " : ""}
{id.ssh.fingerprint}
</span>
<button
@@ -50,7 +50,7 @@ describe("GitIdentitySettings", () => {
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByRole("button", { name: "Generate SSH key" }));
expect(confirmDialog).not.toHaveBeenCalled();
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false);
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519");
expect(await screen.findByLabelText("Public key")).toHaveValue(KEY.publicKey);
});
@@ -62,7 +62,7 @@ describe("GitIdentitySettings", () => {
expect(copyText).toHaveBeenCalledWith(KEY.publicKey);
await userEvent.click(screen.getByRole("button", { name: "Replace key" }));
expect(confirmDialog).toHaveBeenCalled();
await waitFor(() => expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(true));
await waitFor(() => expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(true, "ed25519"));
});
it("lets a member save an https login of their own", async () => {
@@ -85,6 +85,18 @@ describe("GitIdentitySettings", () => {
expect(await screen.findByText("github.com")).toBeInTheDocument();
});
it("makes an RSA key when that type is picked (AWS CodeCommit takes only RSA)", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
view({ ssh: { ...KEY, type: "rsa", publicKey: "ssh-rsa AAAAB3 an@agenthub" } })
);
render(<GitIdentitySettings />);
await userEvent.selectOptions(await screen.findByLabelText("Key type"), "rsa");
await userEvent.click(screen.getByRole("button", { name: "Generate SSH key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
expect(await screen.findByText(/^RSA 4096 · /)).toBeInTheDocument();
});
it("warns while the commit author is unset and saves it", async () => {
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ author: { name: null, email: null } }));
vi.mocked(api.gitIdentity.saveAuthor).mockResolvedValue(view());
+47 -1
View File
@@ -14,6 +14,7 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react";
import { confirmDialog } from "../../lib/confirm";
import { api, type AuthUser, type ServerUserUsage, type UserPatch } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { fmtCost, formatDateTime } from "../../lib/format";
@@ -131,6 +132,38 @@ export function UserEditDialog({
}
};
/** Delete the user for good — asked first, with what goes, in red. */
const remove = async () => {
if (self || busy) return;
if (!current) {
setError(t("delete.needsPassword"));
return;
}
const name = user.displayName || user.username;
const ok = await confirmDialog({
title: t("delete.title", { name }),
message: t("delete.body", { name, count: owned?.length ?? 0, user: user.username }),
confirmLabel: t("delete.confirm"),
destructive: true,
});
if (!ok) return;
setBusy(true);
setError(null);
try {
await api.users.remove(user.id, current);
onDone(t("done.deleted", { name }));
} catch (e) {
const err = e as Error & { code?: string };
setError(
err.code && t(`errors.${err.code}`, { defaultValue: "" })
? t(`errors.${err.code}`)
: err.message
);
} finally {
setBusy(false);
}
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (isSubmitEnter(e)) {
e.preventDefault();
@@ -459,8 +492,21 @@ export function UserEditDialog({
</p>
)}
<div className="flex items-center justify-end gap-2">
{!self && (
<button
type="button"
onClick={() => void remove()}
disabled={busy}
data-testid="user-delete"
className="mr-auto rounded-md border border-status-danger/50 px-3 py-1.5 text-sm text-status-danger hover:bg-status-danger/10 disabled:opacity-50"
>
{t("delete.button")}
</button>
)}
{!changed && (
<span className="mr-auto text-xs text-fg-muted">{t("edit.noChanges")}</span>
<span className={`text-xs text-fg-muted ${self ? "mr-auto" : ""}`}>
{t("edit.noChanges")}
</span>
)}
<button type="button" onClick={onClose} className="btn-ghost">
{t("common:cancel")}
@@ -13,7 +13,9 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ServerUserUsage } from "../../../lib/api";
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn() }));
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn(), remove: vi.fn() }));
const confirmDialog = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
vi.mock("../../../lib/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -122,6 +124,45 @@ describe("UserEditDialog", () => {
expect(save().hasAttribute("disabled")).toBe(false);
});
it("deletes a user after the admin's password and a red confirm that says what goes", async () => {
users.remove.mockReset().mockResolvedValue({ user: mia });
confirmDialog.mockReset().mockResolvedValue(true);
const ue = userEvent.setup();
const { onDone } = open();
await screen.findByText("api");
// No password yet: asked for it, nothing sent.
await ue.click(screen.getByRole("button", { name: "Delete user" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Enter your own password");
expect(users.remove).not.toHaveBeenCalled();
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
await ue.click(screen.getByRole("button", { name: "Delete user" }));
expect(confirmDialog).toHaveBeenCalledWith(
expect.objectContaining({
destructive: true,
message: expect.stringContaining("/srv/agenthub/users/mia"),
})
);
await waitFor(() => expect(users.remove).toHaveBeenCalledWith(2, "boss-pass"));
expect(onDone).toHaveBeenCalled();
});
it("sends nothing when the confirm is cancelled, and offers no delete for yourself", async () => {
users.remove.mockReset();
confirmDialog.mockReset().mockResolvedValue(false);
const ue = userEvent.setup();
open();
await screen.findByText("api");
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
await ue.click(screen.getByRole("button", { name: "Delete user" }));
expect(users.remove).not.toHaveBeenCalled();
});
it("has no Delete button on your own account", async () => {
open(mia, true);
await screen.findByText("api");
expect(screen.queryByRole("button", { name: "Delete user" })).toBeNull();
});
it("generates a 16-character password", async () => {
expect(generatePassword()).toHaveLength(16);
const ue = userEvent.setup();
+1
View File
@@ -116,6 +116,7 @@ export const i18nReady = i18n
"lanes",
"users",
"onboarding",
"artifacts",
],
defaultNS: "common",
interpolation: { escapeValue: false },
+75
View File
@@ -0,0 +1,75 @@
{
"title": "Artifacts",
"subtitle": "What your agents produced — claude.ai pages, files, skills and plugins — kept in one place.",
"subtitleServer": "What your agents produced — claude.ai pages, files, skills and plugins. Share one and it appears in the Hub for everyone.",
"tab.mine": "My library",
"tab.hub": "Hub",
"kind.all": "All",
"kind.page": "Pages",
"kind.file": "Files",
"kind.skill": "Skills",
"kind.plugin": "Plugins",
"kindOne.page": "Page",
"kindOne.file": "File",
"kindOne.skill": "Skill",
"kindOne.plugin": "Plugin",
"search": "Search artifacts…",
"add": "Add",
"add.page": "claude.ai link",
"add.file": "File from a workspace",
"add.skill": "Skill",
"add.plugin": "Plugin",
"empty.mine": "Nothing here yet. Pages your sessions publish appear on their own; add files, skills and plugins with Add.",
"empty.hub": "Nobody has shared anything yet.",
"loading": "Loading…",
"by": "by {{name}}",
"shared": "Shared",
"share": "Share",
"unshare": "Unshare",
"shareDone": "Shared to the Hub",
"unshareDone": "Removed from the Hub",
"open": "Open",
"preview": "Preview",
"download": "Download",
"install": "Install",
"installs": "{{count}} installs",
"installed": "Installed to {{path}}",
"installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it",
"rename": "Rename",
"delete": "Delete",
"deleteTitle": "Delete “{{title}}”?",
"deleteBody": "It is removed from your library and the Hub. Anything already installed from it stays installed.",
"deleteConfirm": "Delete",
"installTitle": "Install “{{title}}”?",
"installSkillBody": "These files are written to your own ~/.claude/skills/{{name}}/. A skill can carry scripts your agent will run — only install what you trust:",
"installPluginBody": "Runs, as your account: claude plugin marketplace add {{market}} and claude plugin install {{name}} --scope user. Only install what you trust.",
"installConfirm": "Install",
"replaceTitle": "Replace your skill “{{name}}”?",
"replaceBody": "You already have a skill with that name. Yours is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
"replaceConfirm": "Replace",
"installsOff": "Installs are off",
"installsSwitch": "Allow installing from the Hub",
"installsSwitchHint": "Off: nobody can install a shared skill or plugin into their account.",
"form.url": "claude.ai link",
"form.title": "Title (optional)",
"form.description": "Description (optional)",
"form.workspace": "Workspace",
"form.pickWorkspace": "Pick a workspace…",
"form.noCandidates": "No documents written by this workspace's sessions yet.",
"form.pinned": "Pinned",
"form.pin": "Pin",
"form.missing": "gone",
"form.skillFrom": "Skill",
"form.fromUser": "mine",
"form.fromWorkspace": "workspace",
"form.noSkills": "No skills found in your ~/.claude/skills or this workspace's .claude/skills.",
"form.pluginKey": "Plugin (name@marketplace)",
"form.market": "Marketplace (owner/repo or git URL)",
"form.save": "Add",
"form.cancel": "Cancel",
"added": "Added",
"files": "{{count}} files",
"close": "Close",
"fromSession": "From a session",
"form.saveEdit": "Save"
}
+8 -1
View File
@@ -417,6 +417,12 @@
"home.addFolder": "Add folder",
"home.folderPath": "Absolute folder path",
"home.add": "Add",
"home.cloneFolders": "Or clone repositories",
"home.cloneHint": "One URL per line — each is cloned into {{path}} with your own git login (Settings → Git) and added here.",
"home.clonePlaceholder": "https://host/owner/repo.git\ngit@host:owner/other.git",
"home.cloneRun": "Clone",
"home.cloneRetry": "Retry the failed ones",
"home.cloneDone": "Added",
"home.suggested": "Untracked worktrees",
"home.remove": "Remove",
"home.missing": "missing",
@@ -606,5 +612,6 @@
"fx.userHomes": "Users' homes",
"fx.readOnlyHome": "Another user's home — view and download only",
"fx.readOnly": "Read-only",
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it."
"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."
}
+1
View File
@@ -1,5 +1,6 @@
{
"activityFeed": "Activity Feed",
"artifacts": "Artifacts",
"agents": "Agents",
"analytics": "Analytics",
"avg": "avg",
+6 -4
View File
@@ -57,8 +57,7 @@
"attention": "Needs you",
"attentionError": "errored",
"attentionWaiting": "waiting on you",
"attentionMore": "+{{count}} more in Sessions",
"chat": "Chat with {{name}}"
"attentionMore": "+{{count}} more in Sessions"
},
"bubbleDismiss": "Dismiss",
"open": "Open {{name}} companion",
@@ -911,11 +910,14 @@
"removeKeyConfirm": "Delete this SSH key? Clones that use it can no longer fetch or push.",
"publicKey": "Public key",
"copy": "Copy public key",
"addKeyHint": "Add this public key to your account on the git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys). The private key never leaves the server.",
"addKeyHint": "Add this public key to your account on the git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys; AWS: IAM → Security credentials → SSH public keys — RSA only). Paste the whole line as one line. The private key never leaves the server.",
"noKey": "No SSH key yet. Without one, an SSH clone uses the account's default ~/.ssh keys.",
"httpsTitle": "HTTPS logins",
"noLogins": "No HTTPS login saved. A clone asks for one when a host needs it.",
"removeLoginConfirm": "Delete the login for {{host}}? Clones from that host will ask for a login again."
"removeLoginConfirm": "Delete the login for {{host}}? Clones from that host will ask for a login again.",
"keyType": "Key type",
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
"keyRsa": "RSA 4096 (AWS CodeCommit, older servers)"
},
"gitCreds": {
"title": "Git credentials",
+11 -2
View File
@@ -57,7 +57,8 @@
"enabled": "{{name}} enabled",
"password": "{{name}}'s password reset",
"workspace": "Workspace given to {{name}}",
"updated": "{{name}} updated"
"updated": "{{name}} updated",
"deleted": "{{name}} was deleted."
},
"errors": {
"EREAUTH": "Your password is wrong.",
@@ -72,7 +73,8 @@
"ENOTOWNED": "That workspace is not this user's any more.",
"EOWNERFOLDER": "That workspace's folder belongs to another user's account.",
"EBADROLE": "Unknown role.",
"ELOCALMODE": "Accounts are managed in server mode only."
"ELOCALMODE": "Accounts are managed in server mode only.",
"ELASTADMIN": "The last admin cannot be deleted."
},
"readOnly": "Analysts can view accounts but not change them.",
"auditTitle": "Audit log",
@@ -187,5 +189,12 @@
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
},
"refreshFailed": "refresh failed"
},
"delete": {
"button": "Delete user",
"title": "Delete {{name}}?",
"body": "This deletes {{name}} for good: their login, {{count}} workspace(s), and their Linux account with the whole home folder (/srv/agenthub/users/{{user}}) — every file and all source code in it. It cannot be undone. Session history and the audit log stay.",
"confirm": "Delete for good",
"needsPassword": "Enter your own password below first."
}
}
+75
View File
@@ -0,0 +1,75 @@
{
"title": "Artifacts",
"subtitle": "Những gì agent đã tạo ra — trang claude.ai, file, skill và plugin — gom một chỗ.",
"subtitleServer": "Những gì agent đã tạo ra — trang claude.ai, file, skill và plugin. Chia sẻ một mục là nó hiện trong Hub cho mọi người.",
"tab.mine": "Thư viện của tôi",
"tab.hub": "Hub",
"kind.all": "Tất cả",
"kind.page": "Trang",
"kind.file": "File",
"kind.skill": "Skill",
"kind.plugin": "Plugin",
"kindOne.page": "Trang",
"kindOne.file": "File",
"kindOne.skill": "Skill",
"kindOne.plugin": "Plugin",
"search": "Tìm artifact…",
"add": "Thêm",
"add.page": "Link claude.ai",
"add.file": "File từ workspace",
"add.skill": "Skill",
"add.plugin": "Plugin",
"empty.mine": "Chưa có gì. Trang mà phiên publish sẽ tự xuất hiện; thêm file, skill và plugin bằng nút Thêm.",
"empty.hub": "Chưa ai chia sẻ gì.",
"loading": "Đang tải…",
"by": "của {{name}}",
"shared": "Đã chia sẻ",
"share": "Chia sẻ",
"unshare": "Bỏ chia sẻ",
"shareDone": "Đã chia sẻ lên Hub",
"unshareDone": "Đã gỡ khỏi Hub",
"open": "Mở",
"preview": "Xem",
"download": "Tải về",
"install": "Cài",
"installs": "{{count}} lượt cài",
"installed": "Đã cài vào {{path}}",
"installedPlugin": "Đã cài {{name}} — mở phiên mới (hoặc /reload-plugins) để dùng",
"rename": "Đổi tên",
"delete": "Xoá",
"deleteTitle": "Xoá “{{title}}”?",
"deleteBody": "Mục này bị gỡ khỏi thư viện của bạn và Hub. Những bản người khác đã cài vẫn giữ nguyên.",
"deleteConfirm": "Xoá",
"installTitle": "Cài “{{title}}”?",
"installSkillBody": "Các file này được ghi vào ~/.claude/skills/{{name}}/ của chính bạn. Skill có thể kèm script mà agent sẽ chạy — chỉ cài thứ bạn tin:",
"installPluginBody": "Chạy dưới tài khoản của bạn: claude plugin marketplace add {{market}} và claude plugin install {{name}} --scope user. Chỉ cài thứ bạn tin.",
"installConfirm": "Cài",
"replaceTitle": "Thay skill “{{name}}” của bạn?",
"replaceBody": "Bạn đã có skill trùng tên. Bản cũ được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá.",
"replaceConfirm": "Thay",
"installsOff": "Đang tắt cài đặt",
"installsSwitch": "Cho phép cài từ Hub",
"installsSwitchHint": "Tắt: không ai cài được skill hay plugin chia sẻ vào tài khoản của mình.",
"form.url": "Link claude.ai",
"form.title": "Tiêu đề (tuỳ chọn)",
"form.description": "Mô tả (tuỳ chọn)",
"form.workspace": "Workspace",
"form.pickWorkspace": "Chọn workspace…",
"form.noCandidates": "Các phiên của workspace này chưa ghi tài liệu nào.",
"form.pinned": "Đã ghim",
"form.pin": "Ghim",
"form.missing": "đã mất",
"form.skillFrom": "Skill",
"form.fromUser": "của tôi",
"form.fromWorkspace": "workspace",
"form.noSkills": "Không thấy skill nào trong ~/.claude/skills của bạn hay .claude/skills của workspace này.",
"form.pluginKey": "Plugin (tên@marketplace)",
"form.market": "Marketplace (owner/repo hoặc git URL)",
"form.save": "Thêm",
"form.cancel": "Huỷ",
"added": "Đã thêm",
"files": "{{count}} file",
"close": "Đóng",
"fromSession": "Từ một phiên",
"form.saveEdit": "Lưu"
}
+8 -1
View File
@@ -417,6 +417,12 @@
"home.addFolder": "Thêm thư mục",
"home.folderPath": "Đường dẫn tuyệt đối của thư mục",
"home.add": "Thêm",
"home.cloneFolders": "Hoặc clone repo",
"home.cloneHint": "Mỗi dòng một URL — từng repo được clone vào {{path}} bằng login git của bạn (Cài đặt → Git) rồi thêm vào đây.",
"home.clonePlaceholder": "https://host/owner/repo.git\ngit@host:owner/other.git",
"home.cloneRun": "Clone",
"home.cloneRetry": "Thử lại các repo lỗi",
"home.cloneDone": "Đã thêm",
"home.suggested": "Worktree chưa theo dõi",
"home.remove": "Gỡ",
"home.missing": "không còn",
@@ -606,5 +612,6 @@
"fx.userHomes": "Home của các user",
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về",
"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.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."
}
+1
View File
@@ -1,5 +1,6 @@
{
"activityFeed": "Luồng hoạt động",
"artifacts": "Artifacts",
"agents": "Agent",
"analytics": "Phân tích",
"avg": "TB",
+6 -4
View File
@@ -57,8 +57,7 @@
"attention": "Cần bạn xem",
"attentionError": "bị lỗi",
"attentionWaiting": "đang chờ bạn",
"attentionMore": "+{{count}} phiên khác trong Phiên",
"chat": "Trò chuyện với {{name}}"
"attentionMore": "+{{count}} phiên khác trong Phiên"
},
"bubbleDismiss": "Đóng",
"open": "Mở trợ lý {{name}}",
@@ -911,11 +910,14 @@
"removeKeyConfirm": "Xoá SSH key này? Các bản clone dùng nó sẽ không fetch/push được nữa.",
"publicKey": "Public key",
"copy": "Copy public key",
"addKeyHint": "Thêm public key này vào tài khoản trên git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys). Private key không bao giờ rời khỏi server.",
"addKeyHint": "Thêm public key này vào tài khoản trên git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys; AWS: IAM → Security credentials → SSH public keys — chỉ nhận RSA). Dán nguyên dòng, trên một dòng. Private key không bao giờ rời khỏi server.",
"noKey": "Chưa có SSH key. Khi chưa có, clone qua SSH dùng key mặc định trong ~/.ssh của tài khoản.",
"httpsTitle": "Đăng nhập HTTPS",
"noLogins": "Chưa lưu đăng nhập HTTPS nào. Khi clone, host nào cần sẽ hỏi.",
"removeLoginConfirm": "Xoá đăng nhập của {{host}}? Clone từ host đó sẽ hỏi đăng nhập lại."
"removeLoginConfirm": "Xoá đăng nhập của {{host}}? Clone từ host đó sẽ hỏi đăng nhập lại.",
"keyType": "Loại key",
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
"keyRsa": "RSA 4096 (AWS CodeCommit, server cũ)"
},
"gitCreds": {
"title": "Git credentials",
+11 -2
View File
@@ -57,7 +57,8 @@
"enabled": "Đã mở khoá {{name}}",
"password": "Đã đặt lại mật khẩu cho {{name}}",
"workspace": "Đã giao workspace cho {{name}}",
"updated": "Đã cập nhật {{name}}"
"updated": "Đã cập nhật {{name}}",
"deleted": "Đã xoá {{name}}."
},
"errors": {
"EREAUTH": "Mật khẩu của bạn không đúng.",
@@ -72,7 +73,8 @@
"ENOTOWNED": "Workspace đó không còn thuộc người này.",
"EOWNERFOLDER": "Thư mục của workspace đó thuộc tài khoản của người dùng khác.",
"EBADROLE": "Vai trò không hợp lệ.",
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server."
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server.",
"ELASTADMIN": "Không thể xoá admin cuối cùng."
},
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
"auditTitle": "Nhật ký audit",
@@ -187,5 +189,12 @@
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
},
"refreshFailed": "làm mới lỗi"
},
"delete": {
"button": "Xoá user",
"title": "Xoá {{name}}?",
"body": "Thao tác này xoá vĩnh viễn {{name}}: tài khoản đăng nhập, {{count}} workspace, và tài khoản Linux cùng toàn bộ thư mục home (/srv/agenthub/users/{{user}}) — mọi file và source code trong đó. Không thể khôi phục. Lịch sử session và audit log vẫn được giữ.",
"confirm": "Xoá vĩnh viễn",
"needsPassword": "Hãy nhập mật khẩu của bạn ở ô bên dưới trước."
}
}
+130 -6
View File
@@ -885,6 +885,16 @@ export const api = {
method: "PATCH",
body: JSON.stringify({ ...body, currentPassword }),
}),
/**
* DELETE /api/users/:id — delete the user for good: logins, their
* workspaces' records and (one Linux user per account) their Linux
* account and whole home. Never yourself or the last admin.
*/
remove: (id: number, currentPassword: string) =>
request<{ user: AuthUser }>(`/users/${id}`, {
method: "DELETE",
body: JSON.stringify({ currentPassword }),
}),
/** POST /api/users/:id/password — a new password; that user's logins end. */
setPassword: (id: number, password: string, currentPassword: string) =>
request<{ user: AuthUser }>(`/users/${id}/password`, {
@@ -2545,8 +2555,7 @@ export const api = {
method: "POST",
body: JSON.stringify({ paths }),
}),
trash: (t: FsPlace) =>
request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
trash: (t: FsPlace) => request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
restore: (t: FsPlace, id: string) =>
request<{ path: string; laneId: number | null }>(fsUrl(t, "/trash/restore"), {
method: "POST",
@@ -2613,11 +2622,14 @@ export const api = {
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
gitIdentity: {
get: () => request<GitIdentity>("/me/git"),
/** Make the SSH key; `replace` swaps an existing one for a new pair. */
generateKey: (replace = false) =>
/**
* Make the SSH key; `replace` swaps an existing one for a new pair.
* `type`: ed25519 (default) or rsa (RSA 4096 — AWS CodeCommit takes only RSA).
*/
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519") =>
request<GitIdentity>("/me/git/ssh-key", {
method: "POST",
body: JSON.stringify({ replace }),
body: JSON.stringify({ replace, type }),
}),
removeKey: () => request<GitIdentity>("/me/git/ssh-key", { method: "DELETE" }),
saveHttps: (body: { host: string; username: string; password: string }) =>
@@ -2646,6 +2658,58 @@ export const api = {
),
remove: (id: number) => request<{ ok: true }>(`/snapshots/${id}`, { method: "DELETE" }),
},
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
artifacts: {
list: (params: { scope?: "mine" | "hub" | "all"; kind?: ArtifactKind; q?: string } = {}) => {
const qs = new URLSearchParams();
if (params.scope) qs.set("scope", params.scope);
if (params.kind) qs.set("kind", params.kind);
if (params.q) qs.set("q", params.q);
const s = qs.toString();
return request<{ items: Artifact[] }>(`/artifacts${s ? `?${s}` : ""}`);
},
settings: () => request<ArtifactSettings>("/artifacts/settings"),
setInstallsEnabled: (installsEnabled: boolean) =>
request<{ installsEnabled: boolean }>("/artifacts/settings", {
method: "PUT",
body: JSON.stringify({ installsEnabled }),
}),
/** Documents a workspace's sessions wrote, to pin. */
candidates: (laneId: number) =>
request<{ items: ArtifactCandidate[] }>(`/artifacts/candidates?laneId=${laneId}`),
/** Skill folders the caller may share (their own, and the workspace's). */
skills: (laneId?: number) =>
request<{ items: { name: string; from: "user" | "workspace" }[] }>(
`/artifacts/skills${laneId ? `?laneId=${laneId}` : ""}`
),
addPage: (body: { url: string; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/page", { method: "POST", body: JSON.stringify(body) }),
pinFile: (body: { laneId: number; path: string; title?: string; sessionId?: string }) =>
request<{ item: Artifact }>("/artifacts/file", { method: "POST", body: JSON.stringify(body) }),
addSkill: (body: { name: string; laneId?: number; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/skill", { method: "POST", body: JSON.stringify(body) }),
addPlugin: (body: { key: string; market: string; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/plugin", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: number, body: { title?: string; description?: string; shared?: boolean }) =>
request<{ item: Artifact }>(`/artifacts/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
remove: (id: number) => request<{ ok: true }>(`/artifacts/${id}`, { method: "DELETE" }),
install: (id: number, replace = false) =>
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
method: "POST",
body: JSON.stringify({ replace }),
}),
/** One of the snapshot's own files, served sandboxed. */
rawUrl: (id: number, path?: string, download = false) => {
const qs = new URLSearchParams();
if (path) qs.set("path", path);
if (download) qs.set("download", "1");
const s = qs.toString();
return `${BASE}/artifacts/${id}/raw${s ? `?${s}` : ""}`;
},
},
/** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */
pets: {
list: () => request<PetList>("/pets"),
@@ -3640,6 +3704,21 @@ export const api = {
method: "PUT",
body: JSON.stringify({ instructions }),
}),
/** POST /api/lanes/:id/sources/clone — clone a related repo BESIDE the
* workspace folder and add it as an extra folder (409 ECREDENTIALS /
* ESSHAUTH like {@link lanes.clone}). */
cloneFolder: (
id: number,
body: {
url: string;
name?: string;
credentials?: { username: string; password: string; remember?: boolean };
}
) =>
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
method: "POST",
body: JSON.stringify(body),
}),
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
addFolder: (id: number, path: string) =>
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, {
@@ -3820,6 +3899,51 @@ function requestCodexBackupsHelper(params?: {
export type CcArtifactType =
"skills" | "agents" | "commands" | "output-styles" | "memory" | "auto-memory";
export type ArtifactKind = "page" | "file" | "skill" | "plugin";
/** One row of the Artifacts hub (`server/lib/artifacts.js` `view`). */
export interface Artifact {
id: number;
kind: ArtifactKind;
title: string;
description: string;
ownerId: number | null;
owner: string | null;
mine: boolean;
canChange: boolean;
laneId: number | null;
sessionId: string | null;
/** page: the file it was published from; file: its workspace path;
* skill: its folder name; plugin: `name@marketplace`. */
source: string | null;
/** plugin: the marketplace source (owner/repo or a git URL). */
market: string | null;
/** page: the claude.ai link. */
url: string | null;
files: { path: string; size: number }[];
size: number | null;
shared: boolean;
sharedAt: string | null;
installs: number;
createdAt: string;
updatedAt: string;
}
export interface ArtifactCandidate {
path: string;
sessionId: string;
at: string;
exists: boolean;
pinned: boolean;
}
export interface ArtifactSettings {
serverMode: boolean;
canShare: boolean;
installsEnabled: boolean;
canManage: boolean;
}
/** Body for PUT /api/cc-config/file - create or overwrite one artifact. */
export interface CcWriteArgs {
// "auto-memory" targets a per-project memory file and requires `project`.
@@ -3950,7 +4074,7 @@ export interface GitIdentity {
account: "member" | "host";
/** False for the host's identity seen by a non-admin. */
writable: boolean;
ssh: { publicKey: string; fingerprint: string } | null;
ssh: { type?: "ed25519" | "rsa"; publicKey: string; fingerprint: string } | null;
/** The account's commit author (`git config --global`); null fields = unset. */
author: { name: string | null; email: string | null };
https: {
+37
View File
@@ -64,6 +64,43 @@ export function droppedAttachments(dt: DataTransfer | null | undefined): Attachm
}
}
/** A drop that carries no file and none of ours, only text — a file manager
* or an editor (VS Code) that hands over `file://` URIs, a selection, a
* link: the local `file://` URIs as `@path` attachments, else the text to
* insert, as a terminal pastes it. */
export function droppedText(
dt: DataTransfer | null | undefined
): { attachments: Attachment[] } | { text: string } | null {
try {
const paths = (dt?.getData("text/uri-list") ?? "")
.split(/\r?\n/)
.filter((l) => l && !l.startsWith("#"))
.map((l) => {
try {
const u = new URL(l.trim());
return u.protocol === "file:" && (!u.host || u.host === "localhost")
? decodeURIComponent(u.pathname)
: null;
} catch {
return null;
}
});
if (paths.length && paths.every(Boolean)) {
return {
attachments: (paths as string[]).map((p) => ({
// A space would end the mention: Claude Code reads `@"…"` whole.
path: /\s/.test(p) ? `@"${p}"` : `@${p}`,
name: p.replace(/\/$/, "").split("/").pop() || p,
})),
};
}
const text = dt?.getData("text/plain") ?? "";
return text.trim() ? { text } : null;
} catch {
return null;
}
}
export const SKILL_MIME = "application/x-agenthub-skill";
// A skill's name as Claude Code types it: `name` or `plugin:name`.
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
+20
View File
@@ -0,0 +1,20 @@
/**
* @file resolvePath.ts — resolves a path a chat reply names against the pane's
* folder: an absolute one (POSIX, `~`-less, or `C:\…`) is kept, a relative one
* (`server/lib/x.js`, `./a`, `../b`) is joined to `cwd` with `.`/`..` folded,
* so the preview and the Changes panel see the same absolute path the agent meant.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** `path` as an absolute path; relative ones resolve against `cwd`. */
export function resolveIn(cwd: string, path: string): string {
if (/^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(path) || !cwd) return path;
const parts = cwd.replace(/[\\/]+$/, "").split(/[\\/]/);
for (const seg of path.split(/[\\/]/)) {
if (seg === "" || seg === ".") continue;
if (seg === "..") {
if (parts.length > 1) parts.pop();
} else parts.push(seg);
}
return parts.join("/") || "/";
}
+10 -1
View File
@@ -34,6 +34,8 @@ export const SETTINGS_INDEX: {
to?: string;
/** Server mode: a control only an admin sees, kept out of anyone else's search. */
adminOnly?: boolean;
/** Hidden in local mode (AgentHub's own git logins), so kept out of its search. */
serverOnly?: boolean;
}[] = [
{
section: "account",
@@ -330,10 +332,17 @@ export const SETTINGS_INDEX: {
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
{ section: "git", titleKey: "gitCreds.title", adminOnly: true, keywords: ["login", "token"] },
{
section: "git",
titleKey: "gitCreds.title",
adminOnly: true,
serverOnly: true,
keywords: ["login", "token"],
},
{
section: "git",
titleKey: "gitIdentity.title",
serverOnly: true,
keywords: ["ssh", "key", "clone", "https"],
to: "#git-identity",
},
+3 -1
View File
@@ -1925,7 +1925,9 @@ export interface WSMessage {
/** A lane's profile hook exited, with its status code. */
| "lane_hook_result"
/** A pane ended; refetch GET /api/run/interrupted (no payload). */
| "runs_interrupted";
| "runs_interrupted"
/** An artifact was added, changed, shared or deleted; refetch the list. */
| "artifact_changed";
/** The message body, whose concrete shape is selected by `type` above. */
data:
| Session
+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>