chore: v2.8.13 — Artifacts reinstall/edit/installs, agent steps in chat, pick a local file to attach, Windows paths as links
- Chat: an Agent step shows its task, status and tool calls and opens its conversation in place; Windows paths (E:\…, C:/…) in replies open as files - Prompt: attach a file on this computer by path (native picker), clearer too-large messages - Artifacts: edit title/description and a skill's files (new snapshot), Reinstall, install history, Hub-only rows, skills installed into ~/.claude/skills and ~/.agents/skills - Files: batch upload of small files in one extract - AI Tools: npm install falls back to --prefix ~/.local when the global prefix is not writable
This commit is contained in:
+588
-138
File diff suppressed because it is too large
Load Diff
@@ -107,7 +107,7 @@ export function GitCredentialsConfig() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* The page header above already names the section. */}
|
||||
<p className="max-w-2xl text-xs text-fg-muted">{t("gitCreds.description")}</p>
|
||||
<button
|
||||
@@ -128,7 +128,7 @@ export function GitCredentialsConfig() {
|
||||
|
||||
{form && (
|
||||
<form
|
||||
className="grid max-w-2xl gap-2 rounded-lg border border-border p-3 sm:grid-cols-3"
|
||||
className="grid gap-2 rounded-lg border border-border bg-surface-1 p-3 sm:grid-cols-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
@@ -159,18 +159,18 @@ export function GitCredentialsConfig() {
|
||||
onChange={(e) => setForm({ ...form, token: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<div className="flex gap-2 sm:col-span-3">
|
||||
<div className="flex justify-end gap-2 sm:col-span-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !form.url.trim() || !form.username.trim() || !form.token}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitCreds.save")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm(null)}
|
||||
className="rounded border border-border-light px-3 py-1 text-xs text-fg-secondary hover:bg-surface-3"
|
||||
className="rounded-lg border border-border-light px-3 py-1.5 text-xs text-fg-secondary hover:bg-surface-3"
|
||||
>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* @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
|
||||
* answers (one merged list; All/My library/Hub filter and Share only in
|
||||
* server mode), an installed skill shows Installed, its install count lists
|
||||
* who installed it and where, 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. Adding:
|
||||
* an HTML preview is a sandboxed iframe with no `allow-*` token, and a
|
||||
* skill's SKILL.md is rendered by the page itself. Adding:
|
||||
* a plugin is picked from the list its marketplace link answers (a link the
|
||||
* server cannot read opens the by-hand form), a skill is uploaded from one
|
||||
* Markdown file under the name the user types, or written in the dialog.
|
||||
@@ -29,6 +32,7 @@ vi.mock("../../lib/api", () => ({
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
install: vi.fn(),
|
||||
installs: vi.fn(),
|
||||
setInstallsEnabled: vi.fn(),
|
||||
skills: vi.fn(),
|
||||
addSkill: vi.fn(),
|
||||
@@ -58,6 +62,7 @@ const item = (over: Partial<Artifact>): Artifact => ({
|
||||
market: null,
|
||||
url: null,
|
||||
files: [{ path: "report.html", size: 10 }],
|
||||
inLibrary: true,
|
||||
size: 10,
|
||||
shared: false,
|
||||
sharedAt: null,
|
||||
@@ -208,9 +213,85 @@ describe("Artifacts", () => {
|
||||
installsEnabled: true,
|
||||
canManage: false,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
expect(await screen.findByRole("button", { name: "Hub" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: /^Share$/ }).length).toBe(2);
|
||||
// One merged list by default; Hub is a filter over it.
|
||||
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" }));
|
||||
await user.click(screen.getByRole("button", { name: "Hub" }));
|
||||
await waitFor(() =>
|
||||
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "hub" }))
|
||||
);
|
||||
});
|
||||
|
||||
it("lists who installed an artifact, when and where, from its install count", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installs: 3 })],
|
||||
});
|
||||
vi.mocked(api.artifacts.installs).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
user: "vi",
|
||||
mine: true,
|
||||
at: "2026-10-19T10:00:00Z",
|
||||
replaced: false,
|
||||
paths: ["/h/.claude/skills/deploy"],
|
||||
},
|
||||
{ user: "khoa", mine: false, at: "2026-10-18T10:00:00Z", replaced: true },
|
||||
],
|
||||
unrecorded: 1,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await user.click(await screen.findByRole("button", { name: "3 installs" }));
|
||||
const box = await screen.findByTestId("artifact-installs");
|
||||
expect(api.artifacts.installs).toHaveBeenCalledWith(2);
|
||||
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
|
||||
expect(within(box).getByText(/khoa/)).toBeInTheDocument();
|
||||
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByTestId("artifact-installs")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a shared row in the Hub when its owner deletes it, and removes it from there on ask", async () => {
|
||||
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
||||
serverMode: true,
|
||||
canShare: true,
|
||||
installsEnabled: true,
|
||||
canManage: false,
|
||||
});
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [item({ id: 3, title: "hub-only", shared: true, inLibrary: false })],
|
||||
});
|
||||
vi.mocked(api.artifacts.update).mockResolvedValue({ item: item({ id: 3 }) } as never);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-3");
|
||||
expect(within(card).getByText("Hub only")).toBeInTheDocument();
|
||||
expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull();
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
await user.click(within(card).getByRole("button", { name: "Remove from Hub" }));
|
||||
await waitFor(() => expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false }));
|
||||
const calls = vi.mocked(confirmDialog).mock.calls;
|
||||
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
|
||||
});
|
||||
|
||||
it("shows Installed instead of Install for a skill already installed", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installed: true })],
|
||||
});
|
||||
open();
|
||||
const card = await screen.findByTestId("artifact-2");
|
||||
expect(within(card).getByTestId("artifact-installed")).toHaveTextContent("Installed");
|
||||
expect(within(card).queryByRole("button", { name: "Install" })).toBeNull();
|
||||
|
||||
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||
vi.mocked(api.artifacts.install).mockResolvedValueOnce({
|
||||
installed: "/h/.claude/skills/deploy",
|
||||
});
|
||||
await userEvent.setup().click(within(card).getByRole("button", { name: "Reinstall" }));
|
||||
await waitFor(() => expect(api.artifacts.install).toHaveBeenLastCalledWith(2, true));
|
||||
});
|
||||
|
||||
it("installs a skill only after a confirmation, and asks before replacing", async () => {
|
||||
@@ -241,6 +322,72 @@ describe("Artifacts", () => {
|
||||
expect(frame.getAttribute("src")).toBe("/api/artifacts/1/raw?path=report.html");
|
||||
});
|
||||
|
||||
it("renders a skill's SKILL.md itself instead of an iframe", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({
|
||||
kind: "skill",
|
||||
title: "standup",
|
||||
files: [
|
||||
{ path: "SKILL.md", size: 10 },
|
||||
{ path: "notes.txt", size: 5 },
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const fetchMock = vi.fn(async (url: string) =>
|
||||
url.includes("SKILL.md")
|
||||
? new Response("---\nname: standup\n---\n# Daily standup\n\nSummarise.")
|
||||
: new Response("plain notes")
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("standup");
|
||||
await user.click(screen.getByRole("button", { name: "Preview" }));
|
||||
expect(await screen.findByRole("heading", { name: "Daily standup" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("skill-summary")).toHaveTextContent("/out/report.html");
|
||||
expect(screen.queryByTestId("artifact-preview")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "notes.txt" }));
|
||||
expect(await screen.findByText("plain notes")).toBeInTheDocument();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("edits a skill's name, description and instructions from its SKILL.md", async () => {
|
||||
vi.mocked(api.artifacts.list).mockResolvedValue({
|
||||
items: [
|
||||
item({
|
||||
kind: "skill",
|
||||
title: "standup",
|
||||
source: "standup",
|
||||
files: [{ path: "SKILL.md", size: 10 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
vi.mocked(api.artifacts.update).mockResolvedValue({ item: item({}) } as never);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("---\nname: standup\ndescription: Old\n---\n\nOld body"))
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("standup");
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
const name = await screen.findByLabelText(/Skill name/);
|
||||
expect(name).toHaveValue("standup");
|
||||
expect(screen.getByLabelText(/Instructions/)).toHaveValue("Old body");
|
||||
await user.clear(screen.getByLabelText(/Description/));
|
||||
await user.type(screen.getByLabelText(/Description/), "New");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() =>
|
||||
expect(api.artifacts.update).toHaveBeenCalledWith(1, {
|
||||
title: "standup",
|
||||
skill: { name: "standup", description: "New", content: "Old body" },
|
||||
})
|
||||
);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("opens a card's actions on right-click", async () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
|
||||
@@ -7239,7 +7239,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.12 (rebuild client to match)
|
||||
UI build v2.8.13 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user