Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import {
ApprovalResponse,
AssetWithOriginResponse,
PrincipalSummary,
UserProfile,
WorkflowRunSummary,
Expand All @@ -21,6 +22,13 @@ export const PrincipalsSchema = paginatedSchema(PrincipalSummary);
export const RunsSchema = paginatedSchema(WorkflowRunSummary);
export const TenantApprovalsSchema = paginatedSchema(ApprovalResponse);

// `GET /api/tenants/:tenantId/assets` returns a bare array of
// `AssetWithOriginResponse` rows (not the paginated envelope), so the schema
// validates the array directly. These tenant assets — workflows, skills,
// package registries, agent state — are the real, listable store the Library
// page renders as artifacts.
export const AssetsSchema = AssetWithOriginResponse.array();

// `@corbits/approvals`'s "needs you" read: the same pending approvals as
// `TenantApprovalsSchema`, but with the agent and bench names already
// resolved server-side, so nothing here ever needs a raw id to render.
Expand All @@ -40,6 +48,7 @@ export type Profile = typeof UserProfile.infer;
export type Principal = typeof PrincipalSummary.infer;
export type WorkflowRun = typeof WorkflowRunSummary.infer;
export type Approval = typeof ApprovalResponse.infer;
export type AssetRow = typeof AssetWithOriginResponse.infer;
export type NeedsYou = typeof NeedsYouSchema.infer;
export type NeedsYouItem = NeedsYou["items"][number];

Expand Down
47 changes: 35 additions & 12 deletions apps/web/src/pages/library-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ import type { ArtifactSort, ArtifactSummary } from "@corbits/artifact-ui";
import { ArrowDownUp, FileStack } from "lucide-react";
import { useMemo, useState } from "react";

import { AssetsSchema, useAPIQuery } from "../api";
import { useBench } from "../bench-context";
import { mapAssetsToArtifacts } from "../shell/library-artifacts";
import { QueryView } from "../query-view";

const SORT_LABEL: Record<ArtifactSort, string> = {
newest: "Newest first",
oldest: "Oldest first",
Expand Down Expand Up @@ -96,10 +101,9 @@ function ArtifactRows({

/**
* The artifact gallery. Real data all the way down — search, sort, view
* mode — but the hub does not yet expose a cross-tenant artifact store (see
* `LibraryRoute` below), so `artifacts` is honestly empty until that
* endpoint exists. Nothing here is placeholder content: an empty `artifacts`
* array renders the teaching empty state, never fabricated rows.
* mode. The route resolves the current bench's assets into the
* `ArtifactSummary` rows this page renders (see `LibraryRoute`); an empty
* list is a truthful empty bench, never fabricated rows.
*/
export function LibraryPage({
artifacts,
Expand Down Expand Up @@ -147,7 +151,7 @@ export function LibraryPage({
<RichEmptyState
icon={<FileStack />}
title="No artifacts yet"
description="The hub doesn't expose an artifact store across benches yet. Once a workflow run can publish an output — a document, an export, a deck — it appears here: searchable, sortable, and grouped by kind."
description="This workbench has no assets yet — workflows, skills, package registries, and agent state show up here as soon as they exist."
/>
) : visible.length === 0 ? (
<RichEmptyState
Expand All @@ -172,11 +176,30 @@ export function LibraryPage({
}

export function LibraryRoute() {
// Seam, not a stub: `ArtifactSummary` (packages/artifact-ui) is the shape
// a future cross-tenant `/api/.../artifacts` endpoint will fill. Until the
// hub exposes one, this stays a real, empty list rather than a fetch
// against a route that doesn't exist — the presentation above is fully
// wired against it and needs no changes once the endpoint lands.
const artifacts: readonly ArtifactSummary[] = [];
return <LibraryPage artifacts={artifacts} />;
const { selectedTenantId } = useBench();
// Tenant-local assets are the honest Library source today: the hub has no
// separate artifact store, but every bench already owns workflows, skills,
// package registries, and agent state at GET /api/tenants/:id/assets.
const assets = useAPIQuery(
selectedTenantId === null ? "" : `/api/tenants/${selectedTenantId}/assets`,
AssetsSchema,
);

if (selectedTenantId === null) {
return (
<PageShell width="full" className="page-fill">
<RichEmptyState
icon={<FileStack />}
title="Select a workbench"
description="Pick a workbench from the switcher to browse the assets it owns."
/>
</PageShell>
);
}

return (
<QueryView query={assets} label="library artifacts">
{(rows) => <LibraryPage artifacts={mapAssetsToArtifacts(rows)} />}
</QueryView>
);
}
3 changes: 3 additions & 0 deletions apps/web/src/query-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const tenantKeys = {
["tenant", tenantId, "definitions"] as const,
agentDirectory: (tenantId: string) =>
["tenant", tenantId, "agents", "directory"] as const,
assets: (tenantId: string) => ["tenant", tenantId, "assets"] as const,
};

/**
Expand All @@ -60,5 +61,7 @@ export function pathToQueryKey(path: string): readonly unknown[] {
if (path === "/api/me/workflows/runs") return meKeys.runs;
const needsYou = /^\/api\/tenants\/([^/]+)\/approvals\/needs-you$/.exec(path);
if (needsYou?.[1] !== undefined) return tenantKeys.needsYou(needsYou[1]);
const assets = /^\/api\/tenants\/([^/]+)\/assets$/.exec(path);
if (assets?.[1] !== undefined) return tenantKeys.assets(assets[1]);
return ["path", path];
}
48 changes: 48 additions & 0 deletions apps/web/src/shell/library-artifacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// The Library page's one seam to the hub's asset store. The hub has no
// dedicated artifact endpoint yet, but it already lists real, tenant-scoped
// assets — workflows, skills, package registries, agent state — at
// `GET /api/tenants/:tenantId/assets`. Each asset is an honest artifact a bench
// owns, so the Library renders them directly rather than staying empty.
//
// This module owns only the AssetRow -> ArtifactSummary mapping, kept pure and
// apart from React so the shape contract has its own test. When a real artifact
// endpoint lands, only this file's body changes; the page above it is already
// wired against `ArtifactSummary`.

import type { ArtifactSummary } from "@corbits/artifact-ui";

import type { AssetRow } from "../api";

/**
* The display title an asset contributes as an artifact: an author-chosen
* `displayName` when present, otherwise the kebab `name` the asset is
* addressed by. Never fabricated.
*/
function assetTitle(asset: AssetRow): string {
return asset.displayName ?? asset.name;
}

/** One tenant asset, re-shaped into the row the Library page renders. */
export function assetToArtifact(asset: AssetRow): ArtifactSummary {
return {
id: asset.id,
title: assetTitle(asset),
// Open vocabulary by design (see packages/artifact-ui/src/types.ts): the
// asset's own kind — "workflow", "skill", "package-registry",
// "agent-state" — passes straight through so a new asset kind needs no
// mapping change here.
kind: asset.kind,
// The asset response carries a `creatorPrincipalId`, not a display name,
// so owner is honestly unknown rather than guessed.
ownerName: null,
createdAt: asset.createdAt,
updatedAt: asset.updatedAt,
};
}

/** Map a full asset listing into artifact rows, preserving order and count. */
export function mapAssetsToArtifacts(
assets: readonly AssetRow[],
): ArtifactSummary[] {
return assets.map(assetToArtifact);
}
51 changes: 51 additions & 0 deletions apps/web/test/library-artifacts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test";

import type { AssetRow } from "../src/api";
import {
assetToArtifact,
mapAssetsToArtifacts,
} from "../src/shell/library-artifacts";

const sample: AssetRow = {
id: "ast_1",
tenantId: "ten_1",
kind: "workflow",
name: "nightly-digest",
displayName: "Nightly Digest",
creatorPrincipalId: "pri_1",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
origin: { tenantId: "ten_1", direct: true },
};

describe("library-artifacts", () => {
test("prefers displayName for the artifact title", () => {
expect(assetToArtifact(sample)).toEqual({
id: "ast_1",
title: "Nightly Digest",
kind: "workflow",
ownerName: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
});
});

test("falls back to the asset name when displayName is null", () => {
const unnamed = { ...sample, displayName: null };
expect(assetToArtifact(unnamed).title).toBe("nightly-digest");
});

test("maps a list without inventing or dropping rows", () => {
const second = {
...sample,
id: "ast_2",
kind: "skill",
name: "summarize",
displayName: null,
};
const mapped = mapAssetsToArtifacts([sample, second]);
expect(mapped).toHaveLength(2);
expect(mapped[0]?.id).toBe("ast_1");
expect(mapped[1]?.kind).toBe("skill");
});
});
3 changes: 1 addition & 2 deletions apps/web/test/pages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ describe("empty states", () => {
test("library teaches what will appear once the seam is real", () => {
const markup = renderToStaticMarkup(<LibraryPage artifacts={[]} />);
expect(markup).toContain("No artifacts yet");
expect(markup).toContain("hub doesn");
expect(markup).toContain("expose an artifact store");
expect(markup).toContain("This workbench has no assets yet");
});

test("skills describes itself instead of faking content", () => {
Expand Down
6 changes: 6 additions & 0 deletions apps/web/test/query-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ describe("pathToQueryKey", () => {
);
});

test("maps tenant assets onto a tenant-scoped key", () => {
expect(pathToQueryKey("/api/tenants/tnt_1/assets")).toEqual(
tenantKeys.assets("tnt_1"),
);
});

test("falls back to a path key for unknown routes", () => {
expect(pathToQueryKey("/api/mystery")).toEqual(["path", "/api/mystery"]);
});
Expand Down
7 changes: 3 additions & 4 deletions packages/artifact-ui/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// The row a Library page renders. The hub exposes no artifact store yet
// (see `apps/web/src/pages/library-page.tsx`), so this schema is the seam a
// future `/api/.../artifacts` response gets validated against — not a mirror
// of any endpoint that exists today.
// The row a Library page renders. Today the web app maps tenant assets
// (`GET /api/tenants/:id/assets`) into this shape; a dedicated
// `/api/.../artifacts` endpoint would validate against the same schema.

import { type } from "arktype";

Expand Down
Loading