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
+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>
)}