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:
2026-10-30 15:21:31 +07:00
parent bc44cbc8b7
commit d09c9d6633
9 changed files with 161 additions and 12 deletions
+6 -1
View File
@@ -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}}",
+6 -1
View File
@@ -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
View File
@@ -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;
}
+37 -3
View File
@@ -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>
+33 -1
View File
@@ -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: [