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:
2026-10-20 16:44:55 +07:00
parent 49f87c4da3
commit 0547488f13
59 changed files with 4285 additions and 690 deletions
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -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>
+149 -2
View File
@@ -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>