Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/compact-live-viewer-posts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Stop live viewer updates from downloading a post's complete revision history. Live post refetches now use an explicit compact viewer representation with current render data and a retained-version count, while the existing post detail endpoints keep returning full history.
68 changes: 49 additions & 19 deletions e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,20 +339,23 @@ test("a surface kind this viewer doesn't know shows a refresh hint, not a broken
// server returns a valid surface, but rewrite the surface kind to one THIS
// viewer build has no Match for. It must degrade to a neutral hint, never
// the diff fallback.
await page.route(/\/api\/(posts\/[^/?]+|sessions\/[^/]+\/posts)(\?|$)/, async (route) => {
const res = await route.fetch();
const body = await res.json();
const rewrite = (post: any) => {
if (Array.isArray(post.surfaces)) {
post.surfaces = post.surfaces.map(() => ({ kind: "futurething" }));
}
return post;
};
await route.fulfill({
response: res,
json: Array.isArray(body) ? body.map(rewrite) : rewrite(body),
});
});
await page.route(
/\/api\/(posts\/[^/?]+(?:\/viewer)?|sessions\/[^/]+\/posts)(\?|$)/,
async (route) => {
const res = await route.fetch();
const body = await res.json();
const rewrite = (post: any) => {
if (Array.isArray(post.surfaces)) {
post.surfaces = post.surfaces.map(() => ({ kind: "futurething" }));
}
return post;
};
await route.fulfill({
response: res,
json: Array.isArray(body) ? body.map(rewrite) : rewrite(body),
});
},
);

await page.goto(server.url);
// wait until the page is loaded and its SSE is connected, so the publish
Expand Down Expand Up @@ -815,16 +818,43 @@ test("the Connect an agent page is reachable directly when sessions already exis
await expect(page.locator(".connect-page")).toContainText(`npx add-mcp ${server.url}/mcp`);
});

test("version select appears live after an update", async ({ page, server }) => {
const snippet = await publish(server.url, { html: "<p>v1</p>", title: "Doc", agent: "e2e" });
test("live creates and updates fetch compact viewer posts with retained versions", async ({
page,
server,
}) => {
const first = await publish(server.url, {
html: "<p>existing</p>",
title: "Existing",
agent: "e2e",
});
const compactRequests: string[] = [];
const fullDetailRequests: string[] = [];
page.on("request", (request) => {
if (request.method() !== "GET") return;
const path = new URL(request.url()).pathname;
if (/^\/api\/posts\/[^/]+\/viewer$/.test(path)) compactRequests.push(path);
if (/^\/api\/posts\/[^/]+$/.test(path)) fullDetailRequests.push(path);
});

await page.goto(server.url);
await page.goto(`${server.url}/session/${first.sessionId}`);
await expect(page.locator(".card .vbadge")).toHaveText("v1");

await update(server.url, snippet.id, { html: "<p>v2</p>" });
const live = await publish(server.url, {
html: "<p>v1</p>",
title: "Live compact",
agent: "e2e",
session: first.sessionId,
});
const liveCard = page.locator(`.card[data-id="${live.id}"]`);
await expect(liveCard.locator(".card-title")).toHaveText("Live compact");

await update(server.url, live.id, { html: "<p>v2</p>", title: "Live compact v2" });

const select = page.locator("select.vbadge");
await expect(liveCard.locator(".card-title")).toHaveText("Live compact v2");
const select = liveCard.locator("select.vbadge");
await expect(select).toBeVisible();
await expect(select).toHaveValue("2");
await expect(select.locator("option")).toHaveText(["v2", "v1"]);
await expect.poll(() => compactRequests.filter((path) => path.includes(live.id)).length).toBe(2);
expect(fullDetailRequests).toEqual([]);
});
51 changes: 38 additions & 13 deletions server/apiViews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,21 +64,46 @@ export const postDetailView = (post: Post) => ({
})),
});

// One session's whole stream, hydrated in a single response (`?hydrate=1`). Same
// envelope as postDetailView — the viewer identifies a hydrated row by `history`
// being an array — minus the bodies it never reads. History is here only to size
// the version dropdown (`history.length`): picking an older version just re-points
// each iframe at /s/:id?part=N&ver=N, so past surfaces are never rendered from
// this payload and reduce to refs.
export const sessionPostHydratedView = (post: Post) => ({
...post,
surfaces: post.surfaces.map(hydratedSurfaceView),
history: post.history.map((version) => ({
...version,
surfaces: version.surfaces.map(surfaceRef),
})),
// The current surface metadata/data the viewer renders. Sandboxed kinds omit
// their body (the iframe fetches it from /s/:id); native kinds keep their inline
// data. Extra kind-specific fields are intentionally open-ended so a newer
// server can send metadata an older viewer safely ignores.
export interface ViewerSurface {
id?: string;
kind: Surface["kind"];
index: number;
[key: string]: unknown;
}

// Compact post representation used only by the live viewer. versionCount is the
// number of retained/renderable versions INCLUDING current; it can be lower than
// `version` after HISTORY_LIMIT rolls old revisions out of the store.
export interface ViewerPost {
id: string;
sessionId: string;
title: string;
surfaces: ViewerSurface[];
createdAt: string;
updatedAt: string;
version: number;
versionCount: number;
}

export const viewerPostView = (post: Post): ViewerPost => ({
id: post.id,
sessionId: post.sessionId,
title: post.title,
surfaces: post.surfaces.map(hydratedSurfaceView) as ViewerSurface[],
createdAt: post.createdAt,
updatedAt: post.updatedAt,
version: post.version,
versionCount: post.history.length + 1,
});

// One session's whole stream, hydrated in a single response (`?hydrate=1`). It
// uses the same compact wire contract as the per-post live-update route.
export const sessionPostHydratedView = viewerPostView;

export const sessionPostListRowView = (post: Post) => {
const surfaces = post.surfaces.map(sessionListSurfaceView);
return {
Expand Down
9 changes: 9 additions & 0 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
sessionPostHydratedView,
sessionPostListRowView,
sessionRowView,
viewerPostView,
type Feedback,
} from "./apiViews.ts";
import { EventBus, type FeedEvent } from "./events.ts";
Expand Down Expand Up @@ -1129,6 +1130,14 @@ export function createApp({
if (!post) return c.json({ error: "post not found" }, 404);
return c.json(postDetailView(post));
};
// Viewer-only projection for live create/update refetches. Keep this a
// canonical post subresource: the legacy detail aliases remain byte-for-byte
// on the full postDetailView contract above.
app.get("/api/posts/:id/viewer", async (c) => {
const post = await store.getPost(c.req.param("id"));
if (!post) return c.json({ error: "post not found" }, 404);
return c.json(viewerPostView(post));
});
app.get("/api/surfaces/:id", getPost); // legacy alias
app.get("/api/posts/:id", getPost);
app.get("/api/snippets/:id", getPost); // legacy alias
Expand Down
91 changes: 78 additions & 13 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { join } from "node:path";
import { test } from "node:test";
import { createApp } from "../server/app.ts";
import { JsonFileStore } from "../server/storage.ts";
import type { Store } from "../server/types.ts";
import { HISTORY_LIMIT, type Store } from "../server/types.ts";

function makeApp(
authToken?: string,
Expand Down Expand Up @@ -2370,7 +2370,78 @@ test("GET /api/sessions/:id/posts lists lean surfaces with ids and omitted html
assert.deepEqual(list[0].parts, list[0].surfaces, "legacy parts aliases surfaces");
});

test("GET /api/sessions/:id/posts?hydrate=1 returns every post the viewer needs in one response", async () => {
test("GET /api/posts/:id/viewer is compact while canonical and legacy details stay full", async () => {
const app = makeApp();
const created = (await (
await app.request(
"/api/posts",
json({
title: "Viewer v1",
surfaces: [
{ kind: "html", html: "<p>historical body</p>" },
{ kind: "json", data: { old: true } },
],
}),
)
).json()) as any;
await app.request(`/api/posts/${created.id}`, {
...json({
title: "Viewer v2",
surfaces: [
{ kind: "markdown", markdown: "# current body" },
{ kind: "json", data: { keep: true } },
{ kind: "image", assetId: "asset-current", alt: "kept image metadata" },
{ kind: "trace", steps: [{ label: "kept trace data" }] },
],
}),
method: "PUT",
});

const compact = (await (await app.request(`/api/posts/${created.id}/viewer`)).json()) as any;
assert.equal(compact.id, created.id);
assert.equal(compact.title, "Viewer v2");
assert.equal(compact.version, 2);
assert.equal(compact.versionCount, 2);
assert.ok(!("history" in compact), "compact viewer response omits history entirely");
assert.ok(!("markdown" in compact.surfaces[0]), "sandboxed current body is omitted");
assert.deepEqual(compact.surfaces[1].data, { keep: true }, "JSON data is retained");
assert.equal(compact.surfaces[2].assetId, "asset-current", "image metadata is retained");
assert.equal(compact.surfaces[3].steps[0].label, "kept trace data", "trace data is retained");

const canonical = (await (await app.request(`/api/posts/${created.id}`)).json()) as any;
const legacySurface = (await (await app.request(`/api/surfaces/${created.id}`)).json()) as any;
const legacySnippet = (await (await app.request(`/api/snippets/${created.id}`)).json()) as any;
assert.deepEqual(legacySurface, canonical);
assert.deepEqual(legacySnippet, canonical);
assert.equal((await app.request(`/api/surfaces/${created.id}/viewer`)).status, 404);
assert.equal((await app.request(`/api/snippets/${created.id}/viewer`)).status, 404);
assert.equal(canonical.history[0].surfaces[0].html, "<p>historical body</p>");
assert.equal(canonical.surfaces[0].markdown, "# current body");
});

test("viewer versionCount is capped to retained history plus current", async () => {
const app = makeApp();
const created = (await (
await app.request(
"/api/posts",
json({ title: "Rolling", surfaces: [{ kind: "html", html: "<p>v1</p>" }] }),
)
).json()) as any;
for (let version = 2; version <= HISTORY_LIMIT + 3; version++) {
const response = await app.request(`/api/posts/${created.id}`, {
...json({ title: `Rolling v${version}` }),
method: "PUT",
});
assert.equal(response.status, 200);
}

const compact = (await (await app.request(`/api/posts/${created.id}/viewer`)).json()) as any;
assert.equal(compact.version, HISTORY_LIMIT + 3);
assert.equal(compact.versionCount, HISTORY_LIMIT + 1);
assert.ok(compact.versionCount < compact.version, "lifetime version can exceed retained count");
});

test("GET /api/sessions/:id/posts?hydrate=1 returns compact ViewerPosts", async () => {
const app = makeApp();
const created = (await (
await app.request(
Expand All @@ -2390,14 +2461,12 @@ test("GET /api/sessions/:id/posts?hydrate=1 returns every post the viewer needs
assert.equal(list[0].id, created.id);
assert.equal(list[0].title, "Hydrated v2");
assert.equal(list[0].version, 2);
assert.equal(list[0].versionCount, 2);
assert.ok(!("history" in list[0]), "hydrate omits history entirely");
// The frame ref survives — it's what /s/:id?part=N is built from.
assert.equal(list[0].surfaces[0].kind, "html");
assert.equal(list[0].surfaces[0].index, 0);
// History is present (the viewer keys "hydrated" off it) and long enough to
// size the version dropdown, but carries no bodies.
assert.equal(list[0].history.length, 1);
assert.equal(list[0].history[0].surfaces[0].index, 0);
assert.equal(list[0].history[0].surfaces[0].kind, "html");
assert.ok(!("html" in list[0].surfaces[0]), "sandboxed current body is omitted");
});

test("hydrated posts omit sandboxed bodies the viewer never reads, and keep native ones", async () => {
Expand Down Expand Up @@ -2428,12 +2497,8 @@ test("hydrated posts omit sandboxed bodies the viewer never reads, and keep nati
// Sandboxed kinds render in an iframe that fetches its own body from
// /s/:id?part=N — the content key is absent, not empty.
assert.ok(!("patch" in post.surfaces[0]), "diff patch body is absent");
// Native kinds render from inline data and must survive intact.
const [older] = post.history;
assert.ok(!("html" in older.surfaces[0]), "history html body is absent");
assert.ok(!("markdown" in older.surfaces[1]), "history markdown body is absent");
assert.ok(!("text" in older.surfaces[2]), "history terminal body is absent");
assert.ok(!("data" in older.surfaces[3]), "history drops native bodies too");
assert.ok(!("history" in post), "history is represented only by versionCount");
assert.equal(post.versionCount, 2);

// A native surface in the CURRENT version keeps its payload — check via a post
// whose latest version holds one.
Expand Down
7 changes: 5 additions & 2 deletions viewer/src/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
type JsonSurface as JsonSurfaceData,
type Post,
type TraceSurface as TraceSurfaceData,
type ViewerPost,
postLink,
postImageLink,
} from "./api.ts";
Expand Down Expand Up @@ -198,7 +199,7 @@ function pollScrollIntoView(el: HTMLElement, postId: string): () => void {
};
}

export function Card(props: { post: Post; standalone?: boolean }) {
export function Card(props: { post: Post | ViewerPost; standalone?: boolean }) {
let card!: HTMLDivElement;
let fullscreenDialog: HTMLDivElement | undefined;
let fullscreenCloseButton: HTMLButtonElement | undefined;
Expand Down Expand Up @@ -362,8 +363,10 @@ export function Card(props: { post: Post; standalone?: boolean }) {
});

const versionRange = (latest: number) => {
const versionCount =
"versionCount" in props.post ? props.post.versionCount : props.post.history.length + 1;
const out = [];
for (let v = latest; v >= Math.max(1, latest - props.post.history.length); v--) out.push(v);
for (let v = latest; v >= Math.max(1, latest - versionCount + 1); v--) out.push(v);
return out;
};

Expand Down
8 changes: 4 additions & 4 deletions viewer/src/SessionTimeline.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createMemo, createSignal, For, Show } from "solid-js";
import type { Post, TraceStep } from "./api.ts";
import type { TraceStep, ViewerPost } from "./api.ts";
import { Card } from "./Card.tsx";
import { streamLoading, posts, traceSteps } from "./state.ts";

Expand All @@ -13,14 +13,14 @@ import { streamLoading, posts, traceSteps } from "./state.ts";
// with posts by time.

interface Gap {
post: Post | null; // the post this gap leads into; null = trailing
post: ViewerPost | null; // the post this gap leads into; null = trailing
steps: TraceStep[];
}

function buildGaps(postList: readonly Post[], steps: readonly TraceStep[]): Gap[] {
function buildGaps(postList: readonly ViewerPost[], steps: readonly TraceStep[]): Gap[] {
const gaps: Gap[] = postList.map((s) => ({ post: s, steps: [] }));
gaps.push({ post: null, steps: [] });
const at = (s: Post) => Date.parse(s.createdAt);
const at = (s: ViewerPost) => Date.parse(s.createdAt);
for (const step of steps) {
const t = step.ts ? Date.parse(step.ts) : NaN;
let idx = gaps.length - 1; // default: trailing
Expand Down
2 changes: 2 additions & 0 deletions viewer/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
TraceSurface,
TraceStep,
} from "../../server/types.ts";
import type { ViewerPost } from "../../server/apiViews.ts";
import { host } from "./host.ts";

export type {
Expand All @@ -34,6 +35,7 @@ export type {
TerminalSurface,
TraceSurface,
TraceStep,
ViewerPost,
};

export type PublicReadMode = "session" | "full";
Expand Down
Loading
Loading