pi-mono/apps/p2/plugins/artifacts/web.tsx
Mario Zechner 05dc8d239d feat: add apps/p2 (imported from pi-private at 7dadff697)
p2 (Pi Mobile on Chord v2) moves from pi-private into pi as one commit. Root scripts that loop all workspaces
use --if-present; npm test, ./test.sh and the release script cover packages/* only (p2: cd apps/p2 && npm test);
sync-versions also updates apps' workspace pins.
2026-11-03 13:39:06 +00:00

111 lines
3.2 KiB
TypeScript

import { compile, obj, union } from "@earendil-works/pi-schema";
import { contribute, defineFacet, literal } from "@p2/app/v1";
import {
ArtifactIcon,
ArtifactPicker,
closeLayers,
FilePanel,
OpenViewer,
openLayer,
SessionId,
Slots,
useObject,
useReadable,
useRoute,
type ViewerItem,
} from "@p2/app/v1/web";
import type { ReactNode } from "react";
import { DownloadInfoSchema } from "../files/contract.ts";
import { ArtifactInfoSchema, ArtifactList } from "./contract.ts";
import { ArtifactPanel } from "./preview.tsx";
const ViewerSchema = union([
obj({ kind: literal("file"), file: DownloadInfoSchema }, { open: true }),
obj({ kind: literal("artifact"), artifact: ArtifactInfoSchema }, { open: true }),
]);
const isViewer = compile(ViewerSchema).check;
/** Untrusted URL metadata is only a locator. HTTP routes authorize every content fetch. */
function viewer(overlay: string | undefined): ViewerItem | undefined {
if (!overlay?.startsWith("viewer:")) return undefined;
try {
const value: unknown = JSON.parse(overlay.slice("viewer:".length));
return isViewer(value) ? value : undefined;
} catch {
return undefined;
}
}
function Content({ children }: { children?: ReactNode }) {
const route = useRoute();
const list = useObject(
ArtifactList,
route.session ? { session: route.session, conversation: route.conversation ?? 1 } : undefined,
);
const artifacts = useReadable(list?.artifacts) ?? [];
const top = route.layers.at(-1);
const item = viewer(top?.kind === "viewer" && top.name === "artifacts" ? top.locator : undefined);
const open = (item: ViewerItem): void =>
openLayer({ kind: "viewer", name: "artifacts", locator: `viewer:${JSON.stringify(item)}` });
return (
<SessionId.Provider value={route.session}>
<OpenViewer.Provider value={open}>
{children}
{top?.kind === "sheet" && top.name === "artifacts" && (
<ArtifactPicker
artifacts={artifacts.toReversed()}
onOpen={(artifact) => open({ kind: "artifact", artifact })}
onClose={closeLayers}
/>
)}
{route.session &&
item &&
(item.kind === "artifact" ? (
<ArtifactPanel
key={top?.kind !== "drawer" ? top?.locator : undefined}
sessionId={route.session}
artifact={item.artifact}
onClose={closeLayers}
/>
) : (
<FilePanel
key={top?.kind !== "drawer" ? top?.locator : undefined}
sessionId={route.session}
file={item.file}
onClose={closeLayers}
/>
))}
</OpenViewer.Provider>
</SessionId.Provider>
);
}
function ArtifactButton() {
const route = useRoute();
const list = useObject(
ArtifactList,
route.session ? { session: route.session, conversation: route.conversation ?? 1 } : undefined,
);
const artifacts = useReadable(list?.artifacts) ?? [];
const count = artifacts.length;
if (!count) return null;
return (
<button
type="button"
className="artifact-button"
aria-label={`Artifacts (${count})`}
onClick={() => openLayer({ kind: "sheet", name: "artifacts" })}
>
<ArtifactIcon />
<span>{count}</span>
</button>
);
}
export default defineFacet((host) => {
contribute(host, "p2.web-artifacts", (self) => {
self.contribute(Slots, { "chat-content": Content, "artifact-actions": ArtifactButton });
});
});