import { SITE_ID_HEADER, SITE_VERSION_HEADER } from "@opengeni/contracts/site-session-http"; import type { Session, SessionListResponse } from "@opengeni/sdk"; import { OpenGeniApiError } from "@opengeni/sdk/browser"; import { InfoIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react"; import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { SiteView } from "../../artifacts"; import { NewConversation } from "../../components/new-conversation"; import type { CreatedConversation, NewConversationCreateOptions, } from "../../hooks/use-new-conversation"; import { SessionConversation } from "../../components/session-conversation"; import { Button } from "../../ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "../../ui/dropdown-menu"; import { Skeleton } from "../../ui/skeleton"; import { useAppContext } from "../app-context"; import { useAppApi } from "../lib/app-api"; import { useConsoleSiteToolBridge, useConsoleSiteViewer } from "../lib/console-site"; import { t } from "../lib/console-text"; import { formatTimestamp } from "../lib/format"; import { Link, useNavigate } from "../lib/navigation"; import { hasWorkspacePermission } from "../lib/permissions"; import { sessionDisplayTitle } from "../lib/session-rename"; import { sessionStateLabel } from "../lib/session-rail"; import { sessionSiteOrigin } from "../lib/session-site-origin"; import { isRunningStatus, relativeTimeLabel } from "../lib/sessions-group"; import { useRecordSiteOpened } from "../lib/site-opened"; import { useSessionListHintRefresh } from "../lib/use-session-list-hint-refresh"; import { cn } from "../lib/utils"; const SITE_ROUTE = "/workspaces/$workspaceId/sites/$siteId"; type SiteState = | { status: "loading" } | { status: "error" } | { status: "ready"; title: string; versionId: string | null; archived: boolean }; /** * A Site with its conversations beside it. The Site fills the page; the * panel keeps one conversation open (the newest when the Site opens, unless * one is chosen), lists the others to switch between, and starts a new one. */ export function SiteViewRoute({ workspaceId, siteId, conversation, }: { workspaceId: string; siteId: string; /** The conversation open in the panel; the newest when omitted. */ conversation?: string | undefined; }) { const { client } = useAppContext(); const viewer = useConsoleSiteViewer(); const toolBridge = useConsoleSiteToolBridge(workspaceId); const [site, setSite] = useState({ status: "loading" }); const [siteAttempt, setSiteAttempt] = useState(0); useRecordSiteOpened(client, workspaceId, siteId); useEffect(() => { const abort = new AbortController(); setSite({ status: "loading" }); client .getWorkspaceArtifact(workspaceId, siteId, { signal: abort.signal }) .then(({ artifact }) => { if (abort.signal.aborted) return; setSite({ status: "ready", title: artifact.title, versionId: artifact.currentVersion?.id ?? null, archived: artifact.status === "archived", }); }) .catch(() => { if (!abort.signal.aborted) setSite({ status: "error" }); }); return () => abort.abort(); }, [client, siteAttempt, siteId, workspaceId]); return (
setSiteAttempt((attempt) => attempt + 1)} conversation={conversation} />
); } const PAGE_SIZE = 30; /** The most a session list page returns. */ const MAX_PAGE_SIZE = 100; /** The Site's own conversations for this viewer, newest activity first. */ function useSiteConversations(workspaceId: string, siteId: string) { const { client } = useAppContext(); const [page, setPage] = useState(null); const [error, setError] = useState(null); const [loadingMore, setLoadingMore] = useState(false); const request = useRef(null); const more = useRef(null); // A refresh re-reads as many as are loaded, so "Show older" survives updates. const loadedCount = useRef(0); useEffect(() => { loadedCount.current = page?.sessions.length ?? 0; }, [page]); const read = useCallback( (signal: AbortSignal, cursor?: string, limit = PAGE_SIZE) => client.listSessionPage(workspaceId, { originSiteId: siteId, parentSessionId: null, sortBy: "updatedAt", limit, signal, ...(cursor ? { cursor } : {}), }), [client, siteId, workspaceId], ); const refresh = useCallback(async () => { request.current?.abort(); const abort = new AbortController(); request.current = abort; try { const next = await read( abort.signal, undefined, Math.min(MAX_PAGE_SIZE, Math.max(PAGE_SIZE, loadedCount.current)), ); if (abort.signal.aborted) return; setPage(next); setError(null); } catch (failure) { if (!abort.signal.aborted) setError(failure instanceof Error ? failure : new Error(String(failure))); } }, [read]); useEffect(() => { void refresh(); return () => { request.current?.abort(); more.current?.abort(); }; }, [refresh]); // This list shows only Site conversations, which ordinary hints never name. useSessionListHintRefresh(refresh, error, { siteConversations: true }); const loadMore = useCallback(async () => { const cursor = page?.nextCursor; if (!cursor || loadingMore) return; const abort = new AbortController(); more.current = abort; setLoadingMore(true); try { const next = await read(abort.signal, cursor); if (abort.signal.aborted) return; setPage((current) => // Only extend the page this cursor came from; a refresh since replaced it. current?.nextCursor === cursor ? { ...next, sessions: [ ...new Map( [...current.sessions, ...next.sessions].map((session) => [session.id, session]), ).values(), ], } : current, ); } catch { // The button stays, so trying again is one click. } finally { if (more.current === abort) more.current = null; setLoadingMore(false); } }, [loadingMore, page?.nextCursor, read]); const sessions = useMemo( () => page ? [...new Map([...page.pinned, ...page.sessions].map((session) => [session.id, session]))] .map(([, session]) => session) .filter((session) => !session.archived) .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)) : [], [page], ); return { sessions, loaded: page !== null, error, nextCursor: page?.nextCursor ?? null, loadingMore, refresh, loadMore, }; } function SiteConversationsColumn({ workspaceId, siteId, site, retrySite, conversation, }: { workspaceId: string; siteId: string; site: SiteState; retrySite: () => void; conversation?: string | undefined; }) { const context = useAppContext(); const api = useAppApi(); const navigate = useNavigate(); const headingId = useId(); const panel = useRef(null); const list = useSiteConversations(workspaceId, siteId); const [composing, setComposing] = useState(false); const [handoff, setHandoff] = useState(null); const [focusRequest, setFocusRequest] = useState(0); // The conversation open without a choice is fixed when the Site opens, so // activity elsewhere never swaps it out from under the person reading it. const [defaultId, setDefaultId] = useState(undefined); const newest = list.sessions[0]?.id ?? null; const pinned = defaultId === null || (defaultId !== undefined && list.sessions.some((session) => session.id === defaultId)) ? defaultId : newest; useEffect(() => { if (list.loaded && pinned !== defaultId) setDefaultId(pinned); }, [defaultId, list.loaded, pinned]); // Choosing a conversation anywhere (the rail, a link) ends composing. // Returning to the Site itself, as its rail row does, opens its newest. const previousConversation = useRef(conversation); const startingNew = useRef(false); useEffect(() => { const previous = previousConversation.current; previousConversation.current = conversation; if (conversation) setComposing(false); else if (previous && !startingNew.current) setDefaultId(undefined); startingNew.current = false; }, [conversation]); const toSite = useCallback( (search: { conversation?: string }, replace = false) => void navigate({ to: SITE_ROUTE, params: { workspaceId, siteId }, search, ...(replace ? { replace: true } : {}), }), [navigate, siteId, workspaceId], ); // A link may name a conversation that isn't this Site's, or one since // archived or deleted; fall back to the Site's own. const listed = !conversation || list.sessions.some((session) => session.id === conversation); // Through a ref: a host's navigate may change identity on every render. const fallBack = useRef(() => {}); useEffect(() => { fallBack.current = () => toSite({}, true); }, [toSite]); useEffect(() => { if (!conversation || !list.loaded || listed) return; const abort = new AbortController(); context.client.getSession(workspaceId, conversation, { signal: abort.signal }).then( (session) => { if (abort.signal.aborted) return; if ( sessionSiteOrigin(session)?.siteId !== siteId || session.parentSessionId !== null || session.archived ) fallBack.current(); }, (failure: unknown) => { if ( !abort.signal.aborted && failure instanceof OpenGeniApiError && [403, 404].includes(failure.status) ) fallBack.current(); }, ); return () => abort.abort(); }, [context.client, conversation, list.loaded, listed, siteId, workspaceId]); // Move focus to the composer New opened, or to the new conversation's. useEffect(() => { if (focusRequest === 0) return; let frame = 0; let tries = 0; const attempt = () => { const input = panel.current?.querySelector("textarea:not([disabled])"); if (input) input.focus(); else if ((tries += 1) < 60) frame = requestAnimationFrame(attempt); }; frame = requestAnimationFrame(attempt); return () => cancelAnimationFrame(frame); }, [focusRequest]); const ready = site.status === "ready" ? site : null; const canCreate = hasWorkspacePermission(context.accessContext, workspaceId, "sessions:create") && ready !== null && ready.versionId !== null && !ready.archived; const selectedId = composing ? null : (conversation ?? pinned); const showComposer = composing || (list.loaded && selectedId === null); const startNew = () => { setComposing(true); setFocusRequest((request) => request + 1); // Nothing is selected while composing, so the rail marks the Site. if (conversation) { startingNew.current = true; toSite({}); } }; // Created like the Site's own chats: private to this person and listed // under the Site. The API refuses anything else a Site may not set. const versionId = ready?.versionId ?? null; const createSession = useCallback( async ( initialMessage: string, idempotencyKey: string, options: NewConversationCreateOptions, ): Promise => { if (!versionId) throw new Error("This Site has no published version yet."); const created = await api.request>( `/v1/workspaces/${encodeURIComponent(workspaceId)}/sessions`, { method: "POST", headers: { [SITE_ID_HEADER]: siteId, [SITE_VERSION_HEADER]: versionId }, body: JSON.stringify({ initialMessage, idempotencyKey, ...(options.model ? { model: options.model } : {}), ...(options.reasoningEffort ? { reasoningEffort: options.reasoningEffort } : {}), ...(options.latencyMode ? { latencyMode: options.latencyMode } : {}), }), }, ); return created.id; }, [api, siteId, versionId, workspaceId], ); const conversations = list.sessions; const handedOff = handoff !== null && handoff.sessionId === selectedId ? handoff : null; // The handoff is for the conversation's first open only. useEffect(() => { if (handoff && selectedId !== null && selectedId !== handoff.sessionId) setHandoff(null); }, [handoff, selectedId]); // Focus follows a new conversation once it, not the composer, is mounted. const handoffId = handoff?.sessionId ?? null; useEffect(() => { if (handoffId !== null && selectedId === handoffId) setFocusRequest((request) => request + 1); }, [handoffId, selectedId]); return ( ); } function PanelLoading({ label }: { label: string }) { return (
{label}
); } function Retry({ message, onRetry }: { message: string; onRetry: () => void }) { return (

{message}{" "}

); } function ConversationRow({ workspaceId, siteId, session, selected, }: { workspaceId: string; siteId: string; session: Session; selected: boolean; }) { const title = sessionDisplayTitle(session); const running = isRunningStatus(session.status); return (
  • {title} {running ? (
  • ); }