From d2508181edbcd4ce8d4f1edc1144e7e1102b4f61 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 7 Jul 2026 08:17:51 -0400 Subject: [PATCH 1/2] feat(viewer): add recent posts home --- .changeset/recent-posts-home.md | 5 ++ e2e/viewer.spec.ts | 36 ++++++++- viewer/src/App.tsx | 22 +++-- viewer/src/Home.tsx | 137 ++++++++++++++++++++++++++++++++ viewer/src/api.ts | 20 +++++ viewer/src/state.ts | 19 +++-- viewer/src/styles.css | 128 +++++++++++++++++++++++++++++ 7 files changed, 351 insertions(+), 16 deletions(-) create mode 100644 .changeset/recent-posts-home.md create mode 100644 viewer/src/Home.tsx diff --git a/.changeset/recent-posts-home.md b/.changeset/recent-posts-home.md new file mode 100644 index 0000000..715934c --- /dev/null +++ b/.changeset/recent-posts-home.md @@ -0,0 +1,5 @@ +--- +"sideshow": minor +--- + +Add a recent posts Home view for multi-session workspaces. diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts index c0c236b..6a02943 100644 --- a/e2e/viewer.spec.ts +++ b/e2e/viewer.spec.ts @@ -100,6 +100,33 @@ 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 recent posts home page", async ({ page, server }) => { + const first = await publish(server.url, { + html: "

first preview

", + title: "First recent", + agent: "alpha", + sessionTitle: "Alpha work", + }); + 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").nth(1)).toContainText("Alpha work"); + await expect(page.locator("#sessionView")).toHaveCount(0); + + 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"); +}); + test("opening a session shows a skeleton while posts load", async ({ page, server }) => { const first = await publish(server.url, { html: "

slow

", @@ -111,7 +138,7 @@ test("opening a session shows a skeleton while posts load", async ({ page, serve await new Promise((resolve) => setTimeout(resolve, 500)); await route.continue(); }); - await page.goto(server.url); + await page.goto(`${server.url}/session/${first.sessionId}`); await expect(page.getByRole("status", { name: "Loading posts" })).toBeVisible(); await expect(page.locator(".sk-card")).toHaveCount(3); @@ -358,10 +385,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 first (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 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 bf27943..b89d8fb 100644 --- a/viewer/src/App.tsx +++ b/viewer/src/App.tsx @@ -14,6 +14,7 @@ import { import { host, isShadow, navHostEl, root, SLOTS } from "./host.ts"; import { applyFrameHeight, Card, cardEls, frameForSource } from "./Card.tsx"; import { ConnectInstructions } from "./Connect.tsx"; +import { HomeView } from "./Home.tsx"; import { renderNotes } from "./notes.ts"; import { SessionTimeline } from "./SessionTimeline.tsx"; import { StreamSkeleton } from "./Skeleton.tsx"; @@ -69,6 +70,8 @@ function isConnectPath() { return rest === "/connect"; } const [connectPath, setConnectPath] = createSignal(isConnectPath()); +const homePath = () => + !streamMode() && !connectPath() && initialLoaded() && sessions.length > 0 && !selected(); // Stream-only layout: no sidebar, session list, or session chrome — just the // current session's stream. Driven by the host's `layout` (cloud embed) or the @@ -298,12 +301,19 @@ export default function App() { - - - - - + + + + + + + } + > + + } > diff --git a/viewer/src/Home.tsx b/viewer/src/Home.tsx new file mode 100644 index 0000000..ae6a362 --- /dev/null +++ b/viewer/src/Home.tsx @@ -0,0 +1,137 @@ +import { createResource, For, Index, Show } from "solid-js"; +import { isSandboxedSurfaceKind, SURFACE_FRAME_CLASSES } from "../../server/types.ts"; +import { appPath, getRecentPosts, relTime, type RecentPostRow, type RecentSurface } from "./api.ts"; +import { activeTheme, resolvedMode } from "./theme.ts"; +import { select } from "./state.ts"; +import { StreamSkeleton } from "./Skeleton.tsx"; + +function sessionTitle(post: RecentPostRow): string { + return post.sessionTitle || (post.agent ? `${post.agent} session` : "Untitled session"); +} + +function partSummary(post: RecentPostRow): string { + return post.partKinds.length === 0 ? "post" : post.partKinds.join(" · "); +} + +function postHref(post: RecentPostRow): string { + return appPath(`/session/${encodeURIComponent(post.sessionId)}/p/${encodeURIComponent(post.id)}`); +} + +function surfaceSrc(post: RecentPostRow, surface: RecentSurface): string { + return appPath( + `/s/${post.id}?part=${surface.index}&ver=${post.version}&cb=${post.version}&theme=${activeTheme()}&mode=${resolvedMode()}`, + ); +} + +function JsonPreview(props: { surface: RecentSurface }) { + const text = () => + JSON.stringify(props.surface.kind === "json" ? props.surface.data : null, null, 2); + return
{text()}
; +} + +function ImagePreview(props: { + post: RecentPostRow; + surface: Extract; +}) { + return ( + {props.surface.alt + ); +} + +function NativePreview(props: { post: RecentPostRow; surface: RecentSurface }) { + const surface = () => props.surface; + return ( + ) : null + } + fallback={ + {surface().kind} surface} + > + + + } + > + {(image) => } + + ); +} + +function SurfacePreview(props: { post: RecentPostRow; surface: RecentSurface }) { + const surface = () => props.surface; + return ( + } + > +