The artifacts/ directory matched a repo-wide ignore rule and was left out of the UI card commit; its suites only passed locally because the files existed on disk. Force-track the complete card so the source push carries every module the tests import. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
222 lines
10 KiB
TypeScript
222 lines
10 KiB
TypeScript
/** Accessible HUX-04 artifact UI. This module never fetches or executes content. */
|
|
|
|
import {
|
|
Code2,
|
|
Download,
|
|
FileText,
|
|
GitCompare,
|
|
History,
|
|
Image as ImageIcon,
|
|
Pencil,
|
|
Share2,
|
|
Sparkles,
|
|
} from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
|
|
import { artifactsEnabled, normalizeArtifactWorkspace, normalizePreview } from "./model.ts";
|
|
import type {
|
|
Artifact,
|
|
ArtifactAuthorization,
|
|
ArtifactMutationIntent,
|
|
ArtifactPreview,
|
|
ArtifactScope,
|
|
DiffIntent,
|
|
HuxIdentity,
|
|
PromoteIntent,
|
|
RawArtifactEnvelope,
|
|
RawPreviewPayload,
|
|
} from "./types.ts";
|
|
|
|
interface ArtifactWorkspaceProps {
|
|
flags?: Iterable<string>;
|
|
identity: HuxIdentity;
|
|
scope: ArtifactScope;
|
|
page: RawArtifactEnvelope;
|
|
previews?: readonly RawPreviewPayload[];
|
|
busy?: boolean;
|
|
error?: string | null;
|
|
onLoadPreview?: (intent: ArtifactMutationIntent & {version: number}) => void;
|
|
onContinueEdit?: (intent: ArtifactMutationIntent) => void;
|
|
onDiff?: (intent: DiffIntent) => void;
|
|
onAuthorize?: (action: "download" | "share", intent: ArtifactMutationIntent) => void;
|
|
onDownload?: (intent: ArtifactMutationIntent & {version: number}) => void;
|
|
onShare?: (intent: ArtifactMutationIntent & {version: number}) => void;
|
|
onPromote?: (intent: PromoteIntent) => void;
|
|
}
|
|
|
|
function intent(artifact: Artifact, scope: ArtifactScope): ArtifactMutationIntent {
|
|
return {artifactId: artifact.id, expectedVersion: artifact.currentVersion, scope};
|
|
}
|
|
|
|
function RendererIcon({artifact}: {artifact: Artifact}) {
|
|
if (artifact.renderer === "image") return <ImageIcon aria-hidden />;
|
|
if (artifact.renderer === "code" || artifact.renderer === "data") return <Code2 aria-hidden />;
|
|
return <FileText aria-hidden />;
|
|
}
|
|
|
|
function Preview({artifact, preview}: {artifact: Artifact; preview: ArtifactPreview | null}) {
|
|
if (!preview) return <div className="hux-artifact-preview-empty">
|
|
<RendererIcon artifact={artifact} />
|
|
<p>Preview not loaded. Content stays behind its verified content reference.</p>
|
|
</div>;
|
|
if (preview.renderer === "image") {
|
|
return <figure className="hux-artifact-image">
|
|
<img src={preview.blobUrl!} alt={`Preview of ${artifact.title}`} />
|
|
<figcaption>{preview.mime}; local authorized preview</figcaption>
|
|
</figure>;
|
|
}
|
|
if (preview.renderer === "audio") {
|
|
return <div className="hux-artifact-audio">
|
|
<audio src={preview.blobUrl!} controls preload="metadata">
|
|
Audio preview is unavailable in this browser.
|
|
</audio>
|
|
<span>{preview.mime}; local authorized preview</span>
|
|
</div>;
|
|
}
|
|
const className = `hux-artifact-${preview.renderer}`;
|
|
return <div className={className} aria-label={`${preview.renderer} preview`}>
|
|
{preview.renderer === "report" && <h3>Report preview</h3>}
|
|
<pre tabIndex={0}><code>{preview.text}</code></pre>
|
|
</div>;
|
|
}
|
|
|
|
function AuthorizationAction({action, state, busy, onAuthorize, onAction}: {
|
|
action: "download" | "share";
|
|
state: ArtifactAuthorization["download"];
|
|
busy: boolean;
|
|
onAuthorize: () => void;
|
|
onAction: () => void;
|
|
}) {
|
|
const label = action === "download" ? "Download" : "Share";
|
|
const Icon = action === "download" ? Download : Share2;
|
|
if (state === "authorized") return <button type="button" disabled={busy} onClick={onAction}>
|
|
<Icon aria-hidden /> {label}
|
|
</button>;
|
|
if (state === "requires_approval") return <button type="button" disabled={busy} onClick={onAuthorize}>
|
|
<Icon aria-hidden /> Request {label.toLowerCase()} approval
|
|
</button>;
|
|
return <button type="button" disabled aria-label={`${label} unavailable`}>
|
|
<Icon aria-hidden /> {label} unavailable
|
|
</button>;
|
|
}
|
|
|
|
function VersionHistory({artifact, selectedVersion, setSelectedVersion, busy, scope, onDiff}: {
|
|
artifact: Artifact;
|
|
selectedVersion: number;
|
|
setSelectedVersion: (version: number) => void;
|
|
busy: boolean;
|
|
scope: ArtifactScope;
|
|
onDiff?: ArtifactWorkspaceProps["onDiff"];
|
|
}) {
|
|
return <section className="hux-artifact-history" aria-labelledby="hux-artifact-history-title">
|
|
<h3 id="hux-artifact-history-title"><History aria-hidden /> Immutable version history</h3>
|
|
<ol>{[...artifact.versions].reverse().map((version) => <li key={version.version}
|
|
data-current={version.version === artifact.currentVersion}>
|
|
<button type="button" aria-pressed={selectedVersion === version.version}
|
|
onClick={() => setSelectedVersion(version.version)}>
|
|
<strong>Version {version.version}</strong>
|
|
<span>{version.note || `Created by ${version.createdBy}`}</span>
|
|
<time dateTime={version.createdAt}>{new Date(version.createdAt).toLocaleString()}</time>
|
|
</button>
|
|
{version.version > 1 && onDiff && <button className="hux-artifact-diff" type="button"
|
|
disabled={busy} onClick={() => onDiff({
|
|
...intent(artifact, scope), fromVersion: version.diffFrom || version.version - 1,
|
|
toVersion: version.version,
|
|
})} aria-label={`Compare version ${version.version}`}><GitCompare aria-hidden /> Compare</button>}
|
|
</li>)}</ol>
|
|
</section>;
|
|
}
|
|
|
|
/** Render nothing unless all negotiated HUX-04 dependencies are active. */
|
|
export function ArtifactWorkspace(props: ArtifactWorkspaceProps) {
|
|
const enabled = artifactsEnabled(props.flags);
|
|
const page = useMemo(() => enabled ?
|
|
normalizeArtifactWorkspace(props.page, props.identity, props.scope) : null,
|
|
[enabled, props.identity, props.page, props.scope]);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
|
|
if (!enabled || !page) return null;
|
|
const artifact = page.artifacts.find((item) => item.id === selectedId) || page.artifacts[0] || null;
|
|
const version = artifact ? Math.min(selectedVersion || artifact.currentVersion, artifact.currentVersion) : null;
|
|
const rawPreview = artifact && version ? props.previews?.find((item) =>
|
|
item.artifact_id === artifact.id && item.version === version) : undefined;
|
|
const preview = artifact && rawPreview ?
|
|
normalizePreview(rawPreview, artifact, props.identity, props.scope) : null;
|
|
const attachments = artifact ? page.attachments.find((item) => item.artifactId === artifact.id) : null;
|
|
const authorization = artifact ? page.authorizations.find((item) => item.artifactId === artifact.id) : null;
|
|
|
|
function selectArtifact(item: Artifact) {
|
|
setSelectedId(item.id);
|
|
setSelectedVersion(item.currentVersion);
|
|
props.onLoadPreview?.({...intent(item, props.scope), version: item.currentVersion});
|
|
}
|
|
|
|
function selectVersion(next: number) {
|
|
if (!artifact) return;
|
|
setSelectedVersion(next);
|
|
props.onLoadPreview?.({...intent(artifact, props.scope), version: next});
|
|
}
|
|
|
|
return <section className="hux-artifacts" aria-label="Artifact workspace" aria-busy={props.busy}>
|
|
<nav className="hux-artifact-list" aria-label="Artifacts">
|
|
<header><h2><Sparkles aria-hidden /> Artifacts</h2>
|
|
<p>Durable outputs from this conversation.</p></header>
|
|
<ul>{page.artifacts.map((item) => <li key={item.id}>
|
|
<button type="button" aria-current={artifact?.id === item.id ? "page" : undefined}
|
|
onClick={() => selectArtifact(item)}>
|
|
<RendererIcon artifact={item} />
|
|
<span><strong>{item.title}</strong><small>{item.renderer} · v{item.currentVersion}</small></span>
|
|
</button>
|
|
</li>)}</ul>
|
|
{!page.artifacts.length && <p className="hux-artifact-empty">No artifacts are attached.</p>}
|
|
</nav>
|
|
|
|
<main className="hux-artifact-main">
|
|
{props.error && <p className="hux-artifact-error" role="alert">{props.error}</p>}
|
|
{page.rejected > 0 && <p className="hux-artifact-warning" role="status">
|
|
Some artifacts were withheld because their owner or workspace binding was invalid.
|
|
</p>}
|
|
{artifact && version ? <>
|
|
<header className="hux-artifact-heading">
|
|
<div><span>{artifact.renderer}</span><h2>{artifact.title}</h2>
|
|
<p>Version {version} · {artifact.sensitivity} · {artifact.language || artifact.type}</p></div>
|
|
<div className="hux-artifact-actions">
|
|
<button type="button" disabled={props.busy}
|
|
onClick={() => props.onContinueEdit?.(intent(artifact, props.scope))}>
|
|
<Pencil aria-hidden /> Continue editing
|
|
</button>
|
|
<AuthorizationAction action="download" state={authorization?.download || "unavailable"}
|
|
busy={Boolean(props.busy)}
|
|
onAuthorize={() => props.onAuthorize?.("download", intent(artifact, props.scope))}
|
|
onAction={() => props.onDownload?.({...intent(artifact, props.scope), version})} />
|
|
<AuthorizationAction action="share" state={authorization?.share || "unavailable"}
|
|
busy={Boolean(props.busy)}
|
|
onAuthorize={() => props.onAuthorize?.("share", intent(artifact, props.scope))}
|
|
onAction={() => props.onShare?.({...intent(artifact, props.scope), version})} />
|
|
</div>
|
|
</header>
|
|
<Preview artifact={artifact} preview={preview} />
|
|
<section className="hux-artifact-evidence" aria-label="Attached research evidence">
|
|
<div><strong>{attachments?.sourceCount || 0}</strong><span>Sources attached</span></div>
|
|
<div><strong>{attachments?.citationCount || 0}</strong><span>Citations attached</span></div>
|
|
</section>
|
|
<button className="hux-artifact-promote" type="button" disabled={props.busy ||
|
|
artifact.promotedVersion === version} onClick={() => props.onPromote?.({
|
|
...intent(artifact, props.scope), targetProjectId: props.scope.projectId, version,
|
|
})}>
|
|
{artifact.promotedVersion === version ? "Promoted to this project" : "Promote version to project"}
|
|
</button>
|
|
</> : <p className="hux-artifact-empty">Select an artifact to inspect it.</p>}
|
|
</main>
|
|
|
|
<aside className="hux-artifact-side" aria-label="Artifact versions">
|
|
{artifact && version ? <VersionHistory artifact={artifact} selectedVersion={version}
|
|
setSelectedVersion={selectVersion} busy={Boolean(props.busy)} scope={props.scope}
|
|
onDiff={props.onDiff} /> : <p>Version history will appear here.</p>}
|
|
</aside>
|
|
<p className="sr-only" role="status" aria-live="polite">
|
|
{props.busy ? "Artifact workspace is updating" : `${page.artifacts.length} artifacts available`}
|
|
</p>
|
|
</section>;
|
|
}
|