Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/empty-first-post-transition.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Keep first-run onboarding visible until the first post arrives and poll as a fallback while waiting.
26 changes: 26 additions & 0 deletions e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p>first</p>", 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,
Expand Down
16 changes: 13 additions & 3 deletions viewer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<div id="onboard" hidden={!initialLoaded() || sessions.length > 0}>
<div id="onboard" hidden={!emptyWorkspace()}>
{/* Host-overridable region (SLOTS.empty): an embedder projects its own
first-run onboarding here. The fallback below is the self-hosted
default — setup snippets that assume a local sideshow on port 8228,
Expand All @@ -719,7 +729,7 @@ function Onboard() {
fallback={
<>
<h1>Nothing here yet</h1>
<p class="sub">This sideshow workspace does not have any sessions yet.</p>
<p class="sub">This sideshow workspace does not have any posts yet.</p>
</>
}
>
Expand Down
15 changes: 8 additions & 7 deletions viewer/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading