From 3666fde9eb5171e1086f36bd1608c802b1d7aab3 Mon Sep 17 00:00:00 2001 From: Cody Brouwers <11965195+codybrouwers@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:17:16 -0400 Subject: [PATCH 1/2] fix(viewer): title shared pages --- .changeset/title-share-pages.md | 5 +++ e2e/fixtures.ts | 2 +- e2e/public-read.spec.ts | 8 ++++- e2e/url-routing.spec.ts | 44 ++++++++++++++++++++++++++ server/app.ts | 55 ++++++++++++++++++++++++++------- test/api.test.ts | 18 +++++++++++ viewer/src/App.tsx | 23 ++++++++++++-- viewer/src/api.ts | 5 +++ 8 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 .changeset/title-share-pages.md diff --git a/.changeset/title-share-pages.md b/.changeset/title-share-pages.md new file mode 100644 index 0000000..38ddbfd --- /dev/null +++ b/.changeset/title-share-pages.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Use post and session titles for browser tab and share-page titles. diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 260cde3..9a99a7e 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -71,7 +71,7 @@ export { expect }; export async function publish( serverUrl: string, - body: { html: string; title?: string; agent?: string; session?: string }, + body: { html: string; title?: string; agent?: string; session?: string; sessionTitle?: string }, token?: string, ): Promise<{ id: string; sessionId: string; version: number }> { const headers: Record = { "content-type": "application/json" }; diff --git a/e2e/public-read.spec.ts b/e2e/public-read.spec.ts index f002934..985b023 100644 --- a/e2e/public-read.spec.ts +++ b/e2e/public-read.spec.ts @@ -41,7 +41,12 @@ test("readonly session-mode viewer loads without fetching the session list", asy try { const surface = await publish( server.url, - { html: "

session scoped

", title: "Session scoped", agent: "e2e" }, + { + html: "

session scoped

", + title: "Session scoped", + agent: "e2e", + sessionTitle: "Auth refactor", + }, token, ); const sessionListRequests: string[] = []; @@ -56,6 +61,7 @@ test("readonly session-mode viewer loads without fetching the session list", asy await page.goto(`${server.url}/session/${surface.sessionId}`); + await expect(page).toHaveTitle("Auth refactor · sideshow"); await expect(page.locator(".card:not(#whatsNew)")).toBeVisible(); await expect(page.locator(".card-title")).toContainText("Session scoped"); expect(sessionListRequests).toEqual([]); diff --git a/e2e/url-routing.spec.ts b/e2e/url-routing.spec.ts index 717ac9e..986dc4a 100644 --- a/e2e/url-routing.spec.ts +++ b/e2e/url-routing.spec.ts @@ -28,6 +28,50 @@ test("navigating to /session/:id selects that session", async ({ page, server }) await expect(page.locator(".card .card-title")).toHaveText("Second"); }); +test("the browser title follows the selected session", async ({ page, server }) => { + const s1 = await publish(server.url, { + html: "

one

", + title: "First post", + agent: "a1", + sessionTitle: "Auth refactor", + }); + const s2 = await publish(server.url, { + html: "

two

", + title: "Second post", + agent: "a2", + sessionTitle: "Release prep", + }); + + await page.goto(`${server.url}/session/${s1.sessionId}`); + await expect(page).toHaveTitle("Auth refactor · sideshow"); + + await page.locator(`#sessionList .sess[data-id="${s2.sessionId}"]`).click(); + await expect(page).toHaveTitle("Release prep · sideshow"); +}); + +test("the standalone share page title uses the shared post title", async ({ page, server }) => { + const post = await publish(server.url, { + html: "

one

", + title: "First post", + agent: "a1", + sessionTitle: "Auth refactor", + }); + const sessionListRequests: string[] = []; + page.on("request", (req) => { + const url = new URL(req.url()); + if (req.method() === "GET" && url.pathname === "/api/sessions") { + sessionListRequests.push(req.url()); + } + }); + + await page.goto(`${server.url}/s/${post.id}`); + await expect(page).toHaveTitle("First post"); + + await publish(server.url, { html: "

two

", title: "Other work", agent: "a2" }); + await expect.poll(() => sessionListRequests.length).toBeGreaterThan(0); + await expect(page).toHaveTitle("First post"); +}); + test("navigating to /session/:id/s/:surfaceId selects session and scrolls to surface", async ({ page, server, diff --git a/server/app.ts b/server/app.ts index 5e97895..1aad8c8 100644 --- a/server/app.ts +++ b/server/app.ts @@ -24,6 +24,7 @@ import { type MarkdownSurface, MAX_ASSET_BYTES, surfacesByteLength, + type Session, type Store, type Post, type Surface, @@ -651,9 +652,30 @@ export function createApp({ : `${head}${text}`; }; - const withViewerConfig = (text: string, request: Request, isReadonly: boolean) => { + const withDocumentTitle = (text: string, title: string | null | undefined) => { + if (!title) return text; + const escaped = escapeHtml(title); + const titleTag = `${escaped}`; + return /.*?<\/title>/.test(text) + ? text.replace(/<title>.*?<\/title>/, titleTag) + : injectHead(text, titleTag); + }; + + const sessionDocumentTitle = (session: Session | null | undefined) => { + if (!session) return null; + const label = session.title || (session.agent ? `${session.agent} session` : null); + return label ? `${label} · sideshow` : null; + }; + + const withViewerConfig = ( + text: string, + request: Request, + isReadonly: boolean, + pageTitle?: string | null, + ) => { const config = [ `window.__SIDESHOW_BASE_PATH__=${JSON.stringify(requestBasePath(request))};`, + pageTitle ? `window.__SIDESHOW_PAGE_TITLE__=${JSON.stringify(pageTitle)};` : "", isReadonly ? "window.__SIDESHOW_READONLY__=true;" : "", isReadonly && publicRead ? `window.__SIDESHOW_PUBLIC_READ__=${JSON.stringify(publicRead)};` @@ -686,31 +708,40 @@ export function createApp({ ].join("\n"); }; - const configuredViewerHtml = (c: Context, surface?: Post) => { - const configured = withViewerConfig( - withOrigin(viewerHtml, { req: { url: c.req.url } }), - c.req.raw, - !!publicRead && !isAuthenticated(c), + const configuredViewerHtml = ( + c: Context, + opts: { surface?: Post; title?: string | null } = {}, + ) => { + const pageTitle = opts.surface?.title ?? opts.title; + const html = withDocumentTitle( + withViewerConfig( + withOrigin(viewerHtml, { req: { url: c.req.url } }), + c.req.raw, + !!publicRead && !isAuthenticated(c), + pageTitle, + ), + pageTitle, ); - return surface ? injectHead(configured, surfacePreviewHead(surface, c.req.raw)) : configured; + return opts.surface ? injectHead(html, surfacePreviewHead(opts.surface, c.req.raw)) : html; }; app.get("/", (c) => c.html(configuredViewerHtml(c))); app.get("/session/:id", async (c) => { - if (isUnauthenticatedSessionRead(c) && !(await store.getSession(c.req.param("id")))) { + const session = await store.getSession(c.req.param("id")); + if (isUnauthenticatedSessionRead(c) && !session) { return c.text("Session not found", 404); } - return c.html(configuredViewerHtml(c)); + return c.html(configuredViewerHtml(c, { title: sessionDocumentTitle(session) })); }); const sessionSurfacePage = async (c: any) => { + const session = await store.getSession(c.req.param("id")); if (isUnauthenticatedSessionRead(c)) { - const session = await store.getSession(c.req.param("id")); const surfaceId = c.req.param("surfaceId") ?? c.req.param("postId"); const surface = await store.getPost(surfaceId ?? ""); if (!session || !surface || surface.sessionId !== session.id) { return c.text("Session or surface not found", 404); } } - return c.html(configuredViewerHtml(c)); + return c.html(configuredViewerHtml(c, { title: sessionDocumentTitle(session) })); }; app.get("/session/:id/s/:surfaceId", sessionSurfacePage); app.get("/session/:id/p/:postId", sessionSurfacePage); // canonical alias @@ -1098,7 +1129,7 @@ export function createApp({ const surface = await store.getPost(c.req.param("id")); if (!surface) return c.text("Post not found", 404); const partParam = c.req.query("surface") ?? c.req.query("part"); - if (partParam == null) return c.html(configuredViewerHtml(c, surface)); + if (partParam == null) return c.html(configuredViewerHtml(c, { surface })); const ver = c.req.query("ver"); let title = surface.title; diff --git a/test/api.test.ts b/test/api.test.ts index 334def2..bf32a64 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -182,6 +182,7 @@ test("GET /s/:id serves the viewer shell with link-preview metadata", async () = const body = await page.text(); assert.ok(body.includes("viewer"), "should serve the trusted viewer shell"); assert.doesNotMatch(body, /<p>diagram<\/p>/, "should not inline agent HTML"); + assert.match(body, /<title>Auth Flow<\/title>/); assert.match(body, /<meta property="og:title" content="Auth Flow">/); assert.match(body, /<meta name="twitter:title" content="Auth Flow">/); assert.match(body, /<meta property="og:description" content="A https:\/\/sideshow\.sh surface">/); @@ -192,6 +193,22 @@ test("GET /s/:id serves the viewer shell with link-preview metadata", async () = assert.doesNotMatch(body, /Secret session/); }); +test("GET /session/:id serves the viewer shell with the session title", async () => { + const app = makeApp(); + const res = await app.request( + "/api/snippets", + json({ html: "<p>x</p>", title: "Post", sessionTitle: "Auth refactor" }), + ); + const surface = (await res.json()) as any; + + const page = await app.request(`/session/${surface.sessionId}`); + assert.equal(page.status, 200); + assert.ok(page.headers.get("content-type")?.includes("text/html")); + const body = await page.text(); + assert.ok(body.includes("viewer"), "should serve the trusted viewer shell"); + assert.match(body, /<title>Auth refactor · sideshow<\/title>/); +}); + test("GET /s/:id emits absolute token-free canonical and preview image URLs", async () => { const app = makeApp("secret"); const res = await app.request( @@ -246,6 +263,7 @@ test("GET /s/:id escapes surface metadata in preview tags", async () => { body, /<meta property="og:title" content="A "quoted" <tag> & more">/, ); + assert.match(body, /<title>A "quoted" <tag> & more<\/title>/); assert.doesNotMatch(body, /content="A "quoted" <tag> & more"/); }); diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx index b57c2d7..22c82bd 100644 --- a/viewer/src/App.tsx +++ b/viewer/src/App.tsx @@ -2,6 +2,7 @@ import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } import { AgentMark } from "./agentMarks.tsx"; import { api, + initialPageTitle, isReadonly, layoutMode, relTime, @@ -71,6 +72,19 @@ function Brand() { ); } +function pageTitle( + post: Post | null, + session: SessionRow | undefined, + unreadCount: number, + serverTitle: string | undefined, +) { + if (post) return post.title || "sideshow"; + const sessionTitle = + session && (session.title || session.agent) ? `${sessionLabel(session)} · sideshow` : null; + const base = sessionTitle || serverTitle || "sideshow"; + return unreadCount > 0 ? `(${unreadCount}) ${base}` : base; +} + export default function App() { // Escape closes the integrations modal while it is open. createEffect(() => { @@ -141,9 +155,12 @@ export default function App() { // post instead (set below), so don't fight it here. createEffect(() => { if (isShadow()) return; - const solo = standalonePost(); - if (solo) document.title = solo.title ? `${solo.title} · sideshow` : "sideshow"; - else document.title = unread().size ? `(${unread().size}) sideshow` : "sideshow"; + document.title = pageTitle( + standalonePost(), + sessions.find((s) => s.id === selected()), + unread().size, + initialPageTitle(), + ); }); // the mobile drawer slides in via a class on the host element (see styles.css // `body.nav-open`; self-hosted that element is <body>) diff --git a/viewer/src/api.ts b/viewer/src/api.ts index 9e22ac2..ecccf47 100644 --- a/viewer/src/api.ts +++ b/viewer/src/api.ts @@ -58,6 +58,7 @@ declare global { __SIDESHOW_READONLY__?: boolean; __SIDESHOW_PUBLIC_READ__?: PublicReadMode; __SIDESHOW_SCREENSHOTS__?: boolean; + __SIDESHOW_PAGE_TITLE__?: string; } } @@ -81,6 +82,10 @@ export function publicReadMode(): PublicReadMode | undefined { return window.__SIDESHOW_PUBLIC_READ__; } +export function initialPageTitle(): string | undefined { + return window.__SIDESHOW_PAGE_TITLE__; +} + // The engine's layout. "full" shows the sidebar + stream; "stream" shows only // the current session's stream (no sidebar/session list). An embedder requests // it through the host; the self-hosted public-read "session" link maps to From d8e2d0242113871957f627fcc55c9ffd075a356b Mon Sep 17 00:00:00 2001 From: Cody Brouwers <11965195+codybrouwers@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:34:37 -0400 Subject: [PATCH 2/2] fix(viewer): address title ci --- e2e/viewer.spec.ts | 6 +++--- server/app.ts | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts index 9ab0621..5c8fa55 100644 --- a/e2e/viewer.spec.ts +++ b/e2e/viewer.spec.ts @@ -275,13 +275,13 @@ test("activity in an unselected session badges the tab title until viewed", asyn await publish(server.url, { html: "<p>a</p>", title: "First", agent: "one" }); await page.goto(server.url); - await expect(page).toHaveTitle("sideshow"); + await expect(page).toHaveTitle("one session · sideshow"); await publish(server.url, { html: "<p>b</p>", title: "Second", agent: "two" }); - await expect(page).toHaveTitle("(1) sideshow"); + await expect(page).toHaveTitle("(1) one session · sideshow"); await page.locator(".sess", { hasText: "two" }).click(); - await expect(page).toHaveTitle("sideshow"); + await expect(page).toHaveTitle("two session · sideshow"); }); test("Cmd+Option+Up/Down switches between sessions, wrapping at the ends", async ({ diff --git a/server/app.ts b/server/app.ts index 1aad8c8..cbb37b8 100644 --- a/server/app.ts +++ b/server/app.ts @@ -656,9 +656,11 @@ export function createApp({ if (!title) return text; const escaped = escapeHtml(title); const titleTag = `<title>${escaped}`; - return /.*?<\/title>/.test(text) - ? text.replace(/<title>.*?<\/title>/, titleTag) - : injectHead(text, titleTag); + const titleStart = text.indexOf("<title>"); + if (titleStart < 0) return injectHead(text, titleTag); + const titleEnd = text.indexOf("", titleStart + "".length); + if (titleEnd < 0) return injectHead(text, titleTag); + return `${text.slice(0, titleStart)}${titleTag}${text.slice(titleEnd + "".length)}`; }; const sessionDocumentTitle = (session: Session | null | undefined) => {