feat(artifacts): last updated time on skills and files; each installer's copy marked
A row said nothing about when its content last changed, and the install list could not tell who still runs an old version. `artifacts.version_at` (new column, existing rows from `updated_at`) is stamped only when a snapshot is replaced — a rename or share toggle leaves it — and served as `versionAt`: skill and file rows read "Updated 3h ago", the detail sheet "Last updated". `GET /api/artifacts/:id/installs` gives each skill install a `state` — latest / outdated / removed / unknown — from its Claude skill copies' hash against the newest snapshot, shown as a badge.
This commit is contained in:
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
+11
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</span>
|
||||
);
|
||||
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(
|
||||
<span
|
||||
key="at"
|
||||
className="shrink-0"
|
||||
title={t("versionAtFull", { date: new Date(a.versionAt).toLocaleString() })}
|
||||
>
|
||||
{t("versionAt", { ago: timeAgo(a.versionAt) })}
|
||||
</span>
|
||||
);
|
||||
else if (date)
|
||||
parts.push(
|
||||
<span key="at" className="shrink-0" title={new Date(a.createdAt).toLocaleString()}>
|
||||
{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 (
|
||||
<Modal title={a.title} onClose={onClose} size="md">
|
||||
@@ -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 }) {
|
||||
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
|
||||
)}
|
||||
</span>
|
||||
{i.state && i.state !== "unknown" && (
|
||||
<span
|
||||
data-testid="install-state"
|
||||
className={`shrink-0 rounded px-1 text-[10px] ${STATE_CLASS[i.state]}`}
|
||||
>
|
||||
{t(`installState.${i.state}`)}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 text-[11px] text-fg-muted">
|
||||
{new Date(i.at).toLocaleString()}
|
||||
</span>
|
||||
|
||||
@@ -77,6 +77,7 @@ const item = (over: Partial<Artifact>): 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: [
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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)]]);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+38
-3
@@ -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/<name>` 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 } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user