diff --git a/client/src/i18n/locales/en/artifacts.json b/client/src/i18n/locales/en/artifacts.json index 34be8092..4e20010c 100644 --- a/client/src/i18n/locales/en/artifacts.json +++ b/client/src/i18n/locales/en/artifacts.json @@ -142,6 +142,11 @@ "installsYou": "You", "installsUnknown": "A removed user", "installsReplaced": "replaced an older copy", + "versionAt": "Updated {{ago}}", + "versionAtFull": "Last updated {{date}}", + "installState.latest": "Latest", + "installState.outdated": "Old version", + "installState.removed": "Removed", "installsPluginWhere": "claude plugin install --scope user", "installsUnrecorded": "{{count}} earlier installs were counted before details were kept", "installsNoneOfYours": "You have not installed it — only your own installs are listed.", @@ -185,7 +190,7 @@ "stat.installs": "Installs", "stat.files": "Files", "stat.size": "Size", - "stat.updated": "Updated", + "stat.updated": "Last updated", "detail.runAs": "Run it as", "detail.market": "Marketplace", "detail.added": "Added {{date}}", diff --git a/client/src/i18n/locales/vi/artifacts.json b/client/src/i18n/locales/vi/artifacts.json index 233b828c..393450de 100644 --- a/client/src/i18n/locales/vi/artifacts.json +++ b/client/src/i18n/locales/vi/artifacts.json @@ -143,6 +143,11 @@ "installsYou": "Bạn", "installsUnknown": "Người dùng đã xoá", "installsReplaced": "thay bản cũ", + "versionAt": "Cập nhật {{ago}}", + "versionAtFull": "Cập nhật lần cuối {{date}}", + "installState.latest": "Bản mới nhất", + "installState.outdated": "Bản cũ", + "installState.removed": "Đã gỡ", "installsPluginWhere": "claude plugin install --scope user", "installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết", "installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.", @@ -186,7 +191,7 @@ "stat.installs": "Lượt cài", "stat.files": "File", "stat.size": "Dung lượng", - "stat.updated": "Cập nhật", + "stat.updated": "Cập nhật lần cuối", "detail.runAs": "Gọi bằng", "detail.market": "Marketplace", "detail.added": "Thêm lúc {{date}}", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 9c857d2e..ef9345b5 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -4720,11 +4720,21 @@ export interface Artifact { suggestedForMe?: boolean; createdAt: string; updatedAt: string; + /** When its content last changed (a new snapshot); a page or plugin: when it was added. */ + versionAt: string; } /** GET /api/artifacts/:id/installs. `paths` only for your own installs (or an admin). */ export interface ArtifactInstalls { - items: { user: string | null; mine: boolean; at: string; replaced: boolean; paths?: string[] }[]; + items: { + user: string | null; + mine: boolean; + at: string; + replaced: boolean; + /** Skills: where that install's copy stands against the newest version now. */ + state?: "latest" | "outdated" | "removed" | "unknown"; + paths?: string[]; + }[]; /** Installs counted before the list was kept. */ unrecorded: number; } diff --git a/client/src/pages/Artifacts.tsx b/client/src/pages/Artifacts.tsx index cf556f3c..380b4740 100644 --- a/client/src/pages/Artifacts.tsx +++ b/client/src/pages/Artifacts.tsx @@ -32,7 +32,10 @@ * takes its folder on the server — or a plugin's copy — on a click, else * asks for the folder and uploads it onto the same row); an installed copy * that differs from the newest version shows **Update** (a pack: "Update N"), - * which replaces it after a confirmation, the old copy moved aside. Editing a skill + * which replaces it after a confirmation, the old copy moved aside. A skill or + * file row says when its CONTENT last changed ("Updated 3h ago", `versionAt`), + * and the install count's list marks each installer's copy Latest / Old + * version / Removed. Editing a skill * edits its SKILL.md (name, description, instructions — a new snapshot); * its preview opens with what it installs as and when Claude uses it. Deleting a shared * row of your own only takes it out of your library — it stays in the Hub @@ -81,6 +84,7 @@ import { toast } from "../lib/toast"; import { confirmDialog } from "../lib/confirm"; import { useDialog } from "../hooks/useDialog"; import { copyText } from "../lib/clipboard"; +import { timeAgo } from "../lib/format"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu"; import { FileUploadPanel, @@ -1271,6 +1275,7 @@ function Meta({ server: boolean; date?: boolean; }) { + const { t } = useTranslation("artifacts"); const parts: React.ReactNode[] = []; if (server && a.owner && !a.mine) parts.push( @@ -1278,7 +1283,18 @@ function Meta({ {a.owner} ); - if (date) + // A skill or file is a snapshot that changes: when it last did. Anything else: when added. + if (a.kind === "skill" || a.kind === "file") + parts.push( + + {t("versionAt", { ago: timeAgo(a.versionAt) })} + + ); + else if (date) parts.push( {fmtDate(a.createdAt)} @@ -1536,7 +1552,10 @@ function DetailDialog({ : []), ...(a.files.length ? [{ label: t("stat.files"), value: String(a.files.length) }] : []), ...(a.size ? [{ label: t("stat.size"), value: fmtBytes(a.size) }] : []), - { label: t("stat.updated"), value: fmtDate(a.updatedAt) }, + { + label: t("stat.updated"), + value: new Date(a.versionAt).toLocaleString(), + }, ]; return ( @@ -1707,6 +1726,13 @@ function DetailDialog({ * The list floats over the page (a portal at the button, below it or above * when there is no room, kept inside the window): inside the card list's * `overflow-hidden` it was cut off at the last card. */ +/** An installer's copy against the newest version: green, amber, grey. */ +const STATE_CLASS = { + latest: "bg-status-success/15 text-status-success", + outdated: "bg-status-warning/15 text-status-warning", + removed: "bg-surface-2 text-fg-muted", +} as const; + function InstallsButton({ item }: { item: Artifact }) { const { t } = useTranslation("artifacts"); const [open, setOpen] = useState(false); @@ -1798,6 +1824,14 @@ function InstallsButton({ item }: { item: Artifact }) { · {t("installsReplaced")} )} + {i.state && i.state !== "unknown" && ( + + {t(`installState.${i.state}`)} + + )} {new Date(i.at).toLocaleString()} diff --git a/client/src/pages/__tests__/Artifacts.test.tsx b/client/src/pages/__tests__/Artifacts.test.tsx index 7c6aad98..91ca574e 100644 --- a/client/src/pages/__tests__/Artifacts.test.tsx +++ b/client/src/pages/__tests__/Artifacts.test.tsx @@ -77,6 +77,7 @@ const item = (over: Partial): Artifact => ({ installs: 0, createdAt: "2026-10-14T00:00:00Z", updatedAt: "2026-10-14T00:00:00Z", + versionAt: "2026-10-14T00:00:00Z", ...over, }); @@ -349,9 +350,16 @@ describe("Artifacts", () => { mine: true, at: "2026-10-19T10:00:00Z", replaced: false, + state: "latest", paths: ["/h/.claude/skills/deploy"], }, - { user: "khoa", mine: false, at: "2026-10-18T10:00:00Z", replaced: true }, + { + user: "khoa", + mine: false, + at: "2026-10-18T10:00:00Z", + replaced: true, + state: "outdated", + }, ], unrecorded: 1, }); @@ -363,6 +371,12 @@ describe("Artifacts", () => { expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument(); expect(within(box).getByText(/khoa/)).toBeInTheDocument(); expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument(); + // Each install says whether its copy is the newest version. + expect( + within(box) + .getAllByTestId("install-state") + .map((b) => b.textContent) + ).toEqual(["Latest", "Old version"]); // Over the page, outside the card list that clips its overflow — and a // click inside it keeps it open. expect(box.parentElement).toBe(document.body); @@ -436,6 +450,24 @@ describe("Artifacts", () => { ); }); + it("says when a skill's content last changed, not when it was renamed", async () => { + vi.mocked(api.artifacts.list).mockResolvedValue({ + items: [ + item({ + id: 2, + kind: "skill", + title: "deploy", + source: "deploy", + updatedAt: new Date().toISOString(), + versionAt: new Date(Date.now() - 3 * 3600_000).toISOString(), + }), + ], + }); + open(); + const card = await screen.findByTestId("artifact-2"); + expect(within(card).getByText("Updated 3h ago")).toBeInTheDocument(); + }); + it("offers Update for an installed skill its owner changed, after a confirmation", async () => { vi.mocked(api.artifacts.list).mockResolvedValue({ items: [ diff --git a/docs/API.md b/docs/API.md index e73e171d..2088b8a1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3147,7 +3147,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session ### Artifacts hub ```http -GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed` and `updateAvailable` (default scope `all`) +GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed`, `updateAvailable` and `versionAt` (when its content last changed — a new snapshot; a rename or share toggle moves only `updatedAt`) (default scope `all`) GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage} PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]} @@ -3167,7 +3167,7 @@ GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, n POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?} POST /api/artifacts/:id/uninstall → {uninstalled, paths} POST /api/artifacts/:id/reshare → {item, changed} — Share again (skill, owner): the owner's folder on this server becomes the new version and the row is shared; 409 ENOSOURCE when the server has no copy -GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded} +GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, state?, paths?}], unrecorded} — state (skills): latest | outdated | removed | unknown, that install's Claude skill copies against the newest version now POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin POST /api/artifacts/:id/decline → {id, declined} — the suggested user POST /api/artifacts/:id/assign {userId} → {item} — admin diff --git a/server/__tests__/artifacts.test.js b/server/__tests__/artifacts.test.js index f0e16009..a037f377 100644 --- a/server/__tests__/artifacts.test.js +++ b/server/__tests__/artifacts.test.js @@ -685,6 +685,29 @@ describe("skills from files or written", () => { assert.equal(same.body.items[0].changed, false); }); + it("dates a skill by its content, and tells each install's copy from the newest", async () => { + const md = (v) => `---\nname: sg-state\ndescription: d\n---\nState ${v}.\n`; + const first = await upload("/api/artifacts/skill/upload", [["sg-state/SKILL.md", md(1)]]); + const id = first.body.items[0].id; + const at1 = first.body.items[0].versionAt; + assert.ok(at1); + const states = async () => + (await request("GET", `/api/artifacts/${id}/installs`)).body.items.map((i) => i.state); + assert.equal((await request("POST", `/api/artifacts/${id}/install`, {})).status, 200); + assert.deepEqual(await states(), ["latest"]); + + // A rename is no new version; new content is. + const renamed = await request("PATCH", `/api/artifacts/${id}`, { title: "State" }); + assert.equal(renamed.body.item.versionAt, at1); + await new Promise((r) => setTimeout(r, 5)); + const next = await upload("/api/artifacts/skill/upload", [["sg-state/SKILL.md", md(2)]]); + assert.ok(next.body.items[0].versionAt > at1); + assert.deepEqual(await states(), ["outdated"]); + + fs.rmSync(path.join(process.env.CLAUDE_HOME, "skills", "sg-state"), { recursive: true }); + assert.deepEqual(await states(), ["removed"]); + }); + it("Share again: an uploaded skill asks for its folder, which replaces that row", async () => { const md = (v) => `---\nname: sg-up\ndescription: d\n---\nUp ${v}.\n`; const first = await upload("/api/artifacts/skill/upload", [["sg-up/SKILL.md", md(1)]]); diff --git a/server/db.js b/server/db.js index 6b946370..ad0acd69 100644 --- a/server/db.js +++ b/server/db.js @@ -973,11 +973,16 @@ for (const [col, ddl] of [ ["suggested_owner_id", "suggested_owner_id INTEGER"], ["version", "version TEXT"], ["source_hash", "source_hash TEXT"], + ["version_at", "version_at TEXT"], ]) { try { db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get(); } catch { db.prepare(`ALTER TABLE artifacts ADD COLUMN ${ddl}`).run(); + // `version_at`: when a row's CONTENT last changed (a new snapshot) — a + // rename or a share toggle moves `updated_at` but not this. An existing + // row starts from its `updated_at`, the best record there is. + if (col === "version_at") db.prepare("UPDATE artifacts SET version_at = updated_at").run(); } } diff --git a/server/lib/artifacts.js b/server/lib/artifacts.js index 8c51b4ca..43cccfce 100644 --- a/server/lib/artifacts.js +++ b/server/lib/artifacts.js @@ -173,6 +173,8 @@ function view(row, req, owners = null) { suggestedForMe: !!req?.user && row.owner_id == null && row.suggested_owner_id === req.user.id, createdAt: row.created_at, updatedAt: row.updated_at, + // When its content last changed (a new snapshot): a page or plugin never does. + versionAt: row.version_at || row.created_at, }; } @@ -627,7 +629,7 @@ async function captureOutputsNow(laneId, sessionId) { const files = JSON.stringify([{ path: shot.name, size: shot.size, mtime: mtimeMs }]); if (row) { sql( - "UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?" + "UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?" ).run(shot.snap.id, files, shot.size, String(sessionId), row.id); dropSnapshot(row.snapshot); done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id)); @@ -1641,7 +1643,8 @@ function editSkill(req, row, { name, description, content }) { sql( `UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?, version = ?, source_hash = ?, - updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?` + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), + version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?` ).run( n, snap.id, @@ -2027,7 +2030,8 @@ function replaceSnapshot(row, files, sourceHash) { : row.description; sql( `UPDATE artifacts SET snapshot = ?, files = ?, size = ?, version = ?, source_hash = ?, - description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?` + description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), + version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?` ).run( snap.id, JSON.stringify(next), @@ -2308,6 +2312,36 @@ async function uninstall(req, id) { * the installer and an admin. `unrecorded` = installs counted before this * list was kept. */ +/** + * Where one recorded install of a skill stands now, from its Claude + * `skills/` copies (one per account it went into; the cross-tool + * `~/.agents/skills` twins are the same files) — + * `outdated` when any differs from the row's newest snapshot (its user is + * offered Update), `removed` when none is there any more, `unknown` when one + * is there but unreadable as a skill, else `latest`. Only hashes are + * compared; no content leaves the server. The paths are the ones the server + * itself recorded at install, read like `updateCheck` reads the viewer's own. + */ +function installState(row, paths) { + const version = snapshotVersion(row); + const dirs = (Array.isArray(paths) ? paths : []).filter( + (p) => + typeof p === "string" && + path.basename(p) === row.source && + path.basename(path.dirname(p)) === "skills" && + path.basename(path.dirname(path.dirname(p))) !== ".agents" + ); + if (!version || !dirs.length) return "unknown"; + const states = dirs.map((dir) => { + const st = folderState(dir, path.dirname(path.dirname(dir))); + if (st) return st.hash === version ? "latest" : "outdated"; + return exists(dir) ? "unknown" : "removed"; + }); + if (states.includes("outdated")) return "outdated"; + if (states.every((x) => x === "removed")) return "removed"; + return states.includes("unknown") ? "unknown" : "latest"; +} + function installsOf(req, id) { const row = load(req, id); const u = req?.user; @@ -2331,6 +2365,7 @@ function installsOf(req, id) { mine, at: r.installed_at, replaced: r.replaced === 1, + ...(row.kind === "skill" ? { state: installState(row, paths) } : {}), ...(mine || u?.role === "admin" ? { paths } : {}), }; });