diff --git a/app/api/sessions/[id]/artifacts/route.test.mjs b/app/api/sessions/[id]/artifacts/route.test.mjs new file mode 100644 index 0000000000..db7999778f --- /dev/null +++ b/app/api/sessions/[id]/artifacts/route.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { tsconfigPaths: true }); +const { GET } = await jiti.import("./route.ts"); +const { cacheSessionPath, invalidateSessionPathCache, invalidateSessionManagerCache } = await jiti.import("@/lib/session-reader.ts"); +const fence = (value) => `\`\`\`pi-html id="demo"\n\n\`\`\``; + +test("artifact index covers compacted/paged history and reads only the selected ancestor branch", async (t) => { + const dir = mkdtempSync(join(tmpdir(), 'pi-web-artifacts-')); + const file = join(dir, 'session.jsonl'); + const id = 'artifact-route-fixture'; + const timestamp = new Date().toISOString(); + const assistant = (id, parentId, text) => ({ type: 'message', id, parentId, timestamp, message: { role: 'assistant', content: [{ type: 'text', text }], provider: 'test', model: 'test', timestamp: Date.now() } }); + const entries = [{ type: 'session', version: 3, id, timestamp, cwd: dir }, assistant('first', null, fence('first'))]; + for (let index = 0; index < 105; index++) entries.push({ type: 'message', id: 'filler-' + index, parentId: entries.at(-1).id, timestamp, message: { role: 'user', content: 'ordinary text' } }); + entries.push(assistant('second', entries.at(-1).id, fence('second'))); + entries.push({ type: 'compaction', id: 'compact', parentId: 'second', timestamp, summary: 'summary', firstKeptEntryId: 'second', tokensBefore: 10000 }); + entries.push(assistant('sibling', 'first', fence('other branch'))); + const original = entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n'; + writeFileSync(file, original); + cacheSessionPath(id, file); + t.after(() => { invalidateSessionPathCache(id); invalidateSessionManagerCache(file); rmSync(dir, { recursive: true, force: true }); }); + const get = (query) => GET(new Request(`http://localhost/api/sessions/${id}/artifacts?${query}`), { params: Promise.resolve({ id }) }); + const response = await get('leafId=compact'); + assert.equal(response.status, 200); + assert.equal(response.headers.get('Cache-Control'), 'no-store'); + const history = (await response.json()).versions; + assert.deepEqual(history.map((item) => item.entryId), ['first', 'second']); + assert.equal(history[0].code, undefined, 'Index returns metadata, not all historical HTML'); + const old = await get('leafId=compact&key=first%3A0%3A0'); + assert.equal((await old.json()).version.code, ''); + assert.equal((await get('leafId=compact&key=sibling%3A0%3A0')).status, 404); + assert.equal((await get('leafId=missing')).status, 404); + assert.deepEqual((await (await get('leafId=null')).json()).versions, []); + assert.deepEqual((await (await get('')).json()).versions.map((item) => item.entryId), ['first', 'sibling']); + assert.equal(readFileSync(file, 'utf8'), original, 'Browsing never rewrites or appends artifact state'); +}); + +test("artifact reads prefer the open wrapper's in-memory SessionManager", async (t) => { + const id = 'in-memory-artifact-fixture'; + const previous = globalThis.__piSessions; + const version = { type: 'message', id: 'live-entry', message: { role: 'assistant', content: [{ type: 'text', text: fence('live') }] } }; + globalThis.__piSessions = new Map([[id, { isAlive: () => true, inner: { sessionManager: { + getLeafId: () => version.id, getEntry: (key) => key === version.id ? version : undefined, getBranch: () => [version], + } } }]]); + t.after(() => { globalThis.__piSessions = previous; }); + const response = await GET(new Request('http://localhost/api/sessions/live/artifacts'), { params: Promise.resolve({ id }) }); + assert.equal(response.status, 200); + assert.equal((await response.json()).versions[0].key, 'live-entry:0:0'); +}); diff --git a/app/api/sessions/[id]/artifacts/route.ts b/app/api/sessions/[id]/artifacts/route.ts new file mode 100644 index 0000000000..1dbecb0dc7 --- /dev/null +++ b/app/api/sessions/[id]/artifacts/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { resolveSessionPath, openSessionManager } from "@/lib/session-reader"; +import { getRpcSession } from "@/lib/rpc-manager"; +import { branchArtifactVersions } from "@/lib/interactive-artifacts"; + +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const query = new URL(req.url).searchParams; + try { + const rpc = getRpcSession(id); + const live = rpc?.isAlive() ? rpc : undefined; + const path = live ? null : await resolveSessionPath(id); + if (!live && !path) return NextResponse.json({ error: "Session not found" }, { status: 404 }); + const sm = live?.inner.sessionManager ?? openSessionManager(path!); + const requestedLeaf = query.get("leafId"); + const leaf = requestedLeaf === "null" ? null : requestedLeaf ?? sm.getLeafId(); + if (leaf && !sm.getEntry(leaf)) return NextResponse.json({ error: "Branch not found" }, { status: 404 }); + const versions = branchArtifactVersions(leaf ? sm.getBranch(leaf) : []); + const key = query.get("key"); + if (key !== null) { + const version = versions.find((item) => item.key === key); + return version ? NextResponse.json({ version }, { headers: { "Cache-Control": "no-store" } }) + : NextResponse.json({ error: "Artifact version not found on this branch" }, { status: 404 }); + } + return NextResponse.json({ leafId: leaf, versions: versions.map(({ id, key, entryId, blockIndex, ordinal }) => ({ id, key, entryId, blockIndex, ordinal })) }, { + headers: { "Cache-Control": "no-store" }, + }); + } catch (error) { + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} diff --git a/app/globals.css b/app/globals.css index e717dc2928..5453580c34 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1114,6 +1114,212 @@ pre, code { color: var(--text); } +.interactive-preview { + position: relative; + inset: auto; + display: flex; + flex-direction: column; + box-sizing: border-box; + width: 100%; + max-width: 100%; + max-height: none; + min-width: 0; + margin: 6px 0; + padding: 0; + border: 1px solid var(--border); + border-radius: 7px; + overflow: hidden; + background: var(--bg); + color: var(--text); +} +.interactive-preview .markdown-code-header { + flex: none; + flex-wrap: wrap; + gap: 6px; +} +.interactive-preview-label { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.interactive-preview .markdown-code-actions { + gap: 3px; + margin-left: auto; +} +.interactive-artifact-id { + flex: 1 1 auto; + min-width: 0; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: 12px var(--font-mono); +} +.has-artifact-version .interactive-preview-label { + flex: 1 1 260px; +} +.interactive-preview-label .markdown-code-lang, +.interactive-preview-label select { + flex: none; + white-space: nowrap; +} +.interactive-preview-label select { + max-width: 100px; + min-width: 0; + padding: 3px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-panel); + color: var(--text); + font-size: 12px; +} +.interactive-artifact-history { + margin: 6px 0; +} +.interactive-artifact-history > summary { + cursor: pointer; + padding: 8px 0; + font: 12px/1.5 var(--font-mono); + color: var(--text-muted); + overflow-wrap: anywhere; +} +.interactive-artifact-notice, +.interactive-artifact-status { + display: flex; + align-items: center; + gap: 6px; + flex: none; + padding: 6px 12px; + font-size: 12px; + color: var(--text-muted); + background: var(--bg-panel); +} +.interactive-artifact-loading { + border: 1px solid var(--border); + border-radius: 7px; + overflow: hidden; +} +.interactive-preview-action { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} +.interactive-preview-action:hover:not(:disabled) { + color: var(--text); + background: var(--bg-hover); +} +.interactive-preview-action:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} +.interactive-preview-action:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.interactive-preview-frame { + flex: none; + height: 320px; + background: var(--bg); +} +.interactive-preview-frame iframe { + display: block; + width: 100%; + height: 100%; + border: 0; + color-scheme: light dark; +} +.interactive-preview-source { + min-height: 0; + max-height: min(480px, 60dvh); + overflow: auto; +} +.interactive-preview-source > .markdown-code-block { + margin: 0; + border: 0; + border-radius: 0; + box-shadow: none; +} +.interactive-preview [hidden] { + display: none; +} +.interactive-preview-error { + flex: none; + padding: 8px 12px; + font-size: 12px; + color: var(--text-muted); + background: var(--bg-panel); + border-bottom: 1px solid var(--border); +} +.interactive-preview-error-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.interactive-preview-error summary { + cursor: pointer; + width: fit-content; + padding: 4px 0; +} +.interactive-preview-error pre { + max-height: 160px; + overflow: auto; + margin: 4px 0 0; + padding: 8px; + font: 12px/1.5 var(--font-mono); + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--text); + background: var(--bg); +} +.interactive-preview.is-expanded { + position: fixed; + inset: 0; + width: calc(100% - 32px); + max-width: 1000px; + height: calc(100dvh - 32px); + max-height: none; + margin: auto; +} +.interactive-preview.is-expanded .interactive-preview-frame { + flex: 1; + height: auto; + min-height: 0; + max-height: none; +} +.interactive-preview.is-expanded .interactive-preview-source { + flex: 1; + max-height: none; +} +.interactive-preview::backdrop { + background: rgb(0 0 0 / 55%); +} +@media (pointer: coarse) { + .interactive-preview-action { + width: 32px; + height: 36px; + } +} +@media (max-width: 600px) { + .interactive-preview.is-expanded { + width: 100%; + height: 100dvh; + border-radius: 0; + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + } +} + .mermaid-block { padding: 12px; overflow-x: auto; diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 84864096e8..42ba33230e 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -15,6 +15,7 @@ import { buildQuotedSelection } from "@/lib/quoted-selection"; import { dropMentionText, splitDroppedItems, uploadFiles, type DroppedItem } from "@/lib/file-upload-client"; import { MessageView } from "./MessageView"; import { MarkdownBody } from "./MarkdownBody"; +import { InteractiveArtifactsProvider, InteractiveArtifactEntry } from "./InteractiveArtifacts"; import { ChatInput, type ChatInputHandle } from "./ChatInput"; import { ChatMinimap, useMessageRefs } from "./ChatMinimap"; import { ExtensionStatusBar } from "./ExtensionStatusBar"; @@ -1079,6 +1080,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi style={{ visibility: pendingScrollRestore ? "hidden" : undefined }} >
+
{(() => { let lastUserIdx = -1; @@ -1128,6 +1130,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi } if (options.showTimestamp !== undefined) showTimestamp = options.showTimestamp; const view = ( + + ); if (!isVisible || currentRefIdx === undefined) return view; return ( @@ -1318,6 +1322,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi +
{isMobile || pendingScrollRestore ? null : ( diff --git a/components/InteractiveArtifacts.tsx b/components/InteractiveArtifacts.tsx new file mode 100644 index 0000000000..89e5c4ee6a --- /dev/null +++ b/components/InteractiveArtifacts.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import type { AgentMessage, AssistantContentBlock } from "@/lib/types"; +import { mergeArtifactVersions, messageArtifactVersions, type ArtifactVersion } from "@/lib/interactive-artifacts"; +import { useI18n } from "@/hooks/useI18n"; +import { InteractivePreview } from "./InteractivePreview"; +import { RefreshIcon } from "./SidebarIcons"; + +const EntryContext = createContext(undefined); +export const InteractiveArtifactEntry = EntryContext.Provider; + +const ArtifactsContext = createContext<{ + groups: Map; + messages: Map; + url?: string; + error: boolean; + loading: boolean; + retry: () => void; +}>({ groups: new Map(), messages: new Map(), error: false, loading: false, retry() {} }); + +export function useInteractiveArtifactSource(block: AssistantContentBlock, fallbackIndex: number) { + const entry = useContext(EntryContext); + const context = useContext(ArtifactsContext); + const message = entry ? context.messages.get(entry) : undefined; + // Process/final-answer projection can change displayed block indices. + const originalIndex = message?.role === "assistant" ? message.content.indexOf(block) : -1; + return entry ? `${entry}:${originalIndex >= 0 ? originalIndex : fallbackIndex}` : undefined; +} + +export function useOriginalAssistantBlocks() { + const entry = useContext(EntryContext); + const context = useContext(ArtifactsContext); + const message = entry ? context.messages.get(entry) : undefined; + return message?.role === "assistant" ? message.content : undefined; +} + +export function InteractiveArtifactsProvider({ sessionId, leafId, messages, entryIds, children }: { + sessionId?: string; leafId: string | null; messages: AgentMessage[]; entryIds: string[]; children: ReactNode; +}) { + const scope = `${sessionId ?? ""}:${leafId ?? "null"}`; + const local = useMemo(() => messages.flatMap((message, index) => message.role === "assistant" + ? messageArtifactVersions(entryIds[index] ?? `pending-${index}`, message.content) : []), [messages, entryIds]); + const hasArtifacts = local.length > 0; + const sourceMessages = useMemo(() => new Map(messages.map((message, index) => [entryIds[index] ?? `pending-${index}`, message])), [messages, entryIds]); + const [snapshot, setSnapshot] = useState<{ scope: string; versions: ArtifactVersion[]; error: boolean }>({ scope: "", versions: [], error: false }); + const [retry, setRetry] = useState(0); + const url = sessionId ? `/api/sessions/${encodeURIComponent(sessionId)}/artifacts?leafId=${encodeURIComponent(leafId ?? "null")}` : undefined; + useEffect(() => { + if (!url || !hasArtifacts) return; + const controller = new AbortController(); + fetch(url, { signal: controller.signal }).then(async (response) => { + if (!response.ok) throw new Error("Artifact history unavailable"); + const data = await response.json() as { versions: ArtifactVersion[] }; + if (!Array.isArray(data.versions)) throw new Error("Invalid artifact history"); + if (!controller.signal.aborted) setSnapshot({ scope, versions: data.versions, error: false }); + }).catch(() => { + if (!controller.signal.aborted) setSnapshot({ scope, versions: [], error: true }); + }); + return () => controller.abort(); + }, [url, scope, hasArtifacts, retry]); + const groups = useMemo(() => mergeArtifactVersions(snapshot.scope === scope ? snapshot.versions : [], local), [snapshot, scope, local]); + const value = useMemo(() => ({ groups, messages: sourceMessages, url, error: snapshot.scope === scope && snapshot.error, + loading: Boolean(url && hasArtifacts && snapshot.scope !== scope), retry: () => setRetry((value) => value + 1), + }), [groups, sourceMessages, url, snapshot, scope, hasArtifacts]); + return {children}; +} + +export function InteractiveArtifact({ id, sourceKey, code, complete, isStreaming }: { + id?: string; sourceKey?: string; code: string; complete: boolean; isStreaming?: boolean; +}) { + const { t } = useI18n(); + const context = useContext(ArtifactsContext); + const versions = id ? context.groups.get(id) ?? [] : []; + const owner = versions.find((version) => version.key === sourceKey); + const latest = versions[versions.length - 1]; + const [open, setOpen] = useState(false); + const [selection, setSelection] = useState<{ owner: string; key: string } | null>(null); + const [loaded, setLoaded] = useState<{ url: string; version: ArtifactVersion } | null>(null); + const [loadError, setLoadError] = useState(false); + const [loadRetry, setLoadRetry] = useState(0); + const selected = versions.find((version) => version.key === (selection && selection.owner === sourceKey ? selection.key : sourceKey)) ?? owner; + const requestUrl = selected && context.url ? `${context.url}&key=${encodeURIComponent(selected.key)}` : undefined; + const selectedCode = selected?.code ?? (selected?.key === owner?.key && owner ? code : loaded && loaded.url === requestUrl ? loaded.version.code : undefined); + useEffect(() => { + if (!requestUrl || selectedCode !== undefined || (!open && owner?.key !== latest?.key)) return; + const controller = new AbortController(); + fetch(requestUrl, { signal: controller.signal }).then(async (response) => { + if (!response.ok) throw new Error("Artifact version unavailable"); + const data = await response.json() as { version: ArtifactVersion }; + if (data.version?.key !== selected?.key || typeof data.version.code !== "string") throw new Error("Invalid artifact version"); + if (!controller.signal.aborted) { setLoaded({ url: requestUrl, version: data.version }); setLoadError(false); } + }).catch(() => { if (!controller.signal.aborted) setLoadError(true); }); + return () => controller.abort(); + }, [requestUrl, selectedCode, open, owner?.key, latest?.key, selected?.key, loadRetry]); + + if (!id || isStreaming || !complete || !owner) return ; + const header = <> + {id} + + ; + const preview = <> + {context.error &&
+ {t("chat.artifactHistoryError")} + +
} + {selectedCode !== undefined ? 1 ? t("chat.artifactVersionReset") : undefined} /> + :
+
{header}
+
+ {loadError ? t("chat.artifactLoadError") : t("i18n.loading")} + {loadError && } +
+
} + ; + if (owner.key === latest?.key) return preview; + return
setOpen(event.currentTarget.open)}> + {id} · {t("chat.artifactVersionNumber", { version: versions.indexOf(owner) + 1 })} + {open && preview} +
; +} diff --git a/components/InteractivePreview.tsx b/components/InteractivePreview.tsx new file mode 100644 index 0000000000..64dd8f915a --- /dev/null +++ b/components/InteractivePreview.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { copyText } from "@/lib/clipboard"; +import { interactivePreviewHostDocument, interactivePreviewError, interactivePreviewHeight, type InteractivePreviewError } from "@/lib/interactive-preview"; +import { useI18n } from "@/hooks/useI18n"; +import { CloseIcon, EyeIcon, RefreshIcon, SpinnerIcon } from "./SidebarIcons"; +import { CodeBlock } from "./MermaidBlock"; + +export function InteractivePreview({ code, isStreaming, complete, headerSlot, notice }: { + code: string; + isStreaming?: boolean; + complete: boolean; + headerSlot?: ReactNode; + notice?: string; +}) { + const { t } = useI18n(); + const [showSource, setShowSource] = useState(false); + const [expanded, setExpanded] = useState(false); + const [run, setRun] = useState(0); + const [errors, setErrors] = useState([]); + const [copied, setCopied] = useState<"source" | "errors" | null>(null); + const [frameHeight, setFrameHeight] = useState(320); + const dialogRef = useRef(null); + const iframeRef = useRef(null); + const expandRef = useRef(null); + const copyTimer = useRef | undefined>(undefined); + const ready = complete && !isStreaming; + const sourceVisible = showSource; + const documentSource = useMemo(() => interactivePreviewHostDocument(code), [code]); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog || !expanded) return; + const expandButton = expandRef.current; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + dialog.close(); + dialog.showModal(); + expandButton?.focus({ preventScroll: true }); + return () => { + document.body.style.overflow = previousOverflow; + dialog.close(); + if (dialog.isConnected) dialog.show(); + expandButton?.focus({ preventScroll: true }); + }; + }, [expanded]); + + // Subscribe during commit so initial iframe failures cannot outrun the listener. + useLayoutEffect(() => { + const onMessage = (event: MessageEvent) => { + if (!iframeRef.current || event.source !== iframeRef.current.contentWindow) return; + if (event.data?.type === "pi-html:error") { + const error = interactivePreviewError(event.data); + if (error) setErrors((previous) => previous.length >= 5 || previous.some((item) => + item.kind === error.kind && item.message === error.message && item.line === error.line && item.column === error.column) + ? previous : [...previous, error]); + } + if (event.data?.type === "pi-html:escape") setExpanded(false); + if (event.data?.type === "pi-html:resize" && !expanded) { + const height = interactivePreviewHeight(event.data.height); + if (height !== null) setFrameHeight(height); + } + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [expanded]); + + useEffect(() => { + if (!expanded && !showSource) { + iframeRef.current?.contentWindow?.postMessage({ type: "pi-html:measure" }, "*"); + } + }, [expanded, showSource]); + + useEffect(() => () => clearTimeout(copyTimer.current), []); + + const download = () => { + const url = URL.createObjectURL(new Blob([code], { type: "text/html;charset=utf-8" })); + const link = document.createElement("a"); + link.href = url; + link.download = "pi-interactive.html"; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); + }; + + const errorText = errors.map((error) => { + const kind = t(error.kind === "promise" ? "chat.previewPromiseError" : "chat.previewRuntimeError"); + const location = error.line === undefined ? "" : `\n${t("chat.previewErrorLocation", { + line: error.line, column: error.column ?? "?", + })}`; + return `${kind}: ${error.message || t("chat.previewUnknownError")}${location}`; + }).join("\n\n"); + + const copy = async (value: string, target: "source" | "errors") => { + try { + await copyText(value); + setCopied(target); + clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(null), 1500); + } catch { + setCopied(null); + } + }; + + return ( + { + event.preventDefault(); + event.stopPropagation(); + setExpanded(false); + }} + onKeyDown={(event) => { + if (event.key !== "Escape" || !expanded) return; + event.preventDefault(); + event.stopPropagation(); + setExpanded(false); + }} + > +
+ + pi-html + {headerSlot} + {isStreaming && } + +
+ + + + + +
+
+ {notice &&
{notice}
} + {errors.length > 0 &&
+
+ {t("chat.previewError")} + +
+
+ {t("chat.previewErrorDetails")} +
{errorText}
+
+
} + +