import { useEffect, useRef, useState, type ReactNode } from "react"; // Emits the asset URL only; the worker itself loads lazily with the pdfjs chunk. import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; import { getArtifacts, getJournalCases, getRoots, readArtifact, revealArtifact, type ArtifactContent, type ArtifactInfo, type Board, type JournalCase, type RootInfo, } from "../api"; import type { SessionInfo, TodoItem } from "../types"; import { AccessSection } from "./AccessSection"; import { BoardSection } from "./BoardPanel"; import { Icon } from "./Icon"; import { Markdown, OPEN_ARTIFACT_EVENT } from "./Markdown"; type Panel = "progress" | "artifacts" | "board" | "journal" | "team" | "files"; // Quiet file-type icons for the artifact list (the colored kind pills read as noisy). function kindIcon(kind: string): "file" | "fileCode" | "image" | "table" { if (kind === "image") return "image"; if (kind === "html" || kind === "code") return "fileCode"; if (kind === "csv" || kind === "sheet") return "table"; return "file"; // markdown, text, pdf, everything else } // Fallback kind for an artifact: link whose path isn't in the list (yet) — mirrors the // server's extension mapping closely enough for the viewer to pick a renderer. function kindFromPath(path: string): string { const ext = (path.split(".").pop() || "").toLowerCase(); if (["png", "jpg", "jpeg", "gif", "svg", "webp"].includes(ext)) return "image"; if (["html", "htm"].includes(ext)) return "html"; if (ext === "md") return "markdown"; if (ext === "csv") return "csv"; if (ext === "pdf") return "pdf"; if (["py", "js", "ts", "tsx", "jsx", "json", "sh", "css"].includes(ext)) return "code"; return "text"; } interface Props { active: boolean; sessionId: string; refreshKey: number; toolNames: string[]; todo: TodoItem[]; running: boolean; // Fires when a full artifact preview opens/closes, so the app can auto-collapse the left nav // to give the preview (PDF/webpage/sheet) more room (#3). onPreviewChange?: (open: boolean) => void; // §32: the rail is the ONE session panel for every persona. Artifacts (scratch-side // deliverables), Files (all roots), and Access all render for every session (UX-036/037). showArtifacts?: boolean; personaId?: string; projectScoped?: boolean; workspace?: string; branch?: string | null; scratchPrimary?: boolean; openAccessKey?: number; onOpenIntegrations?: () => void; // Agent teams (OPE-96): App owns board data (the plan gate needs it too); // the rail renders the summary section and the expand affordance. board?: Board | null; onExpandBoard?: () => void; onOpenBoardItem?: (id: number) => void; // Drawer restructure (seventeenth pass): the team lives HERE, not in the sidebar — // member rows + the # team chat row. `isLead` also suppresses Progress (the board // is the lead's progress surface). isLead?: boolean; teamMembers?: SessionInfo[]; teamChatEnabled?: boolean; teamChatUnread?: number; onOpenTeamChat?: () => void; onOpenWorker?: (s: SessionInfo) => void; // Bumped when a [.](board:) chip in the transcript is clicked — expands the Board section. openBoardKey?: number; } export function RightRail({ active, sessionId, refreshKey, toolNames, todo, running, onPreviewChange, showArtifacts = true, personaId, projectScoped, workspace, branch, scratchPrimary, openAccessKey = 0, onOpenIntegrations, board, onExpandBoard, onOpenBoardItem, isLead = false, teamMembers = [], teamChatEnabled = false, teamChatUnread = 0, onOpenTeamChat, onOpenWorker, openBoardKey = 0, }: Props) { // Seventeenth pass: every panel starts collapsed and nothing auto-expands — a count // chip is the maximum signal. One exception survives (solo sessions only): Progress // still auto-opens the first time a live turn has todos. const [open, setOpen] = useState>({ progress: false, artifacts: false, board: false, journal: false, team: false, files: false, }); const autoOpenedProgress = useRef(false); useEffect(() => { if (!isLead && running && todo.length > 0 && !autoOpenedProgress.current) { autoOpenedProgress.current = true; setOpen((prev) => ({ ...prev, progress: true })); } }, [running, todo.length, isLead]); // A board chip in the transcript deep-links here: expand the Board section. const seenBoardKey = useRef(openBoardKey); useEffect(() => { if (openBoardKey === seenBoardKey.current) return; seenBoardKey.current = openBoardKey; setOpen((prev) => ({ ...prev, board: true })); }, [openBoardKey]); const [artifacts, setArtifacts] = useState([]); // UX-037 Files: the session's roots (workspace/scratch/grants) — the entry points of // the file explorer. const [rootDirs, setRootDirs] = useState([]); const [journal, setJournal] = useState([]); const [selected, setSelected] = useState(null); const [content, setContent] = useState(null); const refreshArtifacts = () => getArtifacts(sessionId).then(setArtifacts).catch(() => setArtifacts([])); useEffect(() => { if (!active) return; if (showArtifacts) refreshArtifacts(); }, [active, sessionId, refreshKey, showArtifacts]); useEffect(() => { if (!active) return; getRoots(sessionId).then(setRootDirs).catch(() => setRootDirs([])); }, [active, sessionId, refreshKey]); // Journal cases surface only when a board exists — same visibility rule as the // Board section, so plain sessions carry zero team chrome. useEffect(() => { if (!active || !board?.space) { setJournal([]); return; } getJournalCases().then(setJournal).catch(() => setJournal([])); }, [active, sessionId, refreshKey, board?.space]); // Switching conversations closes any open artifact — it belongs to the previous session's // workspace, which the new session can't (and shouldn't) read. useEffect(() => { setSelected(null); setContent(null); }, [sessionId]); useEffect(() => { setContent(null); if (!selected) return; readArtifact(sessionId, selected.path).then(setContent).catch(() => setContent(null)); }, [selected?.path, sessionId]); // Notify the app when a preview opens/closes (drives the left-nav auto-collapse). useEffect(() => { onPreviewChange?.(!!selected); }, [!!selected, onPreviewChange]); const reloadSelected = () => { if (!selected) return Promise.resolve(); setContent(null); return readArtifact(sessionId, selected.path).then(setContent).catch(() => setContent(null)); }; // §34 (UX-016): [Title](artifact:path) chips in the transcript open the viewer directly. // Resolve against the loaded list first; on a miss, refresh once (the file may be // seconds old), then fall back to a minimal record — readArtifact validates the path. // Registered even while the rail is HIDDEN (owner-hit 2026-08-15): the chip fires ONE // event, and App's unhide listener and this one race it — gating this on `active` // dropped the selection, so the first click only opened an empty rail. useEffect(() => { if (!sessionId) return; const minimal = (path: string): ArtifactInfo => ({ path, name: path.split("/").pop() || path, kind: kindFromPath(path), size: 0, modified_at: 0, }); const match = (list: ArtifactInfo[], path: string) => list.find((a) => a.path === path || a.path.endsWith("/" + path) || a.name === path); const onOpen = (e: Event) => { const path = String((e as CustomEvent).detail?.path || ""); if (!path) return; const found = match(artifacts, path); if (found) { setSelected(found); return; } getArtifacts(sessionId) .then((list) => { setArtifacts(list); setSelected(match(list, path) ?? minimal(path)); }) .catch(() => setSelected(minimal(path))); }; window.addEventListener(OPEN_ARTIFACT_EVENT, onOpen); return () => window.removeEventListener(OPEN_ARTIFACT_EVENT, onOpen); }, [sessionId, artifacts]); if (!active) return null; return ( ); } // The Board section's header chip: the attention states (blocked/review) when present, // otherwise a quiet active count. Full per-state summary stays on the topbar button. function boardChip(board: Board): { text: string; attention: boolean } { const counts: Record = {}; for (const item of board.items) counts[item.state] = (counts[item.state] || 0) + 1; const attn: string[] = []; if (counts.blocked) attn.push(`${counts.blocked} blocked`); if (counts.review) attn.push(`${counts.review} review`); if (attn.length) return { text: attn.join(" · "), attention: true }; const active = (counts.in_progress || 0) + (counts.open || 0); return { text: active ? `${active} active` : "", attention: false }; } function ProgressSummary({ running, toolNames, todo }: { running: boolean; toolNames: string[]; todo: TodoItem[] }) { if (todo.length) { return (
{todo.map((item, index) => (
{item.content}
))} {running && (
{toolNames.length ? `${toolNames.length} tool call${toolNames.length === 1 ? "" : "s"} so far.` : "Working..."}
)}
); } if (running) { return (
Working on this task{toolNames.length ? ` with ${toolNames.length} tool call${toolNames.length === 1 ? "" : "s"} so far.` : "."}
); } return (
For longer multi-step tasks, progress will appear here while OpenWorker plans, uses tools, waits for approval, and produces artifacts.
); } function RailSection({ title, open, onToggle, children, action, count, countAttention, }: { title: string; open: boolean; onToggle: () => void; children: ReactNode; action?: ReactNode; // The header's maximum signal: a small count chip; amber when it carries attention // states (blocked/review). Panels never shout louder than this. count?: string; countAttention?: boolean; }) { return (
{action}
{open &&
{children}
}
); } // OPE-91: agent-authored HTML is untrusted active content rendered inside the PRIVILEGED // app webview (Tauri IPC). The sandbox must therefore be airtight on two axes: // - no `allow-same-origin`: with srcDoc, that flag would run the page same-origin with // the app — scripts could reach the parent document and the IPC bridge. // - no network: a poisoned report exfiltrates at DISPLAY time via subresources // (). The injected CSP allows inline style/script // (what report interactivity needs) and data: images; everything remote is blocked. // Injected at position 0 so it takes effect before any content the page declares. const ARTIFACT_CSP = '"; function sandboxHtml(html: string): string { return ARTIFACT_CSP + html; } function ArtifactViewer({ sessionId, artifact, content, onReload, onBack, onOpenEntry, }: { sessionId: string; artifact: ArtifactInfo; content: ArtifactContent | null; onReload: () => Promise; onBack: () => void; // Folder listings: open a child entry in the viewer (files and subfolders alike). onOpenEntry?: (path: string) => void; }) { const [reloadKey, setReloadKey] = useState(0); const isHtml = content?.kind === "html" && !content.error; // Best viewed in a real app: spreadsheets, PDFs, and Office docs (pptx/docx can't preview inline) const isApp = content?.kind === "sheet" || content?.kind === "pdf" || content?.kind === "office"; return (
{artifact.origin === "files" ? "Files" : "Artifacts"}/{artifact.name}
{artifact.path}
{isHtml && ( )} {isApp && ( )} {isHtml && ( // The sandboxed preview is deliberately offline and null-origin; a real // browser tab (system default app, outside app privileges) is the escape // hatch for sharing or printing the page. )} {/* Copy the ABSOLUTE path — the workspace-relative one is useless outside the app (tester catch 2026-07-12: it copied just "slack-connector-debug.md"). */}
{!content ? (
Loading...
) : content.error ? (
{content.error}
) : content.kind === "html" ? (