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/e2e/viewer.spec.ts b/e2e/viewer.spec.ts index 6a02943..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, diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx index b89d8fb..a23d5f3 100644 --- a/viewer/src/App.tsx +++ b/viewer/src/App.tsx @@ -70,8 +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() && sessions.length > 0 && !selected(); + !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 @@ -706,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 ( -This sideshow workspace does not have any sessions yet.
+This sideshow workspace does not have any posts yet.
> } > diff --git a/viewer/src/state.ts b/viewer/src/state.ts index 40dc315..42bd952 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -233,19 +233,20 @@ export async function refreshSessions(targetPostId?: string | null) { } if (!selected() && sessions.length > 0) { - // Check the route first, then localStorage, then fall back to first session. - // A host that owns a session-less landing (homeView) skips that fallback: it - // honors a deep-linked route session but otherwise stays session-less so the - // host's home shows with nothing selected (no auto-open, no highlight). + // Check the route first, then localStorage, then fall back to the first + // session with posts. Empty sessions alone keep the first-run onboarding up: + // the useful transition is from "connect an agent" to real output. const route = host().router.get(); + const sessionsWithPosts = sessions.filter((s) => s.surfaceCount > 0); const lastId = localStorage.getItem(LAST_SESSION_KEY); - const validLastId = lastId && sessions.some((s) => s.id === lastId) ? lastId : null; + const validLastId = lastId && sessionsWithPosts.some((s) => s.id === lastId) ? lastId : null; const fallback = host().homeView || isConnectRoute() || - (isSessionlessHomeRoute(route) && !validLastId && sessions.length > 1) + sessionsWithPosts.length === 0 || + (isSessionlessHomeRoute(route) && !validLastId && sessionsWithPosts.length > 1) ? null - : validLastId || sessions[0].id; + : validLastId || sessionsWithPosts[0].id; const target = (route.sessionId && sessions.some((s) => s.id === route.sessionId) && route.sessionId) || fallback;