From 78542e1a8a63214e2d77c4b85ae4225ef179f6bf Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 17 Aug 2026 22:07:24 -0400 Subject: [PATCH 1/2] feat(viewer): add recent posts home --- .changeset/bright-homes-gather.md | 5 + e2e/viewer.spec.ts | 86 ++++++++++++++- viewer/src/App.tsx | 34 ++++-- viewer/src/Home.tsx | 172 ++++++++++++++++++++++++++++++ viewer/src/api.ts | 21 ++++ viewer/src/state.ts | 57 ++++++++-- viewer/src/styles.css | 128 ++++++++++++++++++++++ 7 files changed, 483 insertions(+), 20 deletions(-) create mode 100644 .changeset/bright-homes-gather.md create mode 100644 viewer/src/Home.tsx 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/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/viewer/src/App.tsx b/viewer/src/App.tsx index 9eb9508..96d62f7 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,8 @@ 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 = () => + !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