diff --git a/.changeset/tidy-session-archives.md b/.changeset/tidy-session-archives.md
new file mode 100644
index 0000000..95dcd7c
--- /dev/null
+++ b/.changeset/tidy-session-archives.md
@@ -0,0 +1,5 @@
+---
+"sideshow": minor
+---
+
+Keep the sidebar to the 15 most recent sessions and add a searchable archive for the complete session history.
diff --git a/e2e/embed-archive.spec.ts b/e2e/embed-archive.spec.ts
new file mode 100644
index 0000000..0873531
--- /dev/null
+++ b/e2e/embed-archive.spec.ts
@@ -0,0 +1,94 @@
+// The archive route is part of the embeddable engine contract: a host owns the
+// route and the engine both renders it when received and requests it when the
+// compact sidebar's archive link is activated.
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { expect, test } from "./fixtures.ts";
+
+const embedDir = fileURLToPath(new URL("../viewer/dist-embed", import.meta.url));
+
+function contentType(path: string): string {
+ if (path.endsWith(".js") || path.endsWith(".mjs")) return "text/javascript";
+ return "application/octet-stream";
+}
+
+const embedHtml = `
+
+
+`;
+
+test("embedded routers receive and navigate to the archive route", async ({ page, server }) => {
+ await Promise.all(
+ Array.from({ length: 16 }, (_, index) =>
+ fetch(`${server.url}/api/sessions`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ agent: "embed", title: `Archive ${index}` }),
+ }),
+ ),
+ );
+ await page.route("**/__embed-archive", (route) =>
+ route.fulfill({ contentType: "text/html", body: embedHtml }),
+ );
+ await page.route("**/__embed/**", (route) => {
+ const name = new URL(route.request().url()).pathname.replace("/__embed/", "");
+ route.fulfill({ contentType: contentType(name), body: readFileSync(`${embedDir}/${name}`) });
+ });
+ await page.goto(`${server.url}/__embed-archive`);
+
+ // The host's initial { archives: true } route reaches the archive UI.
+ await expect(page.getByRole("heading", { name: "Archives" })).toBeVisible();
+
+ // Then the host moves to its normal workspace route; the engine restores the
+ // compact sidebar and asks the host for { archives: true } when activated.
+ await page.evaluate(() => {
+ (window as unknown as { __archiveRoute: (route: object) => void }).__archiveRoute({});
+ });
+ await expect(page.locator("#sessionList .sess")).toHaveCount(15);
+
+ // Hosts can retain optional route fields while changing views. `archives`
+ // wins over a stale surfaceId instead of entering standalone-post mode.
+ await page.evaluate(() => {
+ (window as unknown as { __archiveRoute: (route: object) => void }).__archiveRoute({
+ archives: true,
+ surfaceId: "stale-post-id",
+ });
+ });
+ await expect(page.getByRole("heading", { name: "Archives" })).toBeVisible();
+ await page.evaluate(() => {
+ (window as unknown as { __archiveRoute: (route: object) => void }).__archiveRoute({});
+ });
+ await expect(page.locator("#sessionList .sess")).toHaveCount(15);
+ await page.getByRole("button", { name: /Go to archives 16/ }).click();
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ (window as unknown as { __archiveNavigation?: { archives?: boolean } })
+ .__archiveNavigation?.archives,
+ ),
+ )
+ .toBe(true);
+});
diff --git a/e2e/viewer.spec.ts b/e2e/viewer.spec.ts
index 74a551d..6e0d5fe 100644
--- a/e2e/viewer.spec.ts
+++ b/e2e/viewer.spec.ts
@@ -37,6 +37,43 @@ test("the sidebar groups sessions by recency and sinks empty ones to the bottom"
await expect(rows.nth(1).locator(".sess-count")).toHaveCount(0);
});
+test("the sidebar caps sessions and the archive searches the complete history", async ({
+ page,
+ server,
+}) => {
+ const created = await Promise.all(
+ Array.from({ length: 16 }, async (_, index) => {
+ const response = await fetch(`${server.url}/api/sessions`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ agent: "archiver", title: `Archive ${index}` }),
+ });
+ expect(response.ok).toBe(true);
+ return (await response.json()) as { id: string };
+ }),
+ );
+
+ await page.goto(server.url);
+ await expect(page.locator("#sessionList .sess")).toHaveCount(15);
+ await page.getByRole("button", { name: /Go to archives 16/ }).click();
+ await expect(page).toHaveURL(/\/archives$/);
+ await expect(page.getByRole("heading", { name: "Archives" })).toBeVisible();
+ await expect(page.locator("#archiveView .sess")).toHaveCount(16);
+
+ // A real route means reloading, Back, and Forward retain the archive view.
+ await page.reload();
+ await expect(page.getByRole("heading", { name: "Archives" })).toBeVisible();
+ await page.getByRole("searchbox", { name: "Search archives" }).fill("archive 15");
+ await expect(page.locator("#archiveView .sess")).toHaveCount(1);
+ await page.locator(`#archiveView .sess[data-id="${created[15].id}"]`).click();
+ await expect(page).toHaveURL(new RegExp(`/session/${created[15].id}$`));
+ await page.goBack();
+ await expect(page).toHaveURL(/\/archives$/);
+ await expect(page.getByRole("heading", { name: "Archives" })).toBeVisible();
+ await page.goForward();
+ await expect(page).toHaveURL(new RegExp(`/session/${created[15].id}$`));
+});
+
test("session rows show the agent's logo, with a fallback for unknown agents", async ({
page,
server,
diff --git a/server/app.ts b/server/app.ts
index c1b0260..5a9781b 100644
--- a/server/app.ts
+++ b/server/app.ts
@@ -983,6 +983,11 @@ export function createApp({
return injectHead(html, postPreviewHead(opts.post, c.req.raw, themeId, version ?? "dev"));
};
app.get("/", async (c) => c.html(await configuredViewerHtml(c)));
+ // The archive is a viewer-only index over the workspace's existing sessions.
+ // Keep a real route so refreshes and browser history preserve that view.
+ app.get("/archives", async (c) =>
+ c.html(await configuredViewerHtml(c, { title: "Archives" })),
+ );
app.get("/connect", async (c) =>
c.html(await configuredViewerHtml(c, { title: "Connect an agent" })),
);
diff --git a/viewer/embed.d.ts b/viewer/embed.d.ts
index b588517..9255fc9 100644
--- a/viewer/embed.d.ts
+++ b/viewer/embed.d.ts
@@ -2,7 +2,13 @@
// is the Vite-built viewer/dist-embed/engine.js; these declarations describe its
// surface so hosts get types without depending on the viewer source.
-export type Route = { sessionId?: string | null; surfaceId?: string | null };
+/** A session/post route, or the full workspace archive when `archives` is true. */
+export type Route = {
+ sessionId?: string | null;
+ surfaceId?: string | null;
+ /** Show every workspace session in the engine's searchable archive view. */
+ archives?: boolean;
+};
export type LiveTransport = "sse" | "ws";
export interface HostRouter {
diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx
index 9eb9508..ae918bd 100644
--- a/viewer/src/App.tsx
+++ b/viewer/src/App.tsx
@@ -37,6 +37,7 @@ import {
} from "./theme.ts";
import {
applyRoute,
+ archiveView,
bootstrap,
checkVersion,
connect,
@@ -45,9 +46,11 @@ import {
groupSessions,
initialLoaded,
live,
+ openArchives,
navOpen,
nearBottom,
pillTarget,
+ recentSessions,
refreshSessionsQuiet,
select,
selectAdjacent,
@@ -58,6 +61,7 @@ import {
setPillTarget,
setUnread,
setViewMode,
+ SIDEBAR_SESSION_LIMIT,
standalonePost,
streamLoading,
posts,
@@ -211,21 +215,25 @@ export default function App() {
// post instead (set below), so don't fight it here.
createEffect(() => {
if (isShadow()) return;
- document.title = pageTitle(
- standalonePost(),
- sessions.find((s) => s.id === selected()),
- unread().size,
- initialPageTitle(),
- );
+ document.title = archiveView()
+ ? "Archives · sideshow"
+ : 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 )
createEffect(() => navHostEl().classList.toggle("nav-open", navOpen()));
- // sessions bucketed by recency for the sidebar; recomputes whenever the
- // session list changes (incl. the 45s quiet refresh, which keeps the
- // Today/Yesterday split fresh as the day rolls over)
- const sessionGroups = createMemo(() => groupSessions(sessions, new Date()));
+ // The sidebar stays a quick jump list rather than an endless scroll. The
+ // archive view below uses every session from this same in-memory list.
+ const sidebarSessions = createMemo(() =>
+ recentSessions(sessions, new Date()).slice(0, SIDEBAR_SESSION_LIMIT),
+ );
+ const sessionGroups = createMemo(() => groupSessions(sidebarSessions(), new Date()));
return (
)}
+ SIDEBAR_SESSION_LIMIT}>
+
+
{/* Host-overridable region (SLOTS.asideEmpty): the session
list's empty state. The fallback below is a native "Connect an
agent" row — the first item of an otherwise-empty list — that
@@ -343,17 +356,24 @@ export default function App() {
pane (e.g. a cloud Settings page) while the sidebar stays. */}
-
-
-
-
- >
+
+
+
+
+
+ >
+ }
+ >
+
+
}
>
-
+
@@ -562,6 +582,53 @@ function isOwnFrame(source: unknown): boolean {
return false;
}
+function ArchiveView() {
+ const [query, setQuery] = createSignal("");
+ const matchingSessions = createMemo(() => {
+ const needle = query().trim().toLocaleLowerCase();
+ if (!needle) return sessions;
+ return sessions.filter((session) =>
+ [sessionLabel(session), session.agent, session.cwd ?? ""]
+ .join(" ")
+ .toLocaleLowerCase()
+ .includes(needle),
+ );
+ });
+ const groups = createMemo(() => groupSessions(matchingSessions(), new Date()));
+
+ return (
+
+
+
+
Archives
+
Every session in this workspace, past and present.
+
+
+
+
+ 0} fallback={
No sessions found.
}>
+
+ {(group) => (
+
+
{group.label}
+ {(session) => }
+
+ )}
+
+
+
+
+ );
+}
+
function SessionItem(props: { session: SessionRow }) {
const label = () => sessionLabel(props.session);
return (
diff --git a/viewer/src/host.ts b/viewer/src/host.ts
index f39f693..2e2e9c1 100644
--- a/viewer/src/host.ts
+++ b/viewer/src/host.ts
@@ -12,7 +12,13 @@
import type { ThemeTokens } from "../../server/theme-tokens.ts";
import type { Mode } from "../../server/themes.ts";
-export type Route = { sessionId?: string | null; surfaceId?: string | null };
+export type Route = {
+ sessionId?: string | null;
+ surfaceId?: string | null;
+ // The full session archive. This is intentionally a viewer route rather than
+ // persisted session state: every session remains active and selectable.
+ archives?: boolean;
+};
export type LiveTransport = "sse" | "ws";
export interface HostRouter {
@@ -201,6 +207,7 @@ export function createDefaultHost(): SideshowHost {
? location.pathname.slice(basePath.length)
: location.pathname;
const qSurface = new URLSearchParams(location.search).get("surface") ?? undefined;
+ if (rest === "/archives") return { archives: true };
const m = rest.match(/^\/session\/([^/]+)(?:\/[sp]\/([^/]+))?/);
if (m) return { sessionId: m[1], surfaceId: m[2] ?? qSurface };
const surfaceOnly = rest.match(/^\/[sp]\/([^/]+)/);
@@ -209,6 +216,7 @@ export function createDefaultHost(): SideshowHost {
};
const urlFor = (to: Route): string => {
+ if (to.archives) return `${basePath}/archives`;
if (!to.sessionId) return to.surfaceId ? `${basePath}/p/${to.surfaceId}` : basePath || "/";
return to.surfaceId
? `${basePath}/session/${to.sessionId}/p/${to.surfaceId}`
diff --git a/viewer/src/state.ts b/viewer/src/state.ts
index d0e99b5..091e9d8 100644
--- a/viewer/src/state.ts
+++ b/viewer/src/state.ts
@@ -37,6 +37,10 @@ export interface SessionGroup {
sessions: SessionRow[];
}
+// Keep the sidebar deliberately short. The archive is a client-side view over
+// the same complete session list, not a destructive or persisted state.
+export const SIDEBAR_SESSION_LIMIT = 15;
+
// Bucket sessions by last-active recency (Today / Yesterday / Earlier) so the
// freshest work stays on top and a long history reads at a glance. Within a
// bucket, sessions with no posts yet sink to the bottom (and render dimmed)
@@ -65,8 +69,16 @@ export function groupSessions(list: readonly SessionRow[], now: Date): SessionGr
}
return buckets.filter((b) => b.sessions.length > 0);
}
+
+// The flat order used by the compact sidebar and keyboard traversal. Keep it
+// derived from the grouped order so empty sessions still follow active ones.
+export function recentSessions(list: readonly SessionRow[], now: Date): SessionRow[] {
+ return groupSessions(list, now).flatMap((group) => group.sessions);
+}
const [selectedState, setSelectedInternal] = createSignal(null);
export const selected = selectedState;
+const [archiveViewState, setArchiveViewInternal] = createSignal(false);
+export const archiveView = archiveViewState;
// Standalone (direct-link) mode: a bare /s/:id route with no session shows that
// one post full-page — no sidebar, no session feed, no comments — instead of
@@ -203,7 +215,9 @@ function syntheticSession(id: string): SessionRow {
// user lands somewhere usable rather than a blank page.
export async function bootstrap() {
const route = host().router.get();
- if (route.surfaceId && !route.sessionId) {
+ // Keep archive precedence consistent with applyRoute() and the default
+ // router: an embedding host may leave a stale surfaceId on its archive route.
+ if (!route.archives && route.surfaceId && !route.sessionId) {
await enterStandalone(route.surfaceId);
if (standalonePost()) return;
}
@@ -247,6 +261,12 @@ export async function refreshSessions(targetPostId?: string | null) {
}
await refreshSessionsQuiet();
+ const route = host().router.get();
+ if (route.archives) {
+ setArchiveViewInternal(true);
+ setSelectedInternal(null);
+ return;
+ }
if (selected() && !sessions.some((s) => s.id === selected())) setSelectedInternal(null);
if (targetPostId) {
const target = await api(`/api/posts/${encodeURIComponent(targetPostId)}`).catch(
@@ -263,7 +283,6 @@ export async function refreshSessions(targetPostId?: string | null) {
// A host that owns a session-less landing (homeView) skips that fallback: it
// honors a deep-linked route session but otherwise stays session-less so the
// host's home shows with nothing selected (no auto-open, no highlight).
- const route = host().router.get();
const lastId = localStorage.getItem(LAST_SESSION_KEY);
const fallback =
host().homeView || isConnectRoute()
@@ -314,6 +333,7 @@ export async function select(
id: string,
opts?: { fromPopState?: boolean; replace?: boolean; initialPostId?: string },
) {
+ setArchiveViewInternal(false);
setSelectedInternal(id);
if (opts?.fromPopState) {
// The host already moved the route (back/forward); don't touch it.
@@ -366,13 +386,32 @@ export function focusPost(postId: string) {
// clears it. The host itself dedupes a no-op move. applyRoute ignores a null
// sessionId (back/forward to home shouldn't thrash a load), so we deselect here.
export function goHome() {
+ setArchiveViewInternal(false);
setSelectedInternal(null);
setNavOpen(false);
host().router.navigate({ sessionId: null, surfaceId: null });
}
// Re-select the session when the host's route changes (back/forward).
+export function openArchives() {
+ setArchiveViewInternal(true);
+ setSelectedInternal(null);
+ setNavOpen(false);
+ host().router.navigate({ archives: true });
+}
+
export function applyRoute(route: Route) {
+ // Archives takes precedence over every other optional Route field, matching
+ // the default router's URL generation. An embedding host may carry a stale
+ // surfaceId while it switches views; that must not replace the archive with a
+ // standalone post.
+ if (route.archives) {
+ if (standalonePost()) setStandaloneInternal(null);
+ setArchiveViewInternal(true);
+ setSelectedInternal(null);
+ setNavOpen(false);
+ return;
+ }
// A bare post route is the standalone full-page view; back/forward into or
// out of it toggles the mode (leaving it falls through to session handling).
if (route.surfaceId && !route.sessionId) {
@@ -380,6 +419,7 @@ export function applyRoute(route: Route) {
return;
}
if (standalonePost()) setStandaloneInternal(null);
+ setArchiveViewInternal(false);
if (route.sessionId && route.sessionId !== selected()) {
void select(route.sessionId, {
fromPopState: true,
@@ -399,14 +439,15 @@ export function applyRoute(route: Route) {
// Cmd+Option+Up/Down shortcut. No-op with no sessions; jumps to the first
// when nothing is selected yet.
export async function selectAdjacent(delta: 1 | -1) {
- if (sessions.length === 0) return;
- const idx = sessions.findIndex((s) => s.id === selected());
+ const sidebar = recentSessions(sessions, new Date()).slice(0, SIDEBAR_SESSION_LIMIT);
+ if (sidebar.length === 0) return;
+ const idx = sidebar.findIndex((s) => s.id === selected());
if (idx < 0) {
- await select(sessions[0].id);
+ await select(sidebar[0].id);
return;
}
- const next = (idx + delta + sessions.length) % sessions.length;
- await select(sessions[next].id);
+ const next = (idx + delta + sidebar.length) % sidebar.length;
+ await select(sidebar[next].id);
}
// Fetch a post and insert/update it in the open session's stream.
diff --git a/viewer/src/styles.css b/viewer/src/styles.css
index 1c3ba12..addfa34 100644
--- a/viewer/src/styles.css
+++ b/viewer/src/styles.css
@@ -184,6 +184,34 @@ aside {
overflow-y: auto;
padding: 4px 8px;
}
+.archive-link {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ margin: 8px 0 4px;
+ padding: 8px 10px;
+ border: 0;
+ border-radius: 8px;
+ color: var(--accent);
+ background: transparent;
+ font: inherit;
+ font-size: 13px;
+ font-weight: 500;
+ text-align: left;
+ cursor: pointer;
+}
+.archive-link:hover {
+ background: var(--hover);
+}
+.archive-link:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+.archive-link span {
+ color: var(--faint);
+ font-weight: 400;
+}
/* Recency group header (Today / Yesterday / Earlier). The first one hugs the
top; later ones get breathing room above. */
.sess-group {
@@ -528,6 +556,73 @@ main {
padding: 22px 28px 120px;
}
+#archiveView {
+ max-width: 860px;
+ margin: 0 auto;
+ padding: 42px 28px 120px;
+}
+.archive-head {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 24px;
+ padding-bottom: 24px;
+ border-bottom: 0.5px solid var(--border);
+}
+.archive-head h1 {
+ margin: 0;
+ font-size: 24px;
+ letter-spacing: -0.02em;
+}
+.archive-head p {
+ margin: 4px 0 0;
+ color: var(--muted);
+}
+.archive-search {
+ display: grid;
+ gap: 4px;
+ flex: 0 1 270px;
+ color: var(--muted);
+ font-size: 12px;
+}
+.archive-search input {
+ width: 100%;
+ padding: 8px 10px;
+ border: 0.5px solid var(--border-2);
+ border-radius: 7px;
+ color: var(--text);
+ background: var(--surface);
+ font: inherit;
+}
+.archive-search input:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+.archive-list {
+ padding-top: 12px;
+}
+.archive-group {
+ margin-top: 24px;
+}
+.archive-group h2 {
+ margin: 0 0 7px;
+ color: var(--faint);
+ font-size: 11px;
+ font-weight: 500;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.archive-group .sess {
+ border: 0.5px solid transparent;
+}
+.archive-group .sess.sel,
+.archive-group .sess:hover {
+ border-color: var(--border);
+}
+.archive-empty {
+ color: var(--muted);
+}
+
/* Standalone direct-link view (/s/:id with no session): one surface, full page,
no sidebar or session chrome. Mirrors the #stream column so a surface reads
the same as it does in the feed, with a small sideshow watermark beneath. */
@@ -2204,6 +2299,17 @@ iframe {
#stream {
padding: 12px 8px calc(112px + env(safe-area-inset-bottom, 0px));
}
+ #archiveView {
+ padding: 28px 14px calc(56px + env(safe-area-inset-bottom, 0px));
+ }
+ .archive-head {
+ align-items: stretch;
+ flex-direction: column;
+ gap: 16px;
+ }
+ .archive-search {
+ max-width: none;
+ }
.standalone-main {
padding: 12px 8px calc(48px + env(safe-area-inset-bottom, 0px));
}