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:
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user