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/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
94 changes: 92 additions & 2 deletions e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,93 @@ 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 recentLimits: string[] = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (url.pathname === "/api/posts/recent")
recentLimits.push(url.searchParams.get("limit") ?? "");
});

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();
expect(recentLimits).toContain("20");
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 @@ -684,10 +771,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
61 changes: 31 additions & 30 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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 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 @@ -1063,21 +1063,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
Loading
Loading