import { dockArtifact, filesScope, } from "../../stores/artifacts"; import { currentId, project, sessions } from "../../stores/session"; import { activeTab, dock, openTabs, provMode } from "../../stores/ui"; import { isReady } from "../../compat/stub"; import { bytes, callWindow, el, hostWindow, icon, translate } from "./api"; import { artifactTabKey } from "./cache"; import { filesT } from "./copy"; import { artifactDeepLinkHref, parseArtifactDeepLink, rememberViewerVersion, resolveArtifactVersion, versionResolveMessage, } from "./deeplink"; import { browseFiles, filesGridArtifacts, visibleArtifacts } from "./files-index"; import { loadArtifacts, loadProjectArtifacts } from "./load"; import { renderArtifactBody } from "./renderers"; import { filesIndexError, filesIndexItems, viewerVersionState } from "./state"; import { tileThumb, tileThumbBig } from "./thumbs"; import type { ArtifactDeepLink, ArtifactRow, VersionResolve } from "./types"; export function addOpenTab(a: ArtifactRow): void { const tabs = (openTabs.value as ArtifactRow[]) || []; if (!tabs.some((x) => x && artifactTabKey(x) === artifactTabKey(a))) { openTabs.value = [...tabs, a]; } } export function closeTab(id: string): void { const tabs = ((openTabs.value as ArtifactRow[]) || []).filter((x) => x && artifactTabKey(x) !== id); openTabs.value = tabs; if (activeTab.value === id) { const last = tabs[tabs.length - 1]; if (last) { dockArtifact.value = last; provMode.value = false; setActiveTab(artifactTabKey(last)); } else setActiveTab("notebook"); } } function artIcon(a: ArtifactRow): string { const nm = String(a.filename || "").toLowerCase(); const ct = String(a.content_type || ""); if (ct.startsWith("image/") || /\.(png|jpe?g|gif|webp|svg)$/i.test(nm)) return "file"; if (/\.(pdb|cif|mol|mol2|sdf|xyz)$/i.test(nm)) return "atom"; if (/csv|json|tsv/.test(ct) || /\.(csv|json|tsv)$/i.test(nm)) return "table"; return "file"; } function tabBtn(kind: "div" | "button", iconName: string, label: string): HTMLElement { const node = el(kind, "dock-tab"); const ic = el("span", "ic"); ic.innerHTML = icon(iconName, 14); node.appendChild(ic); node.appendChild(el("span", "t-name", label)); return node; } export function renderDockTabs(): void { if (typeof document === "undefined") return; const bar = document.getElementById("dock-tabs"); if (!bar) return; bar.innerHTML = ""; ((openTabs.value as ArtifactRow[]) || []).forEach((a) => { const tab = tabBtn("div", artIcon(a), (a.filename || "artifact") + (a._exactVersion ? " · " + a.version_id : "")); if (activeTab.value === artifactTabKey(a)) tab.classList.add("active"); const close = el("span", "t-close"); close.innerHTML = icon("x", 14); close.title = translate("common.close"); close.onclick = (event) => { event.stopPropagation(); closeTab(artifactTabKey(a)); }; tab.appendChild(close); tab.onclick = () => { dockArtifact.value = a; provMode.value = false; setActiveTab(artifactTabKey(a)); }; bar.appendChild(tab); }); const notebook = tabBtn("button", "notebook", "Notebook"); if (activeTab.value === "notebook") notebook.classList.add("active"); notebook.onclick = () => setActiveTab("notebook"); bar.appendChild(notebook); const timeline = tabBtn("button", "clock", translate("dock.tab.timeline")); if (activeTab.value === "timeline") timeline.classList.add("active"); timeline.onclick = () => setActiveTab("timeline"); bar.appendChild(timeline); if (activeTab.value === "files") { const files = tabBtn("button", "files", "Files"); files.classList.add("active"); bar.appendChild(files); } } export function showDockPane(pane: string): void { if (typeof document === "undefined") return; ["viewer", "notebook", "timeline", "files"].forEach((p) => { const n = document.getElementById("dock-" + p); if (n) n.classList.toggle("hidden", p !== pane); }); } function dockEl(): HTMLElement | null { if (typeof document === "undefined") return null; return document.getElementById("rightdock"); } export function dockOpen(): void { const d = dock.value as { open: boolean; tab: string }; dock.value = { ...d, open: true, tab: d.tab }; dockEl()?.classList.remove("collapsed"); } export function dockClose(): void { const d = dock.value as { open: boolean; tab: string }; dock.value = { ...d, open: false, tab: d.tab }; dockEl()?.classList.add("collapsed"); } export function dockToggle(): void { const d = dock.value as { open: boolean; tab: string }; if (d.open) dockClose(); else setActiveTab(activeTab.value || "notebook"); } export function setActiveTab(tab: string, selectArtifact = true): void { viewerRequest += 1; activeTab.value = tab; const selected = (openTabs.value as ArtifactRow[]).find((row) => artifactTabKey(row) === tab); if (selected && selectArtifact) { dockArtifact.value = selected; rememberViewerVersion(viewerVersionFor(selected)); } dockOpen(); renderDockTabs(); const pane = tab === "notebook" ? "notebook" : tab === "timeline" ? "timeline" : tab === "files" ? "files" : "viewer"; showDockPane(pane); if (tab === "notebook") callWindow("renderNotebook"); else if (tab === "timeline") { callWindow("loadWorkbenchState", currentId.value); callWindow("renderActionTimeline"); } else if (tab === "files") { void (async () => { if (filesScope.value === "project") await loadProjectArtifacts(); else if (currentId.value) await loadArtifacts(currentId.value); else await browseFiles({ reset: true }); renderFilesGrid(); })(); } else renderViewer(); } export function sessionNameFor(frameId: string | null | undefined): string { const list = (sessions.value as Array<{ id?: string; name?: string; task_summary?: string }>) || []; const f = list.find((s) => s.id === frameId); return (f && (f.name || f.task_summary)) || translate("conv.title.default"); } export function renderConversationArtifacts(): void { if (typeof document === "undefined") return; document.querySelectorAll(".generated, .uploaded").forEach((n) => n.remove()); const arts = visibleArtifacts(); if (!arts.length) return; const mkTile = (a: ArtifactRow) => { const tile = el("div", "tile"); tile.appendChild(tileThumb(a)); const fn = el("div", "tfn", a.filename || "artifact"); if ((a.priority || 0) > 0) fn.textContent = "⭐ " + fn.textContent; tile.appendChild(fn); // Strip rows are `_artifact_json` heads whose `version_id` is the current // latest; like the Files grid they must open the latest tab, not pin the // head-at-click (which the cache would then refuse to advance). tile.onclick = () => presentViewer(a); return tile; }; const CAP = 6; const section = (cls: string, label: string, list: ArtifactRow[]) => { if (!list.length) return; const g = el("div", cls); g.appendChild(el("div", "gen-label", `${label} · ${list.length}`)); const tiles = el("div", "gen-tiles"); if (list.length <= CAP) { list.forEach((a) => tiles.appendChild(mkTile(a))); } else { list.slice(0, CAP - 1).forEach((a) => tiles.appendChild(mkTile(a))); const more = el("div", "tile tile-more"); more.appendChild(el("div", "tile-more-n", translate("gen.more", list.length - (CAP - 1)))); more.onclick = () => { more.remove(); list.slice(CAP - 1).forEach((a) => tiles.appendChild(mkTile(a))); }; tiles.appendChild(more); } g.appendChild(tiles); const host = document.getElementById("messages"); if (!host) return; let review: HTMLElement | null = host.querySelector(".step-review"); while (review && review.parentElement !== host) { review = review.parentElement as HTMLElement | null; } host.insertBefore(g, review || null); }; section("uploaded", translate("art.uploaded"), arts.filter((a) => a.is_user_upload)); section("generated", translate("art.generated"), arts.filter((a) => !a.is_user_upload)); callWindow("down"); } function paintVersionBanner(list: HTMLElement): void { const state = viewerVersionState.value; if (!state) return; const msg = versionResolveMessage(state); if (!msg) return; const note = el("div", "files-version-error", msg); list.appendChild(note); } export function renderFilesGrid(): void { if (typeof document === "undefined") return; const list = document.getElementById("results-list"); const count = document.getElementById("results-count"); if (!list) return; const arts = filesGridArtifacts(); list.innerHTML = ""; if (count) count.textContent = String(arts.length); paintVersionBanner(list); const indexErr = filesIndexError.value; if (indexErr && filesIndexItems.value.length === 0 && !arts.length) { list.appendChild(el("div", "files-empty", indexErr)); return; } if (!arts.length) { const msg = filesScope.value === "project" ? translate("files.emptyProject") : translate("files.empty"); list.appendChild(el("div", "files-empty", msg)); return; } arts.forEach((a) => { const card = el("div", "art"); card.appendChild(tileThumbBig(a)); card.appendChild( el("div", "a-name", ((a.priority || 0) > 0 ? "⭐ " : "") + (a.filename || "artifact")), ); card.appendChild(el("div", "a-meta", (a.content_type || "") + " · " + bytes(a.size_bytes))); if (filesScope.value === "project") { card.appendChild(el("div", "a-src", translate("files.fromSession", sessionNameFor(a.root_frame_id)))); } card.onclick = () => presentViewer(a); list.appendChild(card); }); } let renderViewerImpl: (() => void) | null = null; /** F-18 island replaces the F-17 skeleton once `bootIslands` runs. */ export function setRenderViewerImpl(fn: (() => void) | null): void { renderViewerImpl = fn; } export function renderViewer(): void { if (renderViewerImpl) { renderViewerImpl(); return; } if (typeof document === "undefined") return; const a = dockArtifact.value as ArtifactRow | null; const v = document.getElementById("dock-viewer"); if (!v) return; v.innerHTML = ""; if (!a) { v.appendChild(el("div", "dock-empty", translate("viewer.empty"))); return; } const head = el("div", "viewer-head"); head.appendChild(el("div", "vh-name", a.filename || "artifact")); const acts = el("div", "vh-acts"); const banner = versionResolveMessage(viewerVersionState.value); if (banner) { v.appendChild(el("div", "files-version-error", banner)); if (viewerVersionState.value?.status === "stale" || viewerVersionState.value?.status === "not-found") { return; } } const copy = el("button", "outline-btn small", filesT("files.deeplink.copy")); copy.onclick = () => { const href = artifactDeepLinkHref(a.id, a._exactVersion ? a.version_id : null); const clip = (globalThis as { navigator?: { clipboard?: { writeText?: (s: string) => Promise } } }) .navigator?.clipboard?.writeText; if (isReady(clip)) void clip(href); copy.textContent = filesT("files.deeplink.copied"); }; acts.appendChild(copy); head.appendChild(acts); v.appendChild(head); if (provMode.value) { callWindow("renderProvenanceInto", v, a); return; } const body = el("div", "viewer-body"); v.appendChild(body); renderArtifactBody(body, a); } let viewerRequest = 0; /** The viewer state a row already carries: pinned rows are exact, others latest. */ function viewerVersionFor(a: ArtifactRow): VersionResolve { return a._exactVersion ? { status: "exact", artifact: a, versionId: String(a.version_id || "") } : { status: "latest", artifact: a, versionId: a.version_id || a.latest_version_id || null }; } function presentViewer(a: ArtifactRow): void { viewerRequest += 1; rememberViewerVersion(viewerVersionFor(a)); dockArtifact.value = a; provMode.value = false; addOpenTab(a); setActiveTab(artifactTabKey(a)); } /** * app.js:9437. Artifact click → dock Viewer tab. * * A provided `version_id` that is not already an exact pin is resolved * immutably: missing exact → stale/not-found, never silent latest. * Palette ⌘K hits forward `version_id` as-is (F-20); this is the pin. * Files-grid cards call `presentViewer` instead — those rows carry the * current head `version_id` and must not wait on `/versions`. */ export function openViewer(a: ArtifactRow): void | Promise { if (a.version_id && !a._exactVersion) { return applyArtifactDeepLink({ artifactId: a.id, versionId: String(a.version_id), }); } presentViewer(a); } /** * M-03: ⌘K / deep link. Open the owning session first, then the Viewer on * the exact version. A provided version_id never falls back to latest. */ export async function openArtifactFromHit(hit: { id: string; root_frame_id?: string | null; project_id?: string | null; version_id?: string | null; filename?: string | null; }): Promise { const fid = hit.root_frame_id; if (fid && fid !== currentId.value) { callWindow("openConversation", fid, hit.project_id || project.value); } await applyArtifactDeepLink({ artifactId: hit.id, versionId: hit.version_id ? String(hit.version_id) : null, }); } export async function applyArtifactDeepLink(link: ArtifactDeepLink): Promise { const request = ++viewerRequest; let result: VersionResolve; try { result = await resolveArtifactVersion(link); } catch { result = { status: "not-found" as const, artifactId: link.artifactId, versionId: link.versionId, }; } if (request !== viewerRequest) return; rememberViewerVersion(result); if (result.status === "exact" || result.status === "latest") { presentViewer(result.artifact); return; } // stale / not-found: show the banner and do not substitute latest. dockArtifact.value = { id: link.artifactId, artifact_id: link.artifactId }; setActiveTab(artifactTabKey({ id: link.artifactId, version_id: link.versionId, _exactVersion: !!link.versionId }), false); renderViewer(); } export function consumeArtifactDeepLink( search: string | null | undefined = typeof location !== "undefined" ? location.search : "", ): void | Promise { const link = parseArtifactDeepLink(search); if (!link) return; return applyArtifactDeepLink(link); } export async function copyArtifactDeepLink(a: ArtifactRow): Promise { const href = artifactDeepLinkHref(a.id, a._exactVersion ? a.version_id : null); const clip = hostWindow().navigator as unknown as { clipboard?: { writeText?: (s: string) => Promise } }; const write = clip && clip.clipboard && clip.clipboard.writeText; if (isReady(write)) await write(href); return href; }