diff --git a/.changeset/empty-first-post-transition.md b/.changeset/empty-first-post-transition.md new file mode 100644 index 0000000..1d4499e --- /dev/null +++ b/.changeset/empty-first-post-transition.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Keep first-run onboarding visible until the first post arrives and poll as a fallback while waiting. 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..365ac2e 100644 --- a/e2e/viewer.spec.ts +++ b/e2e/viewer.spec.ts @@ -66,6 +66,32 @@ test("snippet published over HTTP appears live via SSE, no reload", async ({ pag await expect(page.locator(".sess-title")).toContainText("e2e session"); }); +test("empty onboarding polls into Home when the first post arrives", async ({ page, server }) => { + await page.route("**/api/events", (route) => route.abort()); + await page.goto(server.url); + await expect(page.locator("#onboard")).toBeVisible(); + + await publish(server.url, { html: "

first

", title: "First post", agent: "e2e" }); + + await expect(page.getByRole("heading", { name: "Home" })).toBeVisible({ timeout: 6_000 }); + await expect(page.locator(".home-card-title")).toHaveText("First post"); + await expect(page.locator("#onboard")).toBeHidden(); +}); + +test("an empty session alone keeps first-run onboarding visible", async ({ page, server }) => { + await fetch(`${server.url}/api/sessions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ agent: "idle", title: "Empty one" }), + }); + + await page.goto(server.url); + + await expect(page.locator("#onboard")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Connect your first agent" })).toBeVisible(); + await expect(page.locator(".sess.sel")).toHaveCount(0); +}); + test("a surface kind this viewer doesn't know shows a refresh hint, not a broken diff", async ({ page, server, @@ -100,6 +126,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 +164,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 +411,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..a23d5f3 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,10 @@ function isConnectPath() { return rest === "/connect"; } const [connectPath, setConnectPath] = createSignal(isConnectPath()); +const hasPosts = () => sessions.some((s) => s.surfaceCount > 0); +const emptyWorkspace = () => initialLoaded() && !selected() && !hasPosts(); +const homePath = () => + !streamMode() && !connectPath() && initialLoaded() && hasPosts() && !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 +303,19 @@ export default function App() { - - - - - + + + + + + + } + > + + } > @@ -696,8 +708,16 @@ function SessionTitle(props: { current: SessionRow | undefined }) { } function Onboard() { + createEffect(() => { + if (!emptyWorkspace() || isReadonly() || connectPath()) return; + const poll = setInterval(() => { + if (!document.hidden) void refreshSessionsQuiet(); + }, 1500); + onCleanup(() => clearInterval(poll)); + }); + return ( -