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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user