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: "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() {This sideshow workspace does not have any sessions yet.
+This sideshow workspace does not have any posts yet.
> } > 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: ExtractRecent posts across your sideshow sessions.
+Open a session from the sidebar, or connect an agent to start publishing.
+