Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions app/api/sessions/[id]/artifacts/route.test.mjs
Original file line number Diff line number Diff line change
@@ -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<button>${value}</button>\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, '<button>first</button>');
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');
});
31 changes: 31 additions & 0 deletions app/api/sessions/[id]/artifacts/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
206 changes: 206 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1079,6 +1080,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi
style={{ visibility: pendingScrollRestore ? "hidden" : undefined }}
>
<div style={{ minWidth: 0, padding: `0 ${CHAT_COLUMN_PADDING}px` }}>
<InteractiveArtifactsProvider sessionId={session?.id ?? sessionIdRef.current ?? undefined} leafId={activeLeafId} messages={messages} entryIds={entryIds}>
<div ref={messageContentRef} onPointerUp={captureQuotedSelection} style={{ width: "100%", minWidth: 0, maxWidth: "var(--chat-content-max-width, 820px)", margin: "0 auto" }}>
{(() => {
let lastUserIdx = -1;
Expand Down Expand Up @@ -1128,6 +1130,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi
}
if (options.showTimestamp !== undefined) showTimestamp = options.showTimestamp;
const view = (
<InteractiveArtifactEntry key={`${keyPrefix}-view-${messageKey}`} value={entryIds[idx] ?? `pending-${idx}`}>
<MessageView
key={`${keyPrefix}-view-${messageKey}`}
message={msg}
Expand All @@ -1151,6 +1154,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi
isCompacting={options.recoverTruncation ? isCompacting : undefined}
compactError={options.recoverTruncation ? compactError : undefined}
/>
</InteractiveArtifactEntry>
);
if (!isVisible || currentRefIdx === undefined) return view;
return (
Expand Down Expand Up @@ -1318,6 +1322,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi

<div ref={promptAnchorSpacerRef} aria-hidden="true" />
</div>
</InteractiveArtifactsProvider>
</div>
</div>
{isMobile || pendingScrollRestore ? null : (
Expand Down
Loading