chore: v2.8.72 — update survives npm-rewritten lockfiles; drag artifacts into folders

- update: before the --ff-only merge, put back package-lock.json /
  client/package-lock.json when they are the ONLY tracked changes (an
  earlier update's npm install rewrote them; v2.8.71 changed
  client/package-lock.json and the merge refused on such installs). Any
  other change still makes the merge refuse.
- Artifacts: drag a row or card onto a folder chip to put it there, onto
  "All folders" to take it out.
This commit is contained in:
2026-11-04 15:27:48 +07:00
parent c946b73d11
commit 30338292a9
11 changed files with 172 additions and 10 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.8.71 (2026-11-04) — artifacts shared with chosen people, per-account folders, and a viewable copy of every page._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
_Last synced: v2.8.72 (2026-11-04) — an update puts back npm-rewritten lockfiles before its `--ff-only` merge; artifacts drag into folders._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
+5 -1
View File
@@ -275,5 +275,9 @@
"pdf.failed": "This PDF could not be shown. Download it instead.",
"pdf.more": "Showing the first {{shown}} of {{total}} pages — download it for the rest.",
"exportsSwitch": "Keep a viewable copy of Docs and Slides",
"exportsSwitchHint": "When Claude publishes a Claude Docs doc, a deck or a design from an AgentHub session, it is asked to also save a copy (Markdown, PDF or HTML) that the Hub shows to the people it is shared with — they need no claude.ai access. Applies to sessions started afterwards."
"exportsSwitchHint": "When Claude publishes a Claude Docs doc, a deck or a design from an AgentHub session, it is asked to also save a copy (Markdown, PDF or HTML) that the Hub shows to the people it is shared with — they need no claude.ai access. Applies to sessions started afterwards.",
"folders.moved": "Moved to “{{name}}”",
"folders.takenOut": "Taken out of its folder",
"folders.dropHint": "Drop an artifact here to put it in this folder",
"folders.dropOutHint": "Drop an artifact here to take it out of its folder"
}
+5 -1
View File
@@ -273,5 +273,9 @@
"pdf.failed": "Không hiển thị được PDF này. Hãy tải về.",
"pdf.more": "Đang hiện {{shown}}/{{total}} trang đầu — tải về để xem hết.",
"exportsSwitch": "Giữ bản xem được của Docs và Slides",
"exportsSwitchHint": "Khi Claude tạo một Claude Docs, slide hoặc design trong phiên AgentHub, Claude được nhắc lưu thêm một bản sao (Markdown, PDF hoặc HTML) để Hub hiển thị cho người được chia sẻ — họ không cần quyền trên claude.ai. Áp dụng cho các phiên mở sau đó."
"exportsSwitchHint": "Khi Claude tạo một Claude Docs, slide hoặc design trong phiên AgentHub, Claude được nhắc lưu thêm một bản sao (Markdown, PDF hoặc HTML) để Hub hiển thị cho người được chia sẻ — họ không cần quyền trên claude.ai. Áp dụng cho các phiên mở sau đó.",
"folders.moved": "Đã chuyển vào “{{name}}”",
"folders.takenOut": "Đã bỏ khỏi thư mục",
"folders.dropHint": "Thả artifact vào đây để đưa vào thư mục này",
"folders.dropOutHint": "Thả artifact vào đây để bỏ khỏi thư mục"
}
+47 -2
View File
@@ -131,6 +131,17 @@ const PREVIEWABLE =
const PDF = /\.pdf$/i;
/** What a row can do, worked out once for its card, menu and detail sheet. */
/** What a dragged artifact carries: its id, for a folder chip to take. */
const DRAG_TYPE = "application/x-agenthub-artifact";
const dragProps = (a: Artifact) => ({
draggable: true,
onDragStart: (e: React.DragEvent) => {
e.dataTransfer.setData(DRAG_TYPE, String(a.id));
e.dataTransfer.setData("text/plain", a.title);
e.dataTransfer.effectAllowed = "move";
},
});
/** Shared at all: with everyone (the Hub) or with chosen people. */
const isShared = (a: Artifact) => a.shared || (a.sharedWith?.length ?? 0) > 0;
@@ -834,6 +845,11 @@ export function Artifacts() {
active={activeFolder}
counts={(id) => (loaded ?? []).filter((a) => folders.of[a.id] === id).length}
onPick={setFolder}
onDropItem={(id, fid) => {
moveToFolder(id, fid);
const f = fid ? folders.folders.find((x) => x.id === fid) : null;
toast.success(f ? t("folders.moved", { name: f.name }) : t("folders.takenOut"));
}}
/>
{items === null ? (
@@ -1539,6 +1555,7 @@ function ArtifactRow({ inPack, ...props }: ItemProps & { inPack?: boolean }) {
className="flex items-center gap-3 px-3 py-2.5 transition-colors hover:bg-surface-2/60"
data-testid={`artifact-${a.id}`}
onContextMenu={(e) => menu.open(e, null)}
{...dragProps(a)}
>
<KindIcon kind={a.kind} />
<button
@@ -1593,6 +1610,7 @@ function ArtifactCard({ as = "li", ...props }: ItemProps & { as?: "li" | "div" }
className="card flex flex-col gap-2.5 p-4 transition-colors hover:border-border-light"
data-testid={`artifact-${a.id}`}
onContextMenu={(e: React.MouseEvent) => menu.open(e, null)}
{...dragProps(a)}
>
<div className="flex items-start gap-3">
<KindIcon kind={a.kind} />
@@ -2492,13 +2510,35 @@ function FolderBar({
active,
counts,
onPick,
onDropItem,
}: {
folders: { id: string; name: string }[];
active: string;
counts: (id: string) => number;
onPick: (id: string) => void;
/** An artifact dropped on a chip: that folder, or (null, "All folders") none. */
onDropItem: (itemId: number, folderId: string | null) => void;
}) {
const { t } = useTranslation("artifacts");
// The chip an artifact is dragged over ("" = All folders), lit as a target.
const [over, setOver] = useState<string | null>(null);
const dropProps = (target: string) => ({
onDragOver: (e: React.DragEvent) => {
if (!e.dataTransfer.types.includes(DRAG_TYPE)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (over !== target) setOver(target);
},
onDragLeave: () => setOver((cur) => (cur === target ? null : cur)),
onDrop: (e: React.DragEvent) => {
setOver(null);
const id = Number(e.dataTransfer.getData(DRAG_TYPE));
if (!Number.isInteger(id) || id <= 0) return;
e.preventDefault();
onDropItem(id, target || null);
},
});
const lit = (target: string) => (over === target ? " ring-2 ring-accent bg-accent-muted" : "");
// `new`: naming a new folder; a folder id: renaming that one.
const [editing, setEditing] = useState<"new" | string | null>(null);
const [name, setName] = useState("");
@@ -2534,8 +2574,10 @@ function FolderBar({
<button
type="button"
aria-pressed={!active}
className={chip(!active)}
className={chip(!active) + lit("")}
onClick={() => onPick("")}
title={folders.length ? t("folders.dropOutHint") : undefined}
{...dropProps("")}
>
{t("folders.all")}
</button>
@@ -2544,8 +2586,11 @@ function FolderBar({
key={f.id}
type="button"
aria-pressed={active === f.id}
className={chip(active === f.id)}
className={chip(active === f.id) + lit(f.id)}
onClick={() => onPick(f.id)}
title={t("folders.dropHint")}
data-testid={`artifact-folder-${f.id}`}
{...dropProps(f.id)}
>
{f.name}
<span className="text-fg-muted">{counts(f.id)}</span>
@@ -506,6 +506,43 @@ describe("Artifacts", () => {
localStorage.removeItem("agenthub.artifacts.folders");
});
it("drags an artifact onto a folder to put it there, and onto All folders to take it out", async () => {
localStorage.setItem(
"agenthub.artifacts.folders",
JSON.stringify({ folders: [{ id: "f1", name: "Slides" }], of: {} })
);
open();
const row = await screen.findByTestId("artifact-2");
expect(row).toHaveAttribute("draggable", "true");
const data = new Map<string, string>();
const dataTransfer = {
setData: (k: string, v: string) => data.set(k, v),
getData: (k: string) => data.get(k) ?? "",
get types() {
return [...data.keys()];
},
effectAllowed: "",
dropEffect: "",
};
const stored = () => JSON.parse(localStorage.getItem("agenthub.artifacts.folders")!);
fireEvent.dragStart(row, { dataTransfer });
const chip = screen.getByTestId("artifact-folder-f1");
fireEvent.dragOver(chip, { dataTransfer });
expect(chip.className).toMatch(/ring-2/);
fireEvent.drop(chip, { dataTransfer });
expect(stored().of["2"]).toBe("f1");
expect(chip.className).not.toMatch(/ring-2/);
// Something that is not an artifact (a file from the desktop) is ignored.
fireEvent.drop(chip, {
dataTransfer: { ...dataTransfer, getData: () => "", types: ["Files"] },
});
expect(stored().of["2"]).toBe("f1");
fireEvent.dragStart(row, { dataTransfer });
fireEvent.drop(screen.getByRole("button", { name: "All folders" }), { dataTransfer });
expect(stored().of["2"]).toBeUndefined();
localStorage.removeItem("agenthub.artifacts.folders");
});
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,
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.71 (rebuild client to match)
UI build v2.8.72 (rebuild client to match)
</span>
</p>
</div>
+1 -1
View File
@@ -3658,7 +3658,7 @@ POST /api/updates/check
POST /api/updates/apply
```
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, plugin_update_progress, repo_root, … }` (`plugin_update_progress` is `{ step, index, total, started_at }` while a plugin-update chain runs — `marketplace_update` → `plugin_update` → `bootstrap`, from `plugin-update-progress.json` in the runtime dir — else `null`) (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only`, rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, plugin_update_progress, repo_root, … }` (`plugin_update_progress` is `{ step, index, total, started_at }` while a plugin-update chain runs — `marketplace_update` → `plugin_update` → `bootstrap`, from `plugin-update-progress.json` in the runtime dir — else `null`) (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only` (first putting back `package-lock.json` / `client/package-lock.json` when they are the ONLY tracked changes — an earlier update's `npm install` rewrote them; any other change still makes the merge refuse), rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
### Import history
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.71",
"version": "2.8.72",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.71",
"version": "2.8.72",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.71",
"version": "2.8.72",
"bin": {
"agenthub": "bin/agenthub.js"
},
@@ -18,6 +18,7 @@ const { execFileSync } = require("child_process");
const {
getUpdatesStatus,
applyUpdate,
restoreNpmLockfiles,
compareVersions,
releaseApiUrl,
detectPluginInstall,
@@ -512,3 +513,48 @@ describe("applyUpdate — plugin cache is Claude Code's to replace", () => {
assert.equal(git(repo, ["rev-parse", "HEAD"]), headBefore, "HEAD must be untouched");
});
});
describe("restoreNpmLockfiles — npm's rewrite never blocks an update", () => {
const git = (cwd, ...args) =>
execFileSync("git", args, {
cwd,
encoding: "utf8",
env: {
...process.env,
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "t@t",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "t@t",
},
});
let repo;
before(() => {
repo = fs.mkdtempSync(path.join(os.tmpdir(), "lockfiles-"));
git(repo, "init", "-q");
fs.mkdirSync(path.join(repo, "client"));
fs.writeFileSync(path.join(repo, "package-lock.json"), "{}\n");
fs.writeFileSync(path.join(repo, "client", "package-lock.json"), "{}\n");
fs.writeFileSync(path.join(repo, "app.js"), "v1\n");
git(repo, "add", ".");
git(repo, "commit", "-qm", "init");
});
after(() => fs.rmSync(repo, { recursive: true, force: true }));
it("puts lockfiles npm rewrote back, and only when nothing else changed", async () => {
const lock = path.join(repo, "client", "package-lock.json");
fs.writeFileSync(lock, '{"npm":"rewrote"}\n');
assert.deepEqual(await restoreNpmLockfiles(repo), ["client/package-lock.json"]);
assert.equal(fs.readFileSync(lock, "utf8"), "{}\n");
// A real change beside it: nothing is touched, the merge refuses as before.
fs.writeFileSync(lock, '{"npm":"rewrote"}\n');
fs.writeFileSync(path.join(repo, "app.js"), "my edit\n");
assert.deepEqual(await restoreNpmLockfiles(repo), []);
assert.equal(fs.readFileSync(lock, "utf8"), '{"npm":"rewrote"}\n');
assert.equal(fs.readFileSync(path.join(repo, "app.js"), "utf8"), "my edit\n");
// A clean tree: nothing to do.
git(repo, "checkout", "--", ".");
assert.deepEqual(await restoreNpmLockfiles(repo), []);
});
});
+26
View File
@@ -837,6 +837,30 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
* @returns {Promise<object>} the refreshed status, plus `applied` (and
* `reason` when `applied` is false).
*/
// The lockfiles `npm run setup` (an earlier update's `npm install`) may have
// rewritten — another npm version normalizes them. Left modified, the next
// update's `merge --ff-only` refuses the moment a release changes one of them.
const NPM_LOCKFILES = ["package-lock.json", "client/package-lock.json"];
/**
* Before a fast-forward: when the ONLY tracked changes are npm lockfiles, put
* them back to HEAD (npm wrote them, not the user) and return their paths.
* Any other change leaves everything alone (returns []), so the merge still
* refuses rather than lose real work.
*/
async function restoreNpmLockfiles(root) {
const changed = (await execGit(root, ["diff", "--name-only", "HEAD", "--"], { timeout: 30_000 }))
.split("\n")
.map((f) => f.trim())
.filter(Boolean);
if (!changed.length || !changed.every((f) => NPM_LOCKFILES.includes(f))) return [];
await execGit(root, ["checkout", "HEAD", "--", ...changed], { timeout: 30_000 });
console.log(
`[updates] restored ${changed.join(", ")} (rewritten by npm install) before updating`
);
return changed;
}
async function applyUpdate(gitRoot = DEFAULT_ROOT) {
// A plugin-cache install is Claude Code's to replace. Pulling here would
// leave a cache directory named after one commit but holding another, and
@@ -866,6 +890,7 @@ async function applyUpdate(gitRoot = DEFAULT_ROOT) {
}
const root = path.resolve(gitRoot);
await restoreNpmLockfiles(root);
if (status.situation === "release_available") {
await execGit(root, ["fetch", status.canonical_remote, "--tags"], { timeout: 120_000 });
// --ff-only is the guard: a release that isn't a descendant of HEAD (a
@@ -903,4 +928,5 @@ module.exports = {
isShallowClone,
npmInvocation,
execNpm,
restoreNpmLockfiles,
};