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/bright-homes-gather.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Show a recent-posts Home view for first-time visitors to multi-session workspaces, with live updates and safe themed previews.
1 change: 1 addition & 0 deletions e2e/embed-home-view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ test("homeView: a session-less route lands with NO session selected", async ({ p
await expect(page.locator(".sess.sel")).toHaveCount(0);
await expect(page.locator(".sess[aria-current='true']")).toHaveCount(0);
await expect(page.locator(".card:not(#whatsNew)")).toHaveCount(0);
await expect(page.locator(".home-page")).toHaveCount(0);
});

test("homeView OFF (self-hosted default): a session-less route auto-selects the latest", async ({
Expand Down
86 changes: 84 additions & 2 deletions e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,85 @@ 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 live recent posts home", async ({ page, server }) => {
const first = await publish(server.url, {
html: "<h2>first preview</h2>",
title: "First recent",
agent: "alpha",
sessionTitle: "Alpha work",
});
const second = 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", { hasText: "Alpha work" })).toContainText("First recent");
await expect(page.locator("#sessionView")).toHaveCount(0);
const preview = page.locator(".home-preview-frame").first();
await expect(preview).toHaveAttribute("sandbox", "allow-scripts");
await expect(preview).toHaveAttribute("src", /\/s\/.+\?part=0&ver=1&theme=.+&mode=(light|dark)$/);

// First-time Home is stable even when an event leaves only one session.
const removeSecond = await fetch(`${server.url}/api/sessions/${second.sessionId}`, {
method: "DELETE",
});
expect(removeSecond.ok).toBe(true);
await expect(page.locator(".home-card")).toHaveCount(1);
await expect(page.locator(".sess.sel")).toHaveCount(0);

const live = await publish(server.url, {
html: "<h2>live preview</h2>",
title: "Live recent",
agent: "gamma",
sessionTitle: "Gamma work",
});
await expect(page.locator(".home-card")).toHaveCount(2);
await expect(page.locator(".home-card-title").first()).toHaveText("Live recent");

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");

// Returning Home is intentional: later session events must not re-open the
// saved stream, and Home's session metadata must remain live.
await page.locator(".brand:visible").first().click();
await expect(page.getByRole("heading", { name: "Home" })).toBeVisible();
const rename = await fetch(`${server.url}/api/sessions/${first.sessionId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "Renamed Alpha" }),
});
expect(rename.ok).toBe(true);
await expect(page.locator(".home-card", { hasText: "First recent" })).toContainText(
"Renamed Alpha",
);
await expect(page.locator(".sess.sel")).toHaveCount(0);

const remove = await fetch(`${server.url}/api/sessions/${first.sessionId}`, { method: "DELETE" });
expect(remove.ok).toBe(true);
await expect(page.locator(".home-card")).toHaveCount(1);
await expect(page.locator(".home-card", { hasText: "First recent" })).toHaveCount(0);

// The explicit Home choice also remains stable once one session is left.
const renameOnly = await fetch(`${server.url}/api/sessions/${live.sessionId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "Only remaining session" }),
});
expect(renameOnly.ok).toBe(true);
await expect(page.locator(".home-card", { hasText: "Live recent" })).toContainText(
"Only remaining session",
);
await expect(page.locator(".sess.sel")).toHaveCount(0);
});

test("opening a session shows a skeleton while posts load", async ({ page, server }) => {
const first = await publish(server.url, {
html: "<p>slow</p>",
Expand Down Expand Up @@ -626,10 +705,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 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 then 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
19 changes: 17 additions & 2 deletions server/apiViews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,23 @@ export const recentSurfacePreviewView = (surface: Surface, index: number) => ({
index,
});

export const recentPostRowView = (post: Post, session: Session | null | undefined) => {
const surfaces = post.surfaces.map(recentSurfacePreviewView);
// The self-hosted Home uses one preview per post. Rich surfaces render from their
// immutable /s document and trace only needs a kind label, so only image/json
// retain inline data. This bounds Home's 30-row response without weakening the
// full recent-feed contract used by embedders.
export const recentHomeSurfaceView = (surface: Surface, index: number) =>
surface.kind === "image" || surface.kind === "json"
? recentSurfacePreviewView(surface, index)
: surfaceRef(surface, index);

export const recentPostRowView = (
post: Post,
session: Session | null | undefined,
opts?: { homePreview?: boolean },
) => {
const surfaces = opts?.homePreview
? post.surfaces.slice(0, 1).map(recentHomeSurfaceView)
: post.surfaces.map(recentSurfacePreviewView);
return {
id: post.id,
sessionId: post.sessionId,
Expand Down
11 changes: 7 additions & 4 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1056,21 +1056,24 @@ export function createApp({
// agent for the feed card, canonical surfaces, legacy partKinds, and capped
// previews.
//
// Previews are bounded by recentPostRowView (large inline text clipped with
// truncated:true); images travel as plain assetId refs (served at /a/:id),
// so the response stays cheap. Same auth as /api/sessions — see
// Full previews are bounded by recentPostRowView (large inline text clipped
// with truncated:true); `?preview=home` returns only one compact preview per
// post for the self-hosted Home. Same auth as /api/sessions — see
// isPublicReadAllowed, which intentionally does NOT expose this path on a
// session-scoped publicRead workspace.
const listRecentPosts = async (c: any) => {
const limit = parseRecentLimit(c.req.query("limit"));
const homePreview = c.req.query("preview") === "home";
const posts = await store.listRecentPosts(limit);
// Resolve each post's session once (agent + session title for the feed card).
const sessions = new Map<string, Session | null>();
for (const p of posts) {
if (!sessions.has(p.sessionId))
sessions.set(p.sessionId, await store.getSession(p.sessionId));
}
return c.json(posts.map((p) => recentPostRowView(p, sessions.get(p.sessionId))));
return c.json(
posts.map((p) => recentPostRowView(p, sessions.get(p.sessionId), { homePreview })),
);
};
app.get("/api/surfaces/recent", listRecentPosts);
app.get("/api/posts/recent", listRecentPosts);
Expand Down
23 changes: 23 additions & 0 deletions test/surfaces-recent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,29 @@ test("GET /api/surfaces/recent caps oversized text parts and flags truncation",
assert.equal(code.truncated, undefined);
});

test("GET /api/posts/recent?preview=home returns one compact surface per post", async () => {
const app = makeApp();
const s = await createSession(app, "amp");
await publish(app, {
session: s.id,
parts: [
{ kind: "html", html: "x".repeat(20_000) },
{ kind: "markdown", markdown: "# hidden from Home" },
{ kind: "json", data: { also: "hidden from Home" } },
],
});

const feed = (await (await app.request("/api/posts/recent?preview=home")).json()) as any[];
assert.deepEqual(feed[0].partKinds, ["html", "markdown", "json"]);
assert.equal(feed[0].surfaces.length, 1);
assert.deepEqual(feed[0].surfaces[0], {
id: feed[0].surfaces[0].id,
kind: "html",
index: 0,
});
assert.deepEqual(feed[0].parts, feed[0].surfaces);
});

test("GET /api/surfaces/recent leaves image parts as plain assetId refs", async () => {
const app = makeApp();
const s = await createSession(app, "amp");
Expand Down
39 changes: 28 additions & 11 deletions viewer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { host, isShadow, navHostEl, root, SLOTS } from "./host.ts";
import { applyFrameHeight, Card, cardForPost, frameForSource } from "./Card.tsx";
import { ConnectInstructions } from "./Connect.tsx";
import { HomeView, isHomePreviewFrame, resizeHomeFrame } from "./Home.tsx";
import { renderNotes } from "./notes.ts";
import { SessionTimeline } from "./SessionTimeline.tsx";
import { StreamSkeleton } from "./Skeleton.tsx";
Expand Down Expand Up @@ -82,6 +83,13 @@ const [connectPath, setConnectPath] = createSignal(isConnectPath());
// current session's stream. Driven by the host's `layout` (cloud embed) or the
// self-hosted public-read "session" link (see api.ts `layoutMode`).
const streamMode = () => layoutMode() === "stream";
const homePath = () =>
!host().homeView &&
!streamMode() &&
!connectPath() &&
initialLoaded() &&
sessions.length > 0 &&
!selected();

// The wordmark, doubling as a home link: clicking it clears the current session
// and returns to the empty workspace (goHome). A real <button> so it's keyboard- and
Expand Down Expand Up @@ -345,12 +353,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 @@ -492,6 +507,7 @@ async function onBridgeMessage(ev: MessageEvent) {
key?: string;
} | null;
if (!d || !d.__sideshow) return;
const fromHomePreview = isHomePreviewFrame(ev.source);
// Every host-affecting message must come from a frame the viewer actually
// embedded — never an unexpected/nested frame. send-prompt and resize prove
// this implicitly (frameForSource resolves the exact html frame); the
Expand All @@ -500,7 +516,7 @@ async function onBridgeMessage(ev: MessageEvent) {
// by those, but open-link is sent by rich-surface frames too, so use the
// broader check that recognizes any embedded iframe.)
if (d.type === "switch-session") {
if (!isOwnFrame(ev.source)) return;
if (fromHomePreview || !isOwnFrame(ev.source)) return;
if (streamMode()) return;
// A post iframe forwarded the session-switch shortcut because focus was
// inside it (see server/surfacePage.ts). Mirror the parent keydown handler.
Expand All @@ -510,8 +526,9 @@ async function onBridgeMessage(ev: MessageEvent) {
// Resolve the source post + iframe by contentWindow — a post may own
// several html-surface iframes, so resize must target the exact one.
const src = frameForSource(ev.source);
if (d.type === "resize" && src) {
applyFrameHeight(src.iframe, d.height);
if (d.type === "resize") {
if (src) applyFrameHeight(src.iframe, d.height);
else resizeHomeFrame(ev.source, d.height);
} else if (d.type === "send-prompt" && src) {
if (isReadonly()) return;
// sendPrompt is post-originated: a script inside the sandbox can fire it
Expand All @@ -528,7 +545,7 @@ async function onBridgeMessage(ev: MessageEvent) {
body: JSON.stringify({ surface: src.id, text: String(d.text), author: "surface" }),
});
toast("Added to this post’s thread");
} else if (d.type === "open-link" && isOwnFrame(ev.source)) {
} else if (d.type === "open-link" && !fromHomePreview && isOwnFrame(ev.source)) {
// Only ever open real external links. The in-frame click handler forwards
// just http(s) hrefs, but a post can call openLink() directly (or post
// this message raw) with any scheme — javascript:, data:, file: — so
Expand All @@ -545,7 +562,7 @@ async function onBridgeMessage(ev: MessageEvent) {
if (link.protocol !== "http:" && link.protocol !== "https:") return;
if (confirm(`Open external link?\n\n${link.href}`))
window.open(link.href, "_blank", "noopener,noreferrer");
} else if (d.type === "copy" && isOwnFrame(ev.source)) {
} else if (d.type === "copy" && !fromHomePreview && isOwnFrame(ev.source)) {
void navigator.clipboard?.writeText(String(d.text)).catch(() => {});
}
}
Expand Down
Loading
Loading