import { t } from "../lib/console-text"; import { ArtifactSessionPage } from "../components/session/artifact-session-page"; import type { ToolGatewayIdentity, WorkspaceArtifactContentResponse, WorkspaceArtifactDetailResponse, } from "@opengeni/sdk"; import { ArtifactSandbox, SiteView, artifactLoadErrorMessage, artifactLoadErrorView, type PublishedHtmlArtifactToolBridge, } from "../../artifacts"; import { loadSiteSnapshot } from "../../sites"; import { SiteConversations } from "../components/artifacts/site-conversations"; import { SiteData } from "../components/artifacts/site-data"; import { SitePublishButton, sitePublicationHeader } from "../components/artifacts/site-publishing"; import { SiteSettings, hasSiteSettings, useSiteManagement, } from "../components/artifacts/site-settings"; import { Link, useNavigate, useSearch } from "../lib/navigation"; import { ArchiveIcon, ArchiveRestoreIcon, PanelsTopLeftIcon, PlusIcon, RotateCcwIcon, SparklesIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "../lib/toast"; import { ArtifactLibrary } from "../components/artifacts/artifact-library"; import { ARTIFACT_DETAIL_FRAME, ArtifactKindTile, useArtifactsBackLink, useArtifactFullView, } from "../components/artifacts/artifact-page-chrome"; import { artifactKinds, type ArtifactKind } from "../lib/artifact-catalog"; import { invalidateArtifactCatalog, useArtifactCatalog } from "../lib/use-artifact-catalog"; import { useArtifactCatalogMutationInvalidation } from "../lib/use-artifact-catalog-mutation-invalidation"; import { Button } from "../../ui/button"; import { ContentPage } from "../../ui/content-layout"; import { DetailPage, DetailPageBody, DetailPageHeader } from "../../ui/detail-page"; import { DetailSection } from "../../ui/detail-sheet"; import { LineTabs, LineTabsContent, LineTabsList, LineTabsTrigger } from "../../ui/line-tabs"; import { ListRow, RowList } from "../../ui/list-row"; import { MetaChip } from "../../ui/meta-chip"; import { Notice } from "../../ui/notice"; import { RowButton } from "../../ui/page-actions"; import { PageHeader } from "../../ui/page-header"; import { RelativeTime } from "../../ui/relative-time"; import { Skeleton } from "../../ui/skeleton"; import { StatusBadge } from "../../ui/status-badge"; import { useAppContext } from "../app-context"; import { ARTIFACT_ACCESS_REQUIRED, isArtifactReadDenied } from "../lib/artifact-access"; import { useAppApi } from "../lib/app-api"; import { createSiteToolBridge } from "../lib/site-tool-bridge"; import { useConsoleSiteToolBridge, useConsoleSiteViewer } from "../lib/console-site"; import { readyNewChat } from "../lib/ready-new-chat"; import { userErrorText } from "../../ui/api-error"; import { newArtifactPrompt, siteEditPrompt } from "../lib/site-edit-prompt"; import { hasWorkspacePermission, lacksWorkspacePermission } from "../lib/permissions"; import { artifactLibraryFilters, artifactLibraryPositionKey, artifactLibrarySearch, readArtifactLibraryPosition, useArtifactLibraryPosition, } from "../lib/artifact-library-navigation"; import type { ArtifactCatalogFilters } from "../lib/artifact-catalog"; import type { ArtifactReturnSearch, SiteDetailTab } from "../lib/routes"; const NO_SITE_TOOLS: readonly ToolGatewayIdentity[] = []; function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function SiteLoadError({ error, accessDenied, onRetry, }: { error: unknown; accessDenied: boolean; onRetry: () => void; }) { if (accessDenied) return {ARTIFACT_ACCESS_REQUIRED}; const view = artifactLoadErrorView(t, error, "site"); return ( ); } export function ArtifactsRoute({ workspaceId, artifactId, fromSession, }: { workspaceId: string; artifactId?: string | undefined; fromSession?: string | undefined; }) { return artifactId ? ( ) : ( ); } function ArtifactListRoute({ workspaceId, fromSession, }: { workspaceId: string; fromSession?: string | undefined; }) { const context = useAppContext(); const navigate = useNavigate(); const search = useSearch({ strict: false }); const filters = artifactLibraryFilters(search); const setFilters = (next: ArtifactCatalogFilters) => { void navigate({ to: "/workspaces/$workspaceId/artifacts", params: { workspaceId }, search: artifactLibrarySearch(next, fromSession), replace: true, resetScroll: false, }); }; const positionKey = artifactLibraryPositionKey(workspaceId, context.accessKeyVersion, filters); // Freeze the restore count for this query; loading another page must not // recreate the catalog's request lifecycle. const retained = useRef({ key: positionKey, client: context.client, pages: readArtifactLibraryPosition(context.client, positionKey).pages, }); if (retained.current.key !== positionKey || retained.current.client !== context.client) retained.current = { key: positionKey, client: context.client, pages: readArtifactLibraryPosition(context.client, positionKey).pages, }; const [empty, setEmpty] = useState(false); const catalog = useArtifactCatalog( context.client, workspaceId, filters, context.accessKeyVersion, retained.current.pages, ); const position = useArtifactLibraryPosition( context.client, positionKey, // The viewer may have repopulated an invalidated cache with only its first // page. Do not consume scroll restoration before the retained pages reload. catalog.loading || Boolean(catalog.error) || Boolean(catalog.nextCursor && catalog.pages < retained.current.pages), catalog.items.length, catalog.pages, ); const invalidateAfterMutation = useArtifactCatalogMutationInvalidation( context.client, workspaceId, invalidateArtifactCatalog, ); const canCreate = hasWorkspacePermission(context.accessContext, workspaceId, "sessions:create"); const canPin = hasWorkspacePermission(context.accessContext, workspaceId, "artifacts:publish"); // The catalog quietly omits Sites and editable artifacts without `artifacts:read`. const artifactKindsHidden = lacksWorkspacePermission( context.accessContext, workspaceId, "artifacts:read", ); // Readies a new chat in the composer; the person says what to make and sends it. const [preparingNew, setPreparingNew] = useState(false); const startSession = async () => { if (preparingNew) return; setPreparingNew(true); try { await readyNewChat(context.client, workspaceId, newArtifactPrompt(filters.kind)); await navigate({ to: "/workspaces/$workspaceId/sessions", params: { workspaceId } }); } catch (nextError) { toast.error("Couldn't open a new chat", { description: userErrorText(nextError) }); } finally { setPreparingNew(false); } }; const newArtifact = canCreate ? ( ) : null; return ( setFilters({ ...filters, kind: kind as ArtifactKind | "all" })} className="min-w-0" > } title="Artifacts" description="Sites, images, documents and files made with Opengeni" actions={empty && filters.kind === "all" ? null : newArtifact} tabs={ {artifactKinds.map(([kind, label]) => ( {label} ))} } /> { await context.client.updateArtifactPin(workspaceId, item.kind, item.id, pinned); invalidateAfterMutation(); await catalog.refresh(); } : undefined } /> ); } type SiteTab = "site" | SiteDetailTab; export function ArtifactDetailRoute({ embedded = false, embeddedView = "site", ...props }: { workspaceId: string; artifactId: string; fromSession?: string | undefined; embedded?: boolean | undefined; /** In the session workbench: the Site itself, or its Settings. */ embeddedView?: "site" | "settings" | undefined; /** The workbench header's reload and full screen, which replace the frame toolbar. */ embeddedControls?: EmbeddedSiteControls | undefined; }) { const { embeddedControls, ...route } = props; if (!embedded) return ; return embeddedView === "settings" ? ( ) : ( ); } export type EmbeddedSiteControls = { reloadToken: number; fullScreen: boolean; onFullScreenChange: (fullScreen: boolean) => void; }; /** * The session dock's Site Settings: the Site page's Settings tab, so the Site * can be published, shared or archived without leaving the session. */ function EmbeddedSiteSettings({ workspaceId, artifactId, }: { workspaceId: string; artifactId: string; }) { const context = useAppContext(); const [artifact, setArtifact] = useState( null, ); const [error, setError] = useState(null); const readAbort = useRef(null); const load = useCallback(async () => { readAbort.current?.abort(); const abort = new AbortController(); readAbort.current = abort; try { setError(null); const detail = await context.client.getWorkspaceArtifact(workspaceId, artifactId, { signal: abort.signal, }); if (!abort.signal.aborted) setArtifact(detail.artifact); } catch (nextError) { if (abort.signal.aborted) return; setArtifact(null); setError(nextError); } }, [artifactId, workspaceId, context.client]); useEffect(() => { setArtifact(null); void load(); return () => readAbort.current?.abort(); }, [load]); const management = useSiteManagement({ workspaceId, siteId: artifactId, artifact, onArtifactChanged: setArtifact, onStatusChanged: load, }); return (
{error ? ( void load()} /> ) : !artifact ? (
Loading Site settings
) : ( )}
{management.showAudience ? management.audience.dialog : null}
); } /** The session dock's Site view: the shared SiteView with console tool access. */ function EmbeddedSiteDetail({ workspaceId, artifactId, controls, }: { workspaceId: string; artifactId: string; controls?: EmbeddedSiteControls | undefined; }) { const { client } = useAppContext(); const viewer = useConsoleSiteViewer(); const toolBridge = useConsoleSiteToolBridge(workspaceId); return ( ); } function ArtifactDetailPage({ workspaceId, artifactId, fromSession, }: { workspaceId: string; artifactId: string; fromSession?: string | undefined; }) { const context = useAppContext(); const api = useAppApi(); const canPublish = hasWorkspacePermission( context.accessContext, workspaceId, "artifacts:publish", ); const navigate = useNavigate(); const back = useArtifactsBackLink(workspaceId, fromSession); const search = useSearch({ strict: false }) as ArtifactReturnSearch; const [fullView, setFullView] = useArtifactFullView(); const viewer = useConsoleSiteViewer(); // The tab is in the address, so Settings (from the workbench or the old // Publish link) and the other tabs can be linked to and survive a reload. const tab: SiteTab = search.view === "publish" ? "settings" : (search.tab ?? "site"); const setTab = (next: SiteTab) => void navigate({ to: "/workspaces/$workspaceId/artifacts/$artifactId", params: { workspaceId, artifactId }, search: { ...search, view: undefined, browse: true, tab: next === "site" ? undefined : next, }, replace: true, resetScroll: false, }); const [detail, setDetail] = useState(null); const [content, setContent] = useState | null>(null); const [error, setError] = useState(null); const [busyVersion, setBusyVersion] = useState(null); const readAbort = useRef(null); const load = useCallback(async () => { readAbort.current?.abort(); const abort = new AbortController(); readAbort.current = abort; try { setError(null); const snapshot = await loadSiteSnapshot(context.client, workspaceId, artifactId, { signal: abort.signal, includeArchivedContent: true, }); if (abort.signal.aborted) return; setDetail(snapshot.detail); setContent(snapshot.content); } catch (nextError) { if (abort.signal.aborted) return; setDetail(null); setContent(null); setError(nextError); } }, [artifactId, workspaceId, context.client]); useEffect(() => { setDetail(null); setContent(null); void load(); return () => readAbort.current?.abort(); }, [load]); const requestedTools = content?.requestedTools ?? NO_SITE_TOOLS; const siteVersionId = content?.versionId; const siteToolBridge = useMemo(() => { if (!siteVersionId) return undefined; return createSiteToolBridge(api, { workspaceTools: context.client.tools.forWorkspace(workspaceId), workspaceId, artifactId, siteVersionId, requestedTools, }); }, [api, artifactId, context.client, requestedTools, siteVersionId, workspaceId]); // Edit with Opengeni readies a new chat about the Site without sending it, // so the person says what to change instead of waiting to be asked. const [preparingEdit, setPreparingEdit] = useState(false); const startEditSession = async () => { if (!canPublish || !detail || detail.artifact.status === "archived" || preparingEdit) return; setPreparingEdit(true); try { await readyNewChat(context.client, workspaceId, siteEditPrompt(workspaceId, detail.artifact)); await navigate({ to: "/workspaces/$workspaceId/sessions", params: { workspaceId } }); } catch (nextError) { toast.error("Couldn't open a new chat", { description: userErrorText(nextError) }); } finally { setPreparingEdit(false); } }; const rollback = async (versionId: string) => { const current = detail?.artifact.currentVersion; if ( !canPublish || !current || current.id === versionId || detail?.artifact.status === "archived" ) return; setBusyVersion(versionId); try { await context.client.rollbackWorkspaceArtifact(workspaceId, artifactId, { versionId, expectedCurrentVersionId: current.id, reason: `Restored from the artifact history by ${context.authSession?.user?.name ?? "a workspace member"}`, idempotencyKey: crypto.randomUUID(), }); invalidateArtifactCatalog(context.client, workspaceId); toast.success("Artifact version restored"); await load(); } catch (nextError) { toast.error("Couldn't restore version", { description: nextError instanceof Error ? nextError.message : String(nextError), }); } finally { setBusyVersion(null); } }; const archived = detail?.artifact.status === "archived"; const management = useSiteManagement({ workspaceId, siteId: artifactId, artifact: detail?.artifact ?? null, onArtifactChanged: (artifact) => setDetail((current) => (current ? { ...current, artifact } : current)), onStatusChanged: load, }); const { manageData, publication, audience, showAudience, statusBusy, setSiteStatus } = management; if (error) { return ( void load()} /> ); } // Content is null once loaded when the Site has no version this person can // open; that is a final state, not a reason to keep showing the skeleton. if (!detail) { return (
Loading Site
); } const artifact = detail.artifact; const toolCount = content?.requestedTools.length ?? 0; const publicationHeader = sitePublicationHeader(publication.load); const showSettings = hasSiteSettings(management); const shownTab: SiteTab = (tab === "settings" && !showSettings) || (tab === "data" && !manageData) ? "site" : tab; return ( setTab(value as SiteTab)}> } title={artifact.title} chips={ showAudience || archived || publicationHeader.chip ? ( <> {showAudience ? audience.chip : null} {publicationHeader.chip} {archived ? ( Archived ) : null} ) : null } meta={[ artifact.currentVersion ? `Version ${artifact.currentVersion.revision}` : null, publicationHeader.meta, toolCount > 0 ? `Uses ${toolCount} ${toolCount === 1 ? "tool" : "tools"}` : null, , ]} actions={ canPublish ? ( <> {showAudience ? audience.shareButton : null} {manageData && !archived ? ( setTab("settings")} /> ) : null} {archived ? ( ) : ( )} ) : null } tabs={ Site Versions Conversations {manageData ? Data : null} {showSettings ? Settings : null} } />
{artifact.description ? (

{artifact.description}

) : null} {archived ? ( } title="This Site is archived"> It's unpublished. Its source and versions are kept: restore it to edit it or bring back an earlier version. ) : null} {content ? ( void startEditSession() : undefined} toolBridge={archived ? undefined : siteToolBridge} viewer={viewer} connectedToolCount={toolCount} // Links open the Site full screen; an archived one opens on // its page, which says so, and expands only on request. fullScreen={archived ? undefined : fullView} onFullScreenChange={archived ? undefined : setFullView} /> ) : ( void load()}> )}
{detail.versions.map((version) => { const current = artifact.currentVersion?.id === version.id; return ( Current : null} meta={[ , formatSize(version.sizeBytes), version.sourceSessionId ? ( {version.revision === 1 ? "Creation session" : "Publishing session"} ) : null, ].filter(Boolean)} control={ !current && canPublish ? ( void rollback(version.id)} > ) : undefined } /> ); })} {manageData ? ( ) : null} {showSettings ? ( ) : null}
{showAudience ? audience.dialog : null}
); }