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
+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();