diff --git a/.changeset/bright-homes-gather.md b/.changeset/bright-homes-gather.md new file mode 100644 index 0000000..4c17ebf --- /dev/null +++ b/.changeset/bright-homes-gather.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Show a recent-posts Home view for first-time visitors to multi-session workspaces, with live updates and safe themed previews. diff --git a/e2e/embed-home-view.spec.ts b/e2e/embed-home-view.spec.ts index ea2961d..43eda5a 100644 --- a/e2e/embed-home-view.spec.ts +++ b/e2e/embed-home-view.spec.ts @@ -64,6 +64,7 @@ test("homeView: a session-less route lands with NO session selected", async ({ p await expect(page.locator(".sess.sel")).toHaveCount(0); await expect(page.locator(".sess[aria-current='true']")).toHaveCount(0); await expect(page.locator(".card:not(#whatsNew)")).toHaveCount(0); + await expect(page.locator(".home-page")).toHaveCount(0); }); test("homeView OFF (self-hosted default): a session-less route auto-selects the latest", async ({ diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts index a91a31d..406b90b 100644 --- a/e2e/viewer.spec.ts +++ b/e2e/viewer.spec.ts @@ -368,6 +368,85 @@ test("a surface kind this viewer doesn't know shows a refresh hint, not a broken await expect(card.locator(".diff-error")).toHaveCount(0); }); +test("the workspace root shows a live recent posts home", async ({ page, server }) => { + const first = await publish(server.url, { + html: "

first preview

", + title: "First recent", + agent: "alpha", + sessionTitle: "Alpha work", + }); + const second = await publish(server.url, { + html: "

second preview

", + title: "Second recent", + agent: "beta", + sessionTitle: "Beta work", + }); + + await page.goto(server.url); + + await expect(page.getByRole("heading", { name: "Home" })).toBeVisible(); + await expect(page.locator(".home-card")).toHaveCount(2); + await expect(page.locator(".home-card-title")).toContainText(["Second recent", "First recent"]); + await expect(page.locator(".home-card", { hasText: "Alpha work" })).toContainText("First recent"); + await expect(page.locator("#sessionView")).toHaveCount(0); + const preview = page.locator(".home-preview-frame").first(); + await expect(preview).toHaveAttribute("sandbox", "allow-scripts"); + await expect(preview).toHaveAttribute("src", /\/s\/.+\?part=0&ver=1&theme=.+&mode=(light|dark)$/); + + // First-time Home is stable even when an event leaves only one session. + const removeSecond = await fetch(`${server.url}/api/sessions/${second.sessionId}`, { + method: "DELETE", + }); + expect(removeSecond.ok).toBe(true); + await expect(page.locator(".home-card")).toHaveCount(1); + await expect(page.locator(".sess.sel")).toHaveCount(0); + + const live = await publish(server.url, { + html: "

live preview

", + title: "Live recent", + agent: "gamma", + sessionTitle: "Gamma work", + }); + await expect(page.locator(".home-card")).toHaveCount(2); + await expect(page.locator(".home-card-title").first()).toHaveText("Live recent"); + + await page.locator(".home-card", { hasText: "First recent" }).click(); + await expect(page).toHaveURL(new RegExp(`/session/${first.sessionId}/p/${first.id}$`)); + await expect(page.locator(`.card[data-id="${first.id}"] .card-title`)).toHaveText("First recent"); + + // Returning Home is intentional: later session events must not re-open the + // saved stream, and Home's session metadata must remain live. + await page.locator(".brand:visible").first().click(); + await expect(page.getByRole("heading", { name: "Home" })).toBeVisible(); + const rename = await fetch(`${server.url}/api/sessions/${first.sessionId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "Renamed Alpha" }), + }); + expect(rename.ok).toBe(true); + await expect(page.locator(".home-card", { hasText: "First recent" })).toContainText( + "Renamed Alpha", + ); + await expect(page.locator(".sess.sel")).toHaveCount(0); + + const remove = await fetch(`${server.url}/api/sessions/${first.sessionId}`, { method: "DELETE" }); + expect(remove.ok).toBe(true); + await expect(page.locator(".home-card")).toHaveCount(1); + await expect(page.locator(".home-card", { hasText: "First recent" })).toHaveCount(0); + + // The explicit Home choice also remains stable once one session is left. + const renameOnly = await fetch(`${server.url}/api/sessions/${live.sessionId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "Only remaining session" }), + }); + expect(renameOnly.ok).toBe(true); + await expect(page.locator(".home-card", { hasText: "Live recent" })).toContainText( + "Only remaining session", + ); + await expect(page.locator(".sess.sel")).toHaveCount(0); +}); + test("opening a session shows a skeleton while posts load", async ({ page, server }) => { const first = await publish(server.url, { html: "

slow

", @@ -626,10 +705,13 @@ test("Cmd+Option+Up/Down switches between sessions, wrapping at the ends", async await publish(server.url, { html: "

b

", title: "Second", agent: "two" }); await page.goto(server.url); - // the newest session sits at the top of the list and is selected on load + await expect(page.getByRole("heading", { name: "Home" })).toBeVisible(); + + // With no selected session on Home, Down opens the newest session. + await page.keyboard.press("Meta+Alt+ArrowDown"); await expect(page.locator(".sess.sel .sess-title")).toContainText("two session"); - // Down moves to the next (older) session down the list + // Down then moves to the next (older) session down the list. await page.keyboard.press("Meta+Alt+ArrowDown"); await expect(page.locator(".sess.sel .sess-title")).toContainText("one session"); diff --git a/server/apiViews.ts b/server/apiViews.ts index 82fcf89..dc53f3f 100644 --- a/server/apiViews.ts +++ b/server/apiViews.ts @@ -246,8 +246,23 @@ export const recentSurfacePreviewView = (surface: Surface, index: number) => ({ index, }); -export const recentPostRowView = (post: Post, session: Session | null | undefined) => { - const surfaces = post.surfaces.map(recentSurfacePreviewView); +// The self-hosted Home uses one preview per post. Rich surfaces render from their +// immutable /s document and trace only needs a kind label, so only image/json +// retain inline data. This bounds Home's 30-row response without weakening the +// full recent-feed contract used by embedders. +export const recentHomeSurfaceView = (surface: Surface, index: number) => + surface.kind === "image" || surface.kind === "json" + ? recentSurfacePreviewView(surface, index) + : surfaceRef(surface, index); + +export const recentPostRowView = ( + post: Post, + session: Session | null | undefined, + opts?: { homePreview?: boolean }, +) => { + const surfaces = opts?.homePreview + ? post.surfaces.slice(0, 1).map(recentHomeSurfaceView) + : post.surfaces.map(recentSurfacePreviewView); return { id: post.id, sessionId: post.sessionId, diff --git a/server/app.ts b/server/app.ts index 46196f2..f3a2f17 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1056,13 +1056,14 @@ export function createApp({ // agent for the feed card, canonical surfaces, legacy partKinds, and capped // previews. // - // Previews are bounded by recentPostRowView (large inline text clipped with - // truncated:true); images travel as plain assetId refs (served at /a/:id), - // so the response stays cheap. Same auth as /api/sessions — see + // Full previews are bounded by recentPostRowView (large inline text clipped + // with truncated:true); `?preview=home` returns only one compact preview per + // post for the self-hosted Home. Same auth as /api/sessions — see // isPublicReadAllowed, which intentionally does NOT expose this path on a // session-scoped publicRead workspace. const listRecentPosts = async (c: any) => { const limit = parseRecentLimit(c.req.query("limit")); + const homePreview = c.req.query("preview") === "home"; const posts = await store.listRecentPosts(limit); // Resolve each post's session once (agent + session title for the feed card). const sessions = new Map(); @@ -1070,7 +1071,9 @@ export function createApp({ if (!sessions.has(p.sessionId)) sessions.set(p.sessionId, await store.getSession(p.sessionId)); } - return c.json(posts.map((p) => recentPostRowView(p, sessions.get(p.sessionId)))); + return c.json( + posts.map((p) => recentPostRowView(p, sessions.get(p.sessionId), { homePreview })), + ); }; app.get("/api/surfaces/recent", listRecentPosts); app.get("/api/posts/recent", listRecentPosts); diff --git a/test/surfaces-recent.test.ts b/test/surfaces-recent.test.ts index 3b7815f..1822536 100644 --- a/test/surfaces-recent.test.ts +++ b/test/surfaces-recent.test.ts @@ -188,6 +188,29 @@ test("GET /api/surfaces/recent caps oversized text parts and flags truncation", assert.equal(code.truncated, undefined); }); +test("GET /api/posts/recent?preview=home returns one compact surface per post", async () => { + const app = makeApp(); + const s = await createSession(app, "amp"); + await publish(app, { + session: s.id, + parts: [ + { kind: "html", html: "x".repeat(20_000) }, + { kind: "markdown", markdown: "# hidden from Home" }, + { kind: "json", data: { also: "hidden from Home" } }, + ], + }); + + const feed = (await (await app.request("/api/posts/recent?preview=home")).json()) as any[]; + assert.deepEqual(feed[0].partKinds, ["html", "markdown", "json"]); + assert.equal(feed[0].surfaces.length, 1); + assert.deepEqual(feed[0].surfaces[0], { + id: feed[0].surfaces[0].id, + kind: "html", + index: 0, + }); + assert.deepEqual(feed[0].parts, feed[0].surfaces); +}); + test("GET /api/surfaces/recent leaves image parts as plain assetId refs", async () => { const app = makeApp(); const s = await createSession(app, "amp"); diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx index 9eb9508..f20f7df 100644 --- a/viewer/src/App.tsx +++ b/viewer/src/App.tsx @@ -15,6 +15,7 @@ import { import { host, isShadow, navHostEl, root, SLOTS } from "./host.ts"; import { applyFrameHeight, Card, cardForPost, frameForSource } from "./Card.tsx"; import { ConnectInstructions } from "./Connect.tsx"; +import { HomeView, isHomePreviewFrame, resizeHomeFrame } from "./Home.tsx"; import { renderNotes } from "./notes.ts"; import { SessionTimeline } from "./SessionTimeline.tsx"; import { StreamSkeleton } from "./Skeleton.tsx"; @@ -82,6 +83,13 @@ const [connectPath, setConnectPath] = createSignal(isConnectPath()); // current session's stream. Driven by the host's `layout` (cloud embed) or the // self-hosted public-read "session" link (see api.ts `layoutMode`). const streamMode = () => layoutMode() === "stream"; +const homePath = () => + !host().homeView && + !streamMode() && + !connectPath() && + initialLoaded() && + sessions.length > 0 && + !selected(); // The wordmark, doubling as a home link: clicking it clears the current session // and returns to the empty workspace (goHome). A real