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