Skip to content
Open
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
23 changes: 23 additions & 0 deletions .changeset/slim-session-hydrate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"sideshow": patch
---

Stop shipping surface bodies the viewer never reads in the session hydrate
response. Opening a session fetches `GET /api/sessions/:id/posts?hydrate=1`,
which served every post through `postDetailView` — the full current surfaces
plus every prior version's surfaces. But a sandboxed surface (`html`, `diff`,
`markdown`, `terminal`, `mermaid`, `code`) renders as an opaque-origin iframe
pointed at `/s/:id?part=N`, which fetches its own body; the viewer only ever
reads `{ id, kind, index }` to build that URL. So the hydrate response carried a
second, unread copy of every surface in the session, and it blocks the stream
from rendering.

`?hydrate=1` now drops just the content field of sandboxed surfaces, and reduces
history surfaces to refs (history is there to size the version dropdown —
choosing an older version re-points each iframe at `?ver=N`, so past bodies are
never rendered from this payload either). Native kinds — `image`, `trace`,
`json` — do render from inline data and are untouched. Across the six largest
sessions in a real store this cut the response 95% raw / 91% gzipped.

`GET /api/posts/:id` and the MCP `read_post` tool still return full detail; only
the session-list hydrate view changed.
37 changes: 37 additions & 0 deletions server/apiViews.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isSandboxedSurfaceKind, SURFACE_CONTENT_FIELDS } from "./types.ts";
import type { Comment, CommentAnchor, Post, Session, Surface } from "./types.ts";

export interface Feedback {
Expand All @@ -23,6 +24,27 @@ export const fullSurfaceView = (surface: Surface, index: number) => ({ ...surfac
export const sessionListSurfaceView = (surface: Surface, index: number) =>
surface.kind === "html" ? surfaceRef(surface, index) : fullSurfaceView(surface, index);

// A sandboxed surface renders as an opaque-origin iframe pointed at
// /s/:id?part=N, which fetches the body itself — so the viewer builds the frame
// from the ref alone and never reads the content field. Shipping the body in the
// session hydrate too made every stream load carry a second, unread copy of every
// surface, the larger half of the response. Drop just that field; the rest of the
// surface (id, kits, …) is small and stays. Native kinds (image/trace/json) DO
// render from inline data, so they keep everything.
export const hydratedSurfaceView = (surface: Surface, index: number) => {
const field = isSandboxedSurfaceKind(surface.kind)
? SURFACE_CONTENT_FIELDS[surface.kind]
: undefined;
if (!field) return fullSurfaceView(surface, index);
// Surface is a union of interfaces, so the content key can't be dropped through
// the union type (no implicit index signature). Widen to a bag, delete the one
// key, and let the result type stay the bag — the shape is kind-dependent and
// this value only ever gets serialized.
const view = { ...surface, index } as unknown as Record<string, unknown>;
delete view[field];
return view;
};

export const postWriteView = (post: Post) => ({
id: post.id,
sessionId: post.sessionId,
Expand All @@ -42,6 +64,21 @@ 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),
})),
});

export const sessionPostListRowView = (post: Post) => {
const surfaces = post.surfaces.map(sessionListSurfaceView);
return {
Expand Down
3 changes: 2 additions & 1 deletion server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
postDetailView,
postWriteView,
recentPostRowView,
sessionPostHydratedView,
sessionPostListRowView,
sessionRowView,
type Feedback,
Expand Down Expand Up @@ -1069,7 +1070,7 @@ export function createApp({
const posts = await store.listPosts(session.id);
return c.json(
c.req.query("hydrate") === "1"
? posts.map(postDetailView)
? posts.map(sessionPostHydratedView)
: posts.map(sessionPostListRowView),
);
};
Expand Down
62 changes: 59 additions & 3 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2320,7 +2320,7 @@ 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 full post details in one response", async () => {
test("GET /api/sessions/:id/posts?hydrate=1 returns every post the viewer needs in one response", async () => {
const app = makeApp();
const created = (await (
await app.request(
Expand All @@ -2338,10 +2338,66 @@ test("GET /api/sessions/:id/posts?hydrate=1 returns full post details in one res
).json()) as any[];
assert.equal(list.length, 1);
assert.equal(list[0].id, created.id);
assert.equal(list[0].surfaces[0].html, "<p>new</p>");
assert.equal(list[0].title, "Hydrated v2");
assert.equal(list[0].version, 2);
// 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);
assert.equal(list[0].history[0].surfaces[0].html, "<p>heavy</p>");
// 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");
});

test("hydrated posts omit sandboxed bodies the viewer never reads, and keep native ones", async () => {
const app = makeApp();
const created = (await (
await app.request(
"/api/posts",
json({
title: "Mixed",
surfaces: [
{ kind: "html", html: "<p>heavy</p>" },
{ kind: "markdown", markdown: "# heavy" },
{ kind: "terminal", text: "heavy" },
{ kind: "json", data: { keep: true } },
],
}),
)
).json()) as any;
await app.request(`/api/posts/${created.id}`, {
...json({ title: "Mixed v2", surfaces: [{ kind: "diff", patch: "--- a\n+++ b\n" }] }),
method: "PUT",
});

const [post] = (await (
await app.request(`/api/sessions/${created.sessionId}/posts?hydrate=1`)
).json()) as any[];

// 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");

// A native surface in the CURRENT version keeps its payload — check via a post
// whose latest version holds one.
const native = (await (
await app.request(
"/api/posts",
json({ title: "Native", surfaces: [{ kind: "json", data: { keep: true } }] }),
)
).json()) as any;
const rows = (await (
await app.request(`/api/sessions/${native.sessionId}/posts?hydrate=1`)
).json()) as any[];
const nativeRow = rows.find((r) => r.id === native.id);
assert.deepEqual(nativeRow.surfaces[0].data, { keep: true });
});

test("read responses expose derived surface indexes and renumber after edits", async () => {
Expand Down
Loading