diff --git a/CLAUDE.md b/CLAUDE.md index 50f24265..3d6e6c45 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/client/src/i18n/locales/en/artifacts.json b/client/src/i18n/locales/en/artifacts.json index 3e78145a..d1a75976 100644 --- a/client/src/i18n/locales/en/artifacts.json +++ b/client/src/i18n/locales/en/artifacts.json @@ -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" } diff --git a/client/src/i18n/locales/vi/artifacts.json b/client/src/i18n/locales/vi/artifacts.json index 907acc81..b902bffc 100644 --- a/client/src/i18n/locales/vi/artifacts.json +++ b/client/src/i18n/locales/vi/artifacts.json @@ -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" } diff --git a/client/src/pages/Artifacts.tsx b/client/src/pages/Artifacts.tsx index 3b1f28e8..ebd90a8f 100644 --- a/client/src/pages/Artifacts.tsx +++ b/client/src/pages/Artifacts.tsx @@ -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)} > @@ -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} {counts(f.id)} diff --git a/client/src/pages/__tests__/Artifacts.test.tsx b/client/src/pages/__tests__/Artifacts.test.tsx index 8d147213..c0560f5a 100644 --- a/client/src/pages/__tests__/Artifacts.test.tsx +++ b/client/src/pages/__tests__/Artifacts.test.tsx @@ -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(); + 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, diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index c3cd2ca1..10dc4e03 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = ` - UI build v2.8.71 (rebuild client to match) + UI build v2.8.72 (rebuild client to match)

diff --git a/docs/API.md b/docs/API.md index 573c35b3..e5ae2b9b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 diff --git a/package-lock.json b/package-lock.json index c31d33c8..400eede6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 2426b1e4..9dff303e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agenthub", - "version": "2.8.71", + "version": "2.8.72", "bin": { "agenthub": "bin/agenthub.js" }, diff --git a/server/__tests__/update-check-release.test.js b/server/__tests__/update-check-release.test.js index 6b3654e6..e76c61dd 100644 --- a/server/__tests__/update-check-release.test.js +++ b/server/__tests__/update-check-release.test.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), []); + }); +}); diff --git a/server/lib/update-check.js b/server/lib/update-check.js index 2a35018a..faf29f85 100644 --- a/server/lib/update-check.js +++ b/server/lib/update-check.js @@ -837,6 +837,30 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) { * @returns {Promise} 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, };