"use client"; /** * SessionViewerPanel — full right-side sidebar with browser-style tabs that * can hold (a) attachment previews and (b) embedded web pages clicked from * assistant messages. * * - Tabs across the top of the panel; each closeable. * - File tabs use the same lazy previewer set as FilePreviewDrawer. * - Web tabs render an iframe of the URL. Cross-origin frames may refuse * to load — we expose an "Open in browser" affordance so the user can * always fall back. The user's network ultimately decides what loads. * - Imperative API via ref: openFileTab(att), openWebTab(url). */ import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type ReactNode, } from "react"; import dynamic from "next/dynamic"; import { Activity, AlertCircle, ArrowRight, Compass, Download, ExternalLink, FileUp, Globe, Loader2, MessageSquarePlus, Paperclip, X, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { previewKindFor, resolveSourceUrl, type FilePreviewSource, } from "@/components/chat/preview/previewerFor"; import { ActivityBody, type SessionActivity, } from "@/components/chat/home/SessionActivityPanel"; import QuizFollowupTabBody from "@/components/quiz/QuizFollowupTabBody"; import SubagentTabBody from "@/components/chat/home/SubagentTabBody"; import type { QuizFollowupTabContext } from "@/context/QuizFollowupContext"; import type { GeogebraTabPayload } from "@/context/GeogebraTabContext"; import { apiUrl } from "@/lib/api"; import type { MessageAttachment } from "@/context/UnifiedChatContext"; import type { StreamEvent } from "@/lib/unified-ws"; const PdfPreview = dynamic( () => import("@/components/chat/preview/previewers/PdfPreview"), ); const ImagePreview = dynamic( () => import("@/components/chat/preview/previewers/ImagePreview"), ); const SvgPreview = dynamic( () => import("@/components/chat/preview/previewers/SvgPreview"), ); const MarkdownPreview = dynamic( () => import("@/components/chat/preview/previewers/MarkdownPreview"), ); const TextPreview = dynamic( () => import("@/components/chat/preview/previewers/TextPreview"), ); const DocxPreview = dynamic( () => import("@/components/chat/preview/previewers/DocxPreview"), ); const XlsxPreview = dynamic( () => import("@/components/chat/preview/previewers/XlsxPreview"), ); const OfficeTextPreview = dynamic( () => import("@/components/chat/preview/previewers/OfficeTextPreview"), ); const FallbackPreview = dynamic( () => import("@/components/chat/preview/previewers/FallbackPreview"), ); const Geogebra = dynamic(() => import("@/components/Geogebra"), { ssr: false, }); const ANIM_MS = 220; /* Resizable width — the panel overlays from the right and the chat shell reserves space for it via the ``--viewer-width`` CSS var (see globals.css). Both read the same var so the squeeze and the panel edge stay locked together while dragging. */ const VIEWER_WIDTH_VAR = "--viewer-width"; const VIEWER_WIDTH_KEY = "dt:viewer-width"; const VIEWER_WIDTH_DEFAULT = 620; const VIEWER_WIDTH_MIN = 400; const VIEWER_WIDTH_MAX = 960; function clampViewerWidth(px: number): number { // Hard floor/ceiling, plus a soft ceiling that always leaves the chat // column ~30% of the viewport so the panel can't swallow the conversation. const ceiling = typeof window !== "undefined" ? Math.max( VIEWER_WIDTH_MIN, Math.min(VIEWER_WIDTH_MAX, window.innerWidth * 0.7), ) : VIEWER_WIDTH_MAX; return Math.round(Math.max(VIEWER_WIDTH_MIN, Math.min(px, ceiling))); } function readStoredViewerWidth(): number { if (typeof window === "undefined") return VIEWER_WIDTH_DEFAULT; const raw = window.localStorage.getItem(VIEWER_WIDTH_KEY); const parsed = raw ? Number(raw) : NaN; return Number.isFinite(parsed) ? clampViewerWidth(parsed) : VIEWER_WIDTH_DEFAULT; } /* ------------------------------------------------------------------ */ /* Tab types */ /* ------------------------------------------------------------------ */ type ViewerTab = | { kind: "file"; id: string; label: string; source: FilePreviewSource } | { kind: "web"; id: string; label: string; url: string } | { kind: "quiz-followup"; id: string; label: string; context: QuizFollowupTabContext; } | { kind: "geogebra"; id: string; label: string; script: string; } | { kind: "subagent"; id: string; label: string; callId: string; events: StreamEvent[]; }; export interface SessionViewerPanelHandle { openFileTab(a: MessageAttachment): void; openWebTab(url: string): void; /** Opens (or focuses) the follow-up chat tab for a quiz question. */ openQuizFollowupTab(context: QuizFollowupTabContext): void; /** Opens (or focuses) an interactive GeoGebra applet tab. */ openGeogebraTab(payload: GeogebraTabPayload): void; /** Opens (first time) or live-updates a connected subagent's run tab. */ openSubagentTab(callId: string, label: string, events: StreamEvent[]): void; /** Opens the panel and switches to the Activity home (where the * capability-config card lives). */ focusActivityHome(): void; } interface SessionViewerPanelProps { open: boolean; sessionId: string | null; onClose: () => void; onAutoOpen: () => void; /** Aggregated session activity, shown on the Activity home view. */ activity: SessionActivity; /** Optional capability-config card appended below the activity sections. */ configSection?: ReactNode; } function fileTabIdFor(a: MessageAttachment, fallback: number): string { return `file:${a.id ?? a.filename ?? `idx-${fallback}`}`; } function webTabIdFor(url: string): string { return `web:${url}`; } function quizFollowupTabIdFor(questionKey: string): string { return `quiz-followup:${questionKey}`; } function geogebraTabIdFor(payloadId: string): string { return `geogebra:${payloadId}`; } function subagentTabIdFor(callId: string): string { return `subagent:${callId}`; } function hostnameFor(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return url.slice(0, 32); } } function attachmentToPreviewSource(a: MessageAttachment): FilePreviewSource { return { filename: a.filename || "", mimeType: a.mime_type, type: a.type, url: a.url, base64: a.base64, extractedText: a.extracted_text, id: a.id, }; } /* ------------------------------------------------------------------ */ /* Panel */ /* ------------------------------------------------------------------ */ function SessionViewerPanelInner( { open, sessionId, onClose, onAutoOpen, activity, configSection, }: SessionViewerPanelProps, ref: React.Ref, ) { const { t } = useTranslation(); const [tabs, setTabs] = useState([]); const [activeTabId, setActiveTabId] = useState(null); // Drag-to-resize width. The width is NOT React state — the panel reads it // from the ``--viewer-width`` CSS var (so does the chat shell's squeeze), // and the drag writes that var directly. This keeps the inline style a // constant string (no SSR/client hydration mismatch) and means a drag // re-styles one DOM node per frame instead of re-rendering the whole panel // every pointer move — the difference between janky and buttery. const widthRef = useRef(VIEWER_WIDTH_DEFAULT); useEffect(() => { // Restore the persisted width after mount (kept out of the initial render // so server and client agree on the fallback width). widthRef.current = readStoredViewerWidth(); document.documentElement.style.setProperty( VIEWER_WIDTH_VAR, `${widthRef.current}px`, ); }, []); const startResize = useCallback((e: React.PointerEvent) => { e.preventDefault(); document.documentElement.dataset.viewerResizing = "true"; document.body.style.userSelect = "none"; document.body.style.cursor = "col-resize"; let rafId = 0; let pendingX = e.clientX; const apply = () => { rafId = 0; const w = clampViewerWidth(window.innerWidth - pendingX); widthRef.current = w; document.documentElement.style.setProperty(VIEWER_WIDTH_VAR, `${w}px`); }; const onMove = (ev: PointerEvent) => { // Coalesce to one var write per frame — pointermove can fire faster // than the display refreshes. pendingX = ev.clientX; if (!rafId) rafId = requestAnimationFrame(apply); }; const onUp = () => { if (rafId) cancelAnimationFrame(rafId); delete document.documentElement.dataset.viewerResizing; document.body.style.userSelect = ""; document.body.style.cursor = ""; window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); window.localStorage.setItem(VIEWER_WIDTH_KEY, String(widthRef.current)); }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); }, []); // Wipe tabs whenever the session changes — preview/web state belongs to // the conversation that triggered it. const [trackedSessionId, setTrackedSessionId] = useState( sessionId, ); if (trackedSessionId !== sessionId) { setTrackedSessionId(sessionId); setTabs([]); setActiveTabId(null); } const openFileTab = useCallback( (a: MessageAttachment) => { setTabs((prev) => { const id = fileTabIdFor(a, prev.length); const existingIdx = prev.findIndex((tab) => tab.id === id); if (existingIdx >= 0) { setActiveTabId(id); return prev; } const label = a.filename || "Attachment"; const next: ViewerTab = { kind: "file", id, label, source: attachmentToPreviewSource(a), }; setActiveTabId(id); return [...prev, next]; }); onAutoOpen(); }, [onAutoOpen], ); const openWebTab = useCallback( (url: string) => { setTabs((prev) => { const id = webTabIdFor(url); const existingIdx = prev.findIndex((tab) => tab.id === id); if (existingIdx >= 0) { setActiveTabId(id); return prev; } const next: ViewerTab = { kind: "web", id, label: hostnameFor(url), url, }; setActiveTabId(id); return [...prev, next]; }); onAutoOpen(); }, [onAutoOpen], ); const openQuizFollowupTab = useCallback( (context: QuizFollowupTabContext) => { setTabs((prev) => { const id = quizFollowupTabIdFor(context.questionKey); const existingIdx = prev.findIndex((tab) => tab.id === id); // When the tab already exists, refresh its pinned context (answer // text, judgment, etc.) since the learner may have updated it // since the tab was first opened. if (existingIdx >= 0) { const refreshed: ViewerTab = { kind: "quiz-followup", id, label: context.tabLabel, context, }; const next = [...prev]; next[existingIdx] = refreshed; setActiveTabId(id); return next; } const next: ViewerTab = { kind: "quiz-followup", id, label: context.tabLabel, context, }; setActiveTabId(id); return [...prev, next]; }); onAutoOpen(); }, [onAutoOpen], ); const openGeogebraTab = useCallback( (payload: GeogebraTabPayload) => { setTabs((prev) => { const id = geogebraTabIdFor(payload.id); const existingIdx = prev.findIndex((tab) => tab.id === id); if (existingIdx >= 0) { // Refresh the script in case the assistant produced an updated // version under the same payload id (e.g. a refined figure). const refreshed: ViewerTab = { kind: "geogebra", id, label: payload.title || "GeoGebra", script: payload.script, }; const next = [...prev]; next[existingIdx] = refreshed; setActiveTabId(id); return next; } const next: ViewerTab = { kind: "geogebra", id, label: payload.title || "GeoGebra", script: payload.script, }; setActiveTabId(id); return [...prev, next]; }); onAutoOpen(); }, [onAutoOpen], ); // A connected subagent's run streams into its own tab. The first call (when // the consult starts) reveals + focuses the tab; later calls only refresh its // events, so live streaming never yanks the user off whatever they're viewing. const subagentSeenRef = useRef>(new Set()); const openSubagentTab = useCallback( (callId: string, label: string, events: StreamEvent[]) => { const id = subagentTabIdFor(callId); const isNew = !subagentSeenRef.current.has(callId); subagentSeenRef.current.add(callId); setTabs((prev) => { const existingIdx = prev.findIndex((tab) => tab.id === id); const tab: ViewerTab = { kind: "subagent", id, label, callId, events }; if (existingIdx >= 0) { const next = [...prev]; next[existingIdx] = tab; return next; } return [...prev, tab]; }); if (isNew) { setActiveTabId(id); onAutoOpen(); } }, [onAutoOpen], ); // Open the panel and return to the Activity home (where the // capability-config card surfaces). Used by the send-gate. const focusActivityHome = useCallback(() => { setActiveTabId(null); onAutoOpen(); }, [onAutoOpen]); useImperativeHandle( ref, () => ({ openFileTab, openWebTab, openQuizFollowupTab, openGeogebraTab, openSubagentTab, focusActivityHome, }), [ openFileTab, openWebTab, openQuizFollowupTab, openGeogebraTab, openSubagentTab, focusActivityHome, ], ); const closeTab = useCallback( (id: string) => { setTabs((prev) => { const idx = prev.findIndex((tab) => tab.id === id); if (idx === -1) return prev; const next = prev.filter((tab) => tab.id !== id); if (activeTabId === id) { // Fall back to the previous tab, or to the Activity home when none // remain — the panel stays open since the home is always useful. setActiveTabId( next.length === 0 ? null : (next[Math.max(0, idx - 1)] ?? next[0]).id, ); } return next; }); }, [activeTabId], ); // ESC closes the panel. useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); onClose(); } }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [open, onClose]); // The viewer is visible whenever it's open — even with no tabs. The // tabs.length === 0 case renders a "Landing" page where the user can // paste a URL or pick a local file to open as the first tab. const visible = open; const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null; const openLocalFile = useCallback( (file: File) => { const url = URL.createObjectURL(file); openFileTab({ type: file.type.startsWith("image/") ? "image" : "file", filename: file.name, mime_type: file.type, url, }); }, [openFileTab], ); return (
{/* Left-edge resize handle. A wide invisible hit-area with a hairline that tints on hover/drag — drag left/right to set the panel width. */}
setActiveTabId(null)} onSelect={setActiveTabId} onCloseTab={closeTab} onClosePanel={onClose} />
{activeTab?.kind === "file" ? ( ) : activeTab?.kind === "web" ? ( ) : activeTab?.kind === "quiz-followup" ? ( ) : activeTab?.kind === "geogebra" ? ( ) : activeTab?.kind === "subagent" ? ( ) : ( )}
); } const SessionViewerPanel = memo(forwardRef(SessionViewerPanelInner)); export default SessionViewerPanel; /* ------------------------------------------------------------------ */ /* Tab bar */ /* ------------------------------------------------------------------ */ /** * Chrome-style tab bar. The strip itself is a muted band; the active tab * "lifts" out of it in the page-body colour with rounded top corners — so * the active tab and the body underneath read as one continuous surface, * exactly like a browser tab. No coloured top stripe; inactive tabs stay * transparent over the strip. */ function TabBar({ tabs, activeTabId, homeActive, onSelectHome, onSelect, onCloseTab, onClosePanel, }: { tabs: ViewerTab[]; activeTabId: string | null; homeActive: boolean; onSelectHome: () => void; onSelect: (id: string) => void; onCloseTab: (id: string) => void; onClosePanel: () => void; }) { const { t } = useTranslation(); return (
{/* Persistent Activity home — always first, never closeable. It's the session-activity landing; opening a file/web tab focuses that tab and this recedes, browser-home-tab style. */} {tabs.map((tab) => { const active = tab.id === activeTabId; const Icon = tab.kind === "web" ? Globe : tab.kind === "quiz-followup" ? MessageSquarePlus : tab.kind === "geogebra" ? Compass : Paperclip; return (
); })}
); } /* ------------------------------------------------------------------ */ /* Activity home — the panel's default view (no tab focused) */ /* ------------------------------------------------------------------ */ /** * The merged Activity landing: the session-activity sections (tools, KBs, * Space refs, attachments — see ``ActivityBody``) plus a compact opener for * a URL or a local file. Clicking an attachment opens it as a file tab in * this same panel. */ function ActivityHome({ activity, open, configSection, onOpenAttachment, onOpenWebTab, onOpenLocalFile, }: { activity: SessionActivity; open: boolean; configSection?: ReactNode; onOpenAttachment: (a: MessageAttachment) => void; onOpenWebTab: (url: string) => void; onOpenLocalFile: (file: File) => void; }) { return (
); } /** Compact "open a URL or local file" footer for the Activity home. */ function ActivityOpener({ onOpenWebTab, onOpenLocalFile, }: { onOpenWebTab: (url: string) => void; onOpenLocalFile: (file: File) => void; }) { const { t } = useTranslation(); const [urlInput, setUrlInput] = useState(""); const fileInputRef = useRef(null); const submitUrl = useCallback(() => { const trimmed = urlInput.trim(); if (!trimmed) return; const href = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; onOpenWebTab(href); setUrlInput(""); }, [urlInput, onOpenWebTab]); const handleFileSelect = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) onOpenLocalFile(file); e.target.value = ""; }, [onOpenLocalFile], ); return (
{t("Open")}
{ e.preventDefault(); submitUrl(); }} className="flex items-center gap-2 rounded-lg border border-[var(--border)]/55 bg-[var(--background)] px-2.5 py-1.5 transition-colors focus-within:border-[var(--primary)]/40" > setUrlInput(e.target.value)} placeholder={t("https://example.com")} className="min-w-0 flex-1 bg-transparent text-[12.5px] text-[var(--foreground)] outline-none placeholder:text-[var(--muted-foreground)]/60" />
); } /* ------------------------------------------------------------------ */ /* File tab body */ /* ------------------------------------------------------------------ */ function FileTabBody({ source }: { source: FilePreviewSource }) { const { t } = useTranslation(); const previewUrl = useMemo(() => resolveSourceUrl(source, apiUrl), [source]); const kind = previewKindFor(source); const filename = source.filename || t("Attachment"); // Prefer the served URL; fall back to a data URL for pending (un-sent) // base64 attachments so download / open-in-browser still work. const fileUrl = useMemo(() => { if (previewUrl) return previewUrl; if (source.base64) { const mime = source.mimeType || "application/octet-stream"; return `data:${mime};base64,${source.base64}`; } return null; }, [previewUrl, source.base64, source.mimeType]); const openInBrowser = useCallback(() => { if (fileUrl) window.open(fileUrl, "_blank", "noopener,noreferrer"); }, [fileUrl]); return (
{/* The tab already carries the filename + type, so this strip is just a minimal action rail — no duplicated name/icon/label. */}
{fileUrl ? ( ) : null}
); } const PreviewBody = memo(function PreviewBody({ source, previewUrl, kind, }: { source: FilePreviewSource; previewUrl: string | null; kind: ReturnType | null; }) { const filename = source.filename; if (kind === "office-text") { return ( ); } if (!previewUrl) { return ; } switch (kind) { case "pdf": return ; case "docx": return ; case "xlsx": return ; case "image": return ; case "svg": return ; case "markdown": return (
); case "code": case "text": return (
); case "fallback": default: return ; } }); /* ------------------------------------------------------------------ */ /* Web tab body — iframe with safety fallback */ /* ------------------------------------------------------------------ */ /** * Web preview tab. Many sites set `X-Frame-Options: DENY` or a CSP * `frame-ancestors` directive that flat-out refuses iframe embedding — a * browser-enforced anti-clickjacking measure we can't bypass from the * frontend. We can't *detect* the failure reliably either (cross-origin * iframes are opaque to JS), so we lean on UX honesty: * * • A persistent info banner at the top tells the user upfront that some * sites won't load, and exposes "Open in browser" as a big primary * action right next to it. * • A loading spinner is overlaid until either `onLoad` fires or a soft * timeout (4.5 s) elapses. After the timeout we switch the banner copy * to a more explicit "site likely refused embedding" warning so the * user knows the spinner isn't a real load-in-progress. */ function WebTabBody({ url }: { url: string }) { const { t } = useTranslation(); const [loaded, setLoaded] = useState(false); const [timedOut, setTimedOut] = useState(false); const host = hostnameFor(url); const openInBrowser = useCallback(() => { window.open(url, "_blank", "noopener,noreferrer"); }, [url]); useEffect(() => { const timer = window.setTimeout(() => setTimedOut(true), 4500); return () => window.clearTimeout(timer); }, []); const blocked = timedOut && !loaded; return (
{host}
{url}
{/* Persistent info banner — explains the iframe limitation. Swaps to a louder warning once we suspect the site has refused to embed. */}
{blocked ? t( "This site looks like it refused to embed (its security headers block iframes). Use “Open in browser” to view it in a real tab.", ) : t( "Many sites refuse to embed for security reasons. If the page below stays blank, use “Open in browser”.", )}