Skip to content

Commit afa0558

Browse files
authored
feat: deep-linkable session and surface URLs (#78)
* feat(server): serve the viewer SPA at /session/:id and /session/:id/s/:surfaceId Add two new GET routes that serve the same viewer HTML document as the root (/). This lets the SPA handle deep links to specific sessions and surfaces — the server does not validate the ids, leaving resolution to the client. Routes: /session/:id — viewer with a session pre-selected /session/:id/s/:surfaceId — viewer with session + surface focused The standalone surface route (/s/:id) is unchanged. Both routes respect auth when SIDESHOW_TOKEN is configured, matching the existing behavior of the root route. Covered by four new unit tests in test/api.test.ts. * feat(viewer): sync URL with session/surface navigation The viewer URL now reflects what the user is looking at, making every position in the board a shareable, resumable deep link. URL scheme: /session/:id — session selected in the sidebar /session/:id/s/:surfaceId — session + surface in view / — redirects to last-viewed session (localStorage) /s/:id — standalone surface (unchanged) Behavior: - Clicking a session in the sidebar pushes /session/:id. - Scrolling past surfaces updates /session/:id/s/:surfaceId via replaceState (IntersectionObserver at 50% threshold), so the address bar always reflects the visible card without polluting browser history. - Navigating directly to a session/surface URL selects the session and scrolls to the surface on load. - The root (/) checks the URL, then localStorage, then falls back to the first session — using replaceState so the initial redirect does not create an extra history entry. - Browser back/forward triggers popstate, which re-selects the session without pushing a duplicate entry. Covered by seven new e2e tests in e2e/url-routing.spec.ts. * fix(viewer): drop scroll-to-surface on deep link, navigate to session top Surface deep links (/session/:id/s/:surfaceId) now navigate to the session and show it from the top instead of attempting to scroll to the specific surface. The surface suffix is still preserved in the URL for shareability. scrollIntoView is unreliable here: cards contain sandboxed iframes whose height resolves asynchronously, so cards above the target shift the layout after the scroll fires. Rather than add fragile retry/timing hacks, accept the simpler behavior — the session-level deep link is the main value, and the surface id in the URL still updates live as the user scrolls. * chore: add changeset for URL routing feature
1 parent 3db3289 commit afa0558

7 files changed

Lines changed: 252 additions & 3 deletions

File tree

.changeset/url-routing.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"sideshow": minor
3+
---
4+
5+
Deep-linkable URLs: the viewer URL now reflects the current session and surface (`/session/:id`, `/session/:id/s/:surfaceId`). Clicking sessions pushes browser history, scrolling updates the surface in the URL via `replaceState`, and `/` redirects to the last-viewed session. Back/forward navigation works across sessions.

e2e/url-routing.spec.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { expect, publish, test } from "./fixtures.ts";
2+
3+
test("clicking a session updates the URL to /session/:id", async ({ page, server }) => {
4+
const s1 = await publish(server.url, { html: "<p>one</p>", title: "First", agent: "a1" });
5+
const s2 = await publish(server.url, { html: "<p>two</p>", title: "Second", agent: "a2" });
6+
await page.goto(server.url);
7+
await expect(page.locator("#sessionList .sess")).toHaveCount(2);
8+
9+
// click the second session row
10+
await page.locator(`#sessionList .sess[data-id="${s2.sessionId}"]`).click();
11+
await expect(page).toHaveURL(new RegExp(`/session/${s2.sessionId}$`));
12+
13+
// click the first session row
14+
await page.locator(`#sessionList .sess[data-id="${s1.sessionId}"]`).click();
15+
await expect(page).toHaveURL(new RegExp(`/session/${s1.sessionId}$`));
16+
});
17+
18+
test("navigating to /session/:id selects that session", async ({ page, server }) => {
19+
await publish(server.url, { html: "<p>one</p>", title: "First", agent: "a1" });
20+
const s2 = await publish(server.url, { html: "<p>two</p>", title: "Second", agent: "a2" });
21+
22+
// go directly to the second session
23+
await page.goto(`${server.url}/session/${s2.sessionId}`);
24+
await expect(page.locator(`#sessionList .sess[data-id="${s2.sessionId}"]`)).toHaveClass(/sel/);
25+
await expect(page.locator(".card .card-title")).toHaveText("Second");
26+
});
27+
28+
test("navigating to /session/:id/s/:surfaceId selects the session", async ({ page, server }) => {
29+
const s1 = await publish(server.url, { html: "<p>first</p>", title: "A", agent: "pi" });
30+
const s2 = await publish(server.url, {
31+
html: "<p>second</p>",
32+
title: "B",
33+
agent: "pi",
34+
session: s1.sessionId,
35+
});
36+
37+
// deep link with a surface id selects the session
38+
await page.goto(`${server.url}/session/${s1.sessionId}/s/${s2.id}`);
39+
await expect(page.locator(`#sessionList .sess[data-id="${s1.sessionId}"]`)).toHaveClass(/sel/);
40+
// both surfaces should be loaded (full session view)
41+
await expect(page.locator(".card:not(#whatsNew) .card-title")).toHaveCount(2);
42+
});
43+
44+
test("browser back/forward navigates between sessions", async ({ page, server }) => {
45+
const s1 = await publish(server.url, { html: "<p>one</p>", title: "First", agent: "a1" });
46+
const s2 = await publish(server.url, { html: "<p>two</p>", title: "Second", agent: "a2" });
47+
await page.goto(server.url);
48+
await expect(page.locator("#sessionList .sess")).toHaveCount(2);
49+
50+
await page.locator(`#sessionList .sess[data-id="${s1.sessionId}"]`).click();
51+
await expect(page).toHaveURL(new RegExp(`/session/${s1.sessionId}(\\b|/)`));
52+
53+
await page.locator(`#sessionList .sess[data-id="${s2.sessionId}"]`).click();
54+
await expect(page).toHaveURL(new RegExp(`/session/${s2.sessionId}(\\b|/)`));
55+
56+
// go back — should return to first session
57+
await page.goBack();
58+
await expect(page).toHaveURL(new RegExp(`/session/${s1.sessionId}(\\b|/)`));
59+
await expect(page.locator(`#sessionList .sess[data-id="${s1.sessionId}"]`)).toHaveClass(/sel/);
60+
61+
// go forward — should return to second session
62+
await page.goForward();
63+
await expect(page).toHaveURL(new RegExp(`/session/${s2.sessionId}(\\b|/)`));
64+
await expect(page.locator(`#sessionList .sess[data-id="${s2.sessionId}"]`)).toHaveClass(/sel/);
65+
});
66+
67+
test("/ redirects to the last viewed session from localStorage", async ({ page, server }) => {
68+
const s = await publish(server.url, { html: "<p>hi</p>", title: "Sticky", agent: "pi" });
69+
70+
// visit the session to populate localStorage
71+
await page.goto(`${server.url}/session/${s.sessionId}`);
72+
await expect(page.locator(`#sessionList .sess[data-id="${s.sessionId}"]`)).toHaveClass(/sel/);
73+
74+
// now visit root — should redirect to the last session
75+
await page.goto(server.url);
76+
await expect(page).toHaveURL(new RegExp(`/session/${s.sessionId}$`));
77+
await expect(page.locator(`#sessionList .sess[data-id="${s.sessionId}"]`)).toHaveClass(/sel/);
78+
});
79+
80+
test("scrolling through surfaces updates the URL", async ({ page, server }) => {
81+
// publish enough surfaces to force scrolling
82+
const s1 = await publish(server.url, {
83+
html: '<div style="height:800px"><h2>Top</h2></div>',
84+
title: "Surface A",
85+
agent: "pi",
86+
});
87+
await publish(server.url, {
88+
html: '<div style="height:800px"><h2>Middle</h2></div>',
89+
title: "Surface B",
90+
agent: "pi",
91+
session: s1.sessionId,
92+
});
93+
const s3 = await publish(server.url, {
94+
html: '<div style="height:800px"><h2>Bottom</h2></div>',
95+
title: "Surface C",
96+
agent: "pi",
97+
session: s1.sessionId,
98+
});
99+
100+
await page.goto(`${server.url}/session/${s1.sessionId}`);
101+
await expect(page.locator(".card:not(#whatsNew)")).toHaveCount(3);
102+
103+
// scroll the last surface into view
104+
await page.locator(`.card[data-id="${s3.id}"]`).scrollIntoViewIfNeeded();
105+
await expect(page).toHaveURL(new RegExp(`/session/${s1.sessionId}/s/${s3.id}$`));
106+
107+
// scroll back to the first surface
108+
await page.locator(`.card[data-id="${s1.id}"]`).scrollIntoViewIfNeeded();
109+
await expect(page).toHaveURL(new RegExp(`/session/${s1.sessionId}/s/${s1.id}$`));
110+
});
111+
112+
test("/s/:id standalone surface route still works unchanged", async ({ page, server }) => {
113+
const s = await publish(server.url, { html: "<h2>Standalone</h2>", title: "Solo" });
114+
await page.goto(`${server.url}/s/${s.id}`);
115+
// standalone route renders the raw surface, not the viewer SPA
116+
await expect(page.locator("h2")).toHaveText("Standalone");
117+
// the sidebar should NOT be present
118+
await expect(page.locator("#sessionList")).toHaveCount(0);
119+
});

server/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,8 @@ export function createApp({
451451
text.replaceAll(LOCAL_ORIGIN, new URL(c.req.url).origin);
452452

453453
app.get("/", (c) => c.html(withOrigin(viewerHtml, c)));
454+
app.get("/session/:id", (c) => c.html(withOrigin(viewerHtml, c)));
455+
app.get("/session/:id/s/:surfaceId", (c) => c.html(withOrigin(viewerHtml, c)));
454456
app.get("/guide", (c) => c.text(withOrigin(guideMarkdown, c)));
455457
app.get("/setup", (c) => c.text(withOrigin(setupText, c)));
456458
app.get("/agent-howto", (c) => c.text(withOrigin(agentHowtoText, c)));

test/api.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -991,3 +991,50 @@ test("asset routes require auth when a token is set", async () => {
991991
);
992992
assert.equal((await app.request("/a/anything")).status, 401);
993993
});
994+
995+
// --- URL routing: /session/:id and /session/:id/s/:surfaceId ---
996+
997+
test("/session/:id serves the viewer HTML", async () => {
998+
const app = makeApp();
999+
// create a session with a surface so the id is valid
1000+
const s = (await (
1001+
await app.request("/api/snippets", json({ html: "<p>x</p>", agent: "pi" }))
1002+
).json()) as any;
1003+
const res = await app.request(`/session/${s.sessionId}`);
1004+
assert.equal(res.status, 200);
1005+
assert.ok(res.headers.get("content-type")?.includes("text/html"));
1006+
const body = await res.text();
1007+
assert.ok(body.includes("viewer"), "should serve the viewer document");
1008+
});
1009+
1010+
test("/session/:id/s/:surfaceId serves the viewer HTML", async () => {
1011+
const app = makeApp();
1012+
const s = (await (
1013+
await app.request("/api/snippets", json({ html: "<p>x</p>", agent: "pi" }))
1014+
).json()) as any;
1015+
const res = await app.request(`/session/${s.sessionId}/s/${s.id}`);
1016+
assert.equal(res.status, 200);
1017+
assert.ok(res.headers.get("content-type")?.includes("text/html"));
1018+
const body = await res.text();
1019+
assert.ok(body.includes("viewer"), "should serve the viewer document");
1020+
});
1021+
1022+
test("/session/:id serves viewer even for nonexistent session ids", async () => {
1023+
const app = makeApp();
1024+
// the SPA handles resolution; the server just serves the HTML
1025+
const res = await app.request("/session/deadbeef");
1026+
assert.equal(res.status, 200);
1027+
assert.ok(res.headers.get("content-type")?.includes("text/html"));
1028+
});
1029+
1030+
test("/session routes require auth when a token is set", async () => {
1031+
const app = makeApp("secret");
1032+
assert.equal((await app.request("/session/abc123")).status, 401);
1033+
assert.equal((await app.request("/session/abc123/s/def456")).status, 401);
1034+
// with auth they serve the viewer
1035+
const authed = await app.request("/session/abc123", {
1036+
headers: { authorization: "Bearer secret" },
1037+
});
1038+
assert.equal(authed.status, 200);
1039+
assert.ok((await authed.text()).includes("viewer"));
1040+
});

viewer/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
connect,
1212
dismissUpdate,
1313
groupSessions,
14+
handlePopState,
1415
live,
1516
navOpen,
1617
nearBottom,
@@ -88,6 +89,9 @@ export default function App() {
8889
};
8990
window.addEventListener("keydown", onKeydown);
9091
onCleanup(() => window.removeEventListener("keydown", onKeydown));
92+
// URL routing: handle browser back/forward
93+
window.addEventListener("popstate", handlePopState);
94+
onCleanup(() => window.removeEventListener("popstate", handlePopState));
9195
});
9296

9397
// unseen activity badges the tab title

viewer/src/Card.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { activeTheme } from "./theme.ts";
3333
import { TracePart } from "./TracePart.tsx";
3434
import {
3535
comments,
36+
focusSurface,
3637
scrollTarget,
3738
sendComment,
3839
sessions,
@@ -88,6 +89,18 @@ export function Card(props: { surface: Surface }) {
8889
setScrollTarget(null);
8990
card.scrollIntoView({ behavior: "smooth", block: "start" });
9091
}
92+
// Update the URL as the user scrolls past surfaces (replaceState, no
93+
// history noise). The first card that crosses the 50% threshold wins.
94+
const observer = new IntersectionObserver(
95+
(entries) => {
96+
for (const entry of entries) {
97+
if (entry.isIntersecting) focusSurface(props.surface.id);
98+
}
99+
},
100+
{ threshold: 0.5 },
101+
);
102+
observer.observe(card);
103+
onCleanup(() => observer.disconnect());
91104
});
92105

93106
const versionRange = (latest: number) => {
@@ -97,7 +110,7 @@ export function Card(props: { surface: Surface }) {
97110
};
98111

99112
return (
100-
<div class="card" ref={(el) => (card = el)}>
113+
<div class="card" data-id={props.surface.id} ref={(el) => (card = el)}>
101114
<div class="card-head">
102115
<span class="card-title">{props.surface.title}</span>
103116
<span class="vslot">

viewer/src/state.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,33 @@ import {
1212
} from "./api.ts";
1313
import { applyTheme } from "./theme.ts";
1414

15+
// --- URL routing ---
16+
// /session/:id → viewer with that session selected
17+
// /session/:id/s/:sid → viewer with session selected, surface focused
18+
// / → redirect to last-viewed session (localStorage)
19+
const LAST_SESSION_KEY = "sideshow-last-session";
20+
21+
export function parseRoute(pathname: string): { sessionId?: string } {
22+
// /session/:id and /session/:id/s/:surfaceId both resolve to the session.
23+
// The surface suffix is preserved in the URL for shareability but the
24+
// viewer navigates to the top of the session (scroll-to-surface on load
25+
// is unreliable with async iframe-heavy content).
26+
const match = pathname.match(/^\/session\/([^/]+)/);
27+
if (match) return { sessionId: match[1] };
28+
return {};
29+
}
30+
31+
function pushSessionUrl(id: string) {
32+
const target = `/session/${id}`;
33+
if (window.location.pathname !== target) {
34+
history.pushState(null, "", target);
35+
}
36+
}
37+
38+
function replaceSurfaceUrl(sessionId: string, surfaceId: string) {
39+
history.replaceState(null, "", `/session/${sessionId}/s/${surfaceId}`);
40+
}
41+
1542
// A comment as the viewer renders it: server comments plus the optimistic
1643
// local echo (pending until the POST confirms).
1744
export type ViewComment = Comment & { pending?: boolean };
@@ -126,11 +153,28 @@ export async function refreshSessionsQuiet() {
126153
export async function refreshSessions() {
127154
await refreshSessionsQuiet();
128155
if (selected() && !sessions.some((s) => s.id === selected())) setSelectedInternal(null);
129-
if (!selected() && sessions.length > 0) await select(sessions[0].id);
156+
if (!selected() && sessions.length > 0) {
157+
// Check the URL first, then localStorage, then fall back to first session.
158+
const route = parseRoute(window.location.pathname);
159+
const lastId = localStorage.getItem(LAST_SESSION_KEY);
160+
const target =
161+
(route.sessionId && sessions.some((s) => s.id === route.sessionId) && route.sessionId) ||
162+
(lastId && sessions.some((s) => s.id === lastId) && lastId) ||
163+
sessions[0].id;
164+
await select(target, { replace: true });
165+
}
130166
}
131167

132-
export async function select(id: string) {
168+
export async function select(id: string, opts?: { fromPopState?: boolean; replace?: boolean }) {
133169
setSelectedInternal(id);
170+
if (opts?.fromPopState) {
171+
// Browser already moved the history; don't touch it.
172+
} else if (opts?.replace) {
173+
history.replaceState(null, "", `/session/${id}`);
174+
} else {
175+
pushSessionUrl(id);
176+
}
177+
localStorage.setItem(LAST_SESSION_KEY, id);
134178
setUnread((prev) => {
135179
const next = new Set(prev);
136180
next.delete(id);
@@ -155,6 +199,21 @@ export async function select(id: string) {
155199
mergeComments(res.comments);
156200
}
157201

202+
// Update the URL to reflect the currently visible surface (replaceState so
203+
// scrolling doesn't pollute browser history).
204+
export function focusSurface(surfaceId: string) {
205+
const sid = selected();
206+
if (sid) replaceSurfaceUrl(sid, surfaceId);
207+
}
208+
209+
// Handle browser back/forward by re-selecting the session from the URL.
210+
export function handlePopState() {
211+
const route = parseRoute(window.location.pathname);
212+
if (route.sessionId && route.sessionId !== selected()) {
213+
void select(route.sessionId, { fromPopState: true });
214+
}
215+
}
216+
158217
// Switch to the session above (-1) or below (+1) the current one in the
159218
// sidebar list, wrapping at the ends so repeated presses cycle. Drives the
160219
// Cmd+Option+Up/Down shortcut. No-op with no sessions; jumps to the first

0 commit comments

Comments
 (0)