Skip to content
Open
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.
5 changes: 5 additions & 0 deletions .changeset/recent-posts-home.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": minor
---

Add a recent posts Home view for multi-session workspaces.
62 changes: 59 additions & 3 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 Expand Up @@ -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: "<h2>first preview</h2>",
title: "First recent",
agent: "alpha",
sessionTitle: "Alpha work",
});
await publish(server.url, {
html: "<h2>second preview</h2>",
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: "<p>slow</p>",
Expand All @@ -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);
Expand Down Expand Up @@ -358,10 +411,13 @@ test("Cmd+Option+Up/Down switches between sessions, wrapping at the ends", async
await publish(server.url, { html: "<p>b</p>", 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");

Expand Down
36 changes: 28 additions & 8 deletions viewer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -298,12 +303,19 @@ export default function App() {
<Show
when={connectPath()}
fallback={
<>
<Show when={!streamMode()}>
<Onboard />
</Show>
<SessionView />
</>
<Show
when={homePath()}
fallback={
<>
<Show when={!streamMode()}>
<Onboard />
</Show>
<SessionView />
</>
}
>
<HomeView />
</Show>
}
>
<ConnectPage />
Expand Down Expand Up @@ -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 (
<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 @@ -709,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
137 changes: 137 additions & 0 deletions viewer/src/Home.tsx
Original file line number Diff line number Diff line change
@@ -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 <pre class="home-json-preview">{text()}</pre>;
}

function ImagePreview(props: {
post: RecentPostRow;
surface: Extract<RecentSurface, { kind: "image" }>;
}) {
return (
<img
class="home-image-preview"
src={appPath(`/a/${encodeURIComponent(props.surface.assetId)}`)}
alt={props.surface.alt ?? props.post.title}
loading="lazy"
/>
);
}

function NativePreview(props: { post: RecentPostRow; surface: RecentSurface }) {
const surface = () => props.surface;
return (
<Show
when={
surface().kind === "image" ? (surface() as Extract<RecentSurface, { kind: "image" }>) : null
}
fallback={
<Show
when={surface().kind === "json"}
fallback={<div class="home-data-preview">{surface().kind} surface</div>}
>
<JsonPreview surface={surface()} />
</Show>
}
>
{(image) => <ImagePreview post={props.post} surface={image()} />}
</Show>
);
}

function SurfacePreview(props: { post: RecentPostRow; surface: RecentSurface }) {
const surface = () => props.surface;
return (
<Show
when={isSandboxedSurfaceKind(surface().kind)}
fallback={<NativePreview post={props.post} surface={surface()} />}
>
<iframe
class={`home-preview-frame ${SURFACE_FRAME_CLASSES[surface().kind] ?? ""}`}
src={surfaceSrc(props.post, surface())}
title={`${props.post.title} preview`}
sandbox="allow-scripts"
loading="lazy"
/>
</Show>
);
}

function HomeCard(props: { post: RecentPostRow }) {
const open = (event: MouseEvent) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0)
return;
event.preventDefault();
void select(props.post.sessionId, { initialPostId: props.post.id });
};
return (
<a class="home-card" href={postHref(props.post)} onClick={open}>
<div class="home-card-head">
<span class="home-card-title">{props.post.title}</span>
<span class="home-card-meta">{relTime(props.post.updatedAt)}</span>
</div>
<div class="home-card-sub">
{sessionTitle(props.post)} · {partSummary(props.post)}
</div>
<div class="home-previews" aria-hidden="true">
<Index each={props.post.surfaces.slice(0, 2)}>
{(surface) => <SurfacePreview post={props.post} surface={surface()} />}
</Index>
</div>
<div class="home-card-foot">View full post →</div>
</a>
);
}

export function HomeView() {
const [recent] = createResource(() => getRecentPosts(30));
const posts = () => recent() ?? [];
return (
<section class="home-page" aria-label="Home">
<header class="home-head">
<h1>Home</h1>
<p>Recent posts across your sideshow sessions.</p>
</header>
<Show when={!recent.loading} fallback={<StreamSkeleton />}>
<Show
when={posts().length > 0}
fallback={
<div class="home-empty">
<h2>No posts yet</h2>
<p>Open a session from the sidebar, or connect an agent to start publishing.</p>
</div>
}
>
<div class="home-feed">
<For each={posts()}>{(post) => <HomeCard post={post} />}</For>
</div>
</Show>
</Show>
</section>
);
}
20 changes: 20 additions & 0 deletions viewer/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ export interface VersionInfo {
notes?: string | null;
}

export type RecentSurface = Surface & { index: number; truncated?: boolean };

export interface RecentPostRow {
id: string;
sessionId: string;
sessionTitle: string | null;
agent: string | null;
title: string;
createdAt: string;
updatedAt: string;
version: number;
surfaces: RecentSurface[];
parts: RecentSurface[];
partKinds: string[];
}

declare global {
interface Window {
// __SIDESHOW_BASE_PATH__ lives in host.ts (the default host reads it).
Expand Down Expand Up @@ -126,6 +142,10 @@ export async function api<T = unknown>(path: string, init?: RequestInit): Promis
return res.json() as Promise<T>;
}

export function getRecentPosts(limit = 30): Promise<RecentPostRow[]> {
return api<RecentPostRow[]>(`/api/posts/recent?limit=${limit}`);
}

export const sessionLabel = (s: Session) => s.title || s.agent + " session";

export function relTime(iso: string): string {
Expand Down
Loading
Loading