feat(artifacts): capture Claude Docs docs and session documents, claim unowned rows

- A Claude Docs `batch` that creates a doc is captured like an `Artifact`
  publish, titled by the doc's name and linked by its own frame URL.
- At Stop/SessionEnd, each pptx/docx/xlsx/pdf/html a workspace session
  created or changed is snapshotted into its owner's library (`auto`);
  an unchanged file is skipped, a changed one refreshes its snapshot.
- Server mode: a page with no owner carries a suggested owner (the
  workspace or home its session ran in). The suggested user claims or
  declines it; an admin claims or assigns any. New tab Unowned /
  Is this yours? on the Artifacts page; all three actions audited.
This commit is contained in:
2026-10-23 08:33:55 +07:00
parent 241e19b713
commit 62b4520507
14 changed files with 736 additions and 44 deletions
+15 -1
View File
@@ -183,5 +183,19 @@
"stat.updated": "Updated",
"detail.runAs": "Run it as",
"detail.market": "Marketplace",
"detail.added": "Added {{date}}"
"detail.added": "Added {{date}}",
"tab.unowned": "Unowned",
"tab.suggested": "Is this yours?",
"auto": "Auto",
"autoHint": "Added when its session stopped: a document the session created or changed",
"unowned.hint": "Items no workspace owns that were made in your folders — confirm whether they are yours",
"unowned.hintAdmin": "Items with no owner: sessions outside any workspace, run as the host account",
"unowned.mine": "It's mine",
"unowned.takeIt": "Take it",
"unowned.notMine": "Not mine",
"unowned.assign": "Assign to…",
"unowned.claimed": "Added to your library",
"unowned.declined": "Removed from your list",
"unowned.assigned": "Owner assigned",
"unowned.empty": "Nothing without an owner."
}
+15 -1
View File
@@ -183,5 +183,19 @@
"stat.updated": "Cập nhật",
"detail.runAs": "Gọi bằng",
"detail.market": "Marketplace",
"detail.added": "Thêm lúc {{date}}"
"detail.added": "Thêm lúc {{date}}",
"tab.unowned": "Vô chủ",
"tab.suggested": "Có phải của bạn?",
"auto": "Tự động",
"autoHint": "Tự thêm khi phiên làm việc dừng: file tài liệu phiên đã tạo hoặc sửa",
"unowned.hint": "Những mục không thuộc workspace nào nhưng được tạo trong thư mục của bạn — xác nhận có phải của bạn không",
"unowned.hintAdmin": "Những mục chưa có chủ: phiên không thuộc workspace nào, chạy bằng tài khoản host",
"unowned.mine": "Của tôi",
"unowned.takeIt": "Nhận về tôi",
"unowned.notMine": "Không phải của tôi",
"unowned.assign": "Gán cho…",
"unowned.claimed": "Đã nhận về thư viện của bạn",
"unowned.declined": "Đã bỏ khỏi danh sách của bạn",
"unowned.assigned": "Đã gán chủ",
"unowned.empty": "Không có mục nào chưa có chủ."
}
+22 -2
View File
@@ -3016,7 +3016,9 @@ export const api = {
},
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
artifacts: {
list: (params: { scope?: "mine" | "hub" | "all"; kind?: ArtifactKind; q?: string } = {}) => {
list: (
params: { scope?: "mine" | "hub" | "all" | "unowned"; kind?: ArtifactKind; q?: string } = {}
) => {
const qs = new URLSearchParams();
if (params.scope) qs.set("scope", params.scope);
if (params.kind) qs.set("kind", params.kind);
@@ -3092,6 +3094,18 @@ export const api = {
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
},
/** An unowned row: its suggested user (or an admin) takes it. */
claim: (id: number) =>
request<{ item: Artifact }>(`/artifacts/${id}/claim`, { method: "POST" }),
/** An unowned row offered to the caller is not theirs. */
decline: (id: number) =>
request<{ id: number; declined: true }>(`/artifacts/${id}/decline`, { method: "POST" }),
/** Admin: give an unowned row to a user. */
assign: (id: number, userId: number) =>
request<{ item: Artifact }>(`/artifacts/${id}/assign`, {
method: "POST",
body: JSON.stringify({ userId }),
}),
update: (
id: number,
body: {
@@ -4382,7 +4396,7 @@ export interface Artifact {
market: string | null;
/** page: the claude.ai link. */
url: string | null;
files: { path: string; size: number }[];
files: { path: string; size: number; mtime?: number }[];
size: number | null;
shared: boolean;
sharedAt: string | null;
@@ -4391,6 +4405,12 @@ export interface Artifact {
inLibrary: boolean;
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
installed?: boolean;
/** file: captured when its session stopped, not pinned by hand. */
auto?: boolean;
/** Server mode: nobody owns it yet. */
unowned?: boolean;
/** Unowned and offered to the caller to claim or decline. */
suggestedForMe?: boolean;
createdAt: string;
updatedAt: string;
}
+120 -13
View File
@@ -49,7 +49,9 @@ import {
type ArtifactInstalls,
type ArtifactKind,
type ArtifactSettings,
type ServerUserUsage,
} from "../lib/api";
import { usePermissions } from "../components/AuthGate";
import type { Lane } from "../lib/types";
import { eventBus } from "../lib/eventBus";
import { toast } from "../lib/toast";
@@ -114,7 +116,11 @@ const footAction =
export function Artifacts() {
const { t } = useTranslation("artifacts");
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
const [tab, setTab] = useState<"all" | "mine" | "hub">("all");
const [tab, setTab] = useState<"all" | "mine" | "hub" | "unowned">("all");
const { isAdmin } = usePermissions();
// Server mode: rows nobody owns — every one for an admin, those offered to you otherwise.
const [unownedCount, setUnownedCount] = useState(0);
const [users, setUsers] = useState<ServerUserUsage[]>([]);
const [kind, setKind] = useState<ArtifactKind | "">("");
const [q, setQ] = useState("");
const [items, setItems] = useState<Artifact[] | null>(null);
@@ -147,14 +153,45 @@ export function Artifacts() {
return () => window.clearTimeout(id);
}, [load, q]);
const serverMode = !!settings?.serverMode;
const countUnowned = useCallback(() => {
if (!serverMode) return;
api.artifacts
.list({ scope: "unowned" })
.then((r) => setUnownedCount(r.items.length))
.catch(() => setUnownedCount(0));
}, [serverMode]);
useEffect(countUnowned, [countUnowned]);
useEffect(() => {
if (tab !== "unowned" || !isAdmin || users.length) return;
api.users
.list()
.then((r) => setUsers(r.users.filter((u) => !u.disabled)))
.catch(() => setUsers([]));
}, [tab, isAdmin, users.length]);
useEffect(
() =>
eventBus.subscribe((msg) => {
if (msg.type === "artifact_changed") load();
if (msg.type === "artifact_changed") {
load();
countUnowned();
}
}),
[load]
[load, countUnowned]
);
const settle = async (run: () => Promise<unknown>, done: string) => {
try {
await run();
toast.success(t(done));
load();
countUnowned();
} catch (err) {
toast.error(err);
}
};
// The Add menu closes on Escape or a click anywhere outside it.
useEffect(() => {
if (!addMenu) return;
@@ -297,15 +334,63 @@ export function Artifacts() {
},
});
const card = (a: Artifact) => (
<ArtifactCard
key={a.id}
item={a}
server={server}
canInstall={!!settings?.installsEnabled}
{...actionsFor(a)}
/>
);
const card = (a: Artifact) =>
tab === "unowned" ? (
<div key={a.id} className="flex flex-col gap-2" data-testid={`unowned-${a.id}`}>
<ArtifactCard
item={a}
server={server}
canInstall={!!settings?.installsEnabled}
{...actionsFor(a)}
/>
<div className="flex flex-wrap items-center gap-2 text-xs">
{(a.suggestedForMe || isAdmin) && (
<button
type="button"
className="btn-primary px-2 py-1 text-xs"
onClick={() => settle(() => api.artifacts.claim(a.id), "unowned.claimed")}
>
{t(a.suggestedForMe ? "unowned.mine" : "unowned.takeIt")}
</button>
)}
{a.suggestedForMe && (
<button
type="button"
className={footAction}
onClick={() => settle(() => api.artifacts.decline(a.id), "unowned.declined")}
>
{t("unowned.notMine")}
</button>
)}
{isAdmin && users.length > 0 && (
<select
className="input h-7 w-auto py-0 text-xs"
aria-label={t("unowned.assign")}
value=""
onChange={(e) => {
const userId = Number(e.target.value);
if (userId) settle(() => api.artifacts.assign(a.id, userId), "unowned.assigned");
}}
>
<option value="">{t("unowned.assign")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.displayName || u.username}
</option>
))}
</select>
)}
</div>
</div>
) : (
<ArtifactCard
key={a.id}
item={a}
server={server}
canInstall={!!settings?.installsEnabled}
{...actionsFor(a)}
/>
);
// Local mode (everything is yours): the unfiltered page is shelved by
// kind; a category or a search is one flat grid of results.
@@ -320,7 +405,7 @@ export function Artifacts() {
// what you shared, My library into what you installed from others (the
// server adds those to `mine`) and your own.
const halves =
server && items
server && items && tab !== "unowned"
? (["others", "own"] as const).map((who) => ({
key: `${tab}.${who}`,
rows: items.filter((a) => (who === "own" ? a.mine : !a.mine)),
@@ -424,12 +509,29 @@ export function Artifacts() {
{t(`tab.${v}`)}
</button>
))}
{(isAdmin || unownedCount > 0 || tab === "unowned") && (
<button
type="button"
aria-pressed={tab === "unowned"}
onClick={() => setTab("unowned")}
className={segment(tab === "unowned")}
data-testid="artifacts-tab-unowned"
title={t(isAdmin ? "unowned.hintAdmin" : "unowned.hint")}
>
{t(isAdmin ? "tab.unowned" : "tab.suggested")}
{unownedCount > 0 && <span className="ml-1 text-fg-muted">{unownedCount}</span>}
</button>
)}
</div>
)}
</div>
{items === null ? (
<SkeletonGrid label={t("loading")} />
) : items.length === 0 && tab === "unowned" ? (
<p className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-xs text-fg-muted">
{t("unowned.empty")}
</p>
) : items.length === 0 ? (
<EmptyState
hub={tab === "hub"}
@@ -755,6 +857,11 @@ function ArtifactCard({
{t(hubOnly ? "hubOnly" : "shared")}
</span>
)}
{a.auto && (
<span className="badge self-start" title={t("autoHint")}>
{t("auto")}
</span>
)}
{a.description && (
<p className="line-clamp-2 text-xs leading-relaxed text-fg-secondary">{a.description}</p>
)}