Skip to content
Open
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/tidy-session-archives.md
Original file line number Diff line number Diff line change
@@ -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.
94 changes: 94 additions & 0 deletions e2e/embed-archive.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = `<!doctype html>
<html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%}#m{position:fixed;inset:0}</style></head>
<body><div id="m"></div>
<script type="module">
import { mountViewer } from "/__embed/engine.js";
let route = { archives: true, surfaceId: "stale-post-id" };
const subscribers = new Set();
window.__archiveRoute = (next) => {
route = next;
for (const subscriber of subscribers) subscriber(route);
};
mountViewer(document.getElementById("m"), {
basePath: "",
router: {
get: () => route,
navigate: (next) => {
window.__archiveNavigation = next;
window.__archiveRoute(next);
},
subscribe: (subscriber) => {
subscribers.add(subscriber);
return () => subscribers.delete(subscriber);
},
},
});
</script></body></html>`;

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);
});
37 changes: 37 additions & 0 deletions e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })),
);
Expand Down
8 changes: 7 additions & 1 deletion viewer/embed.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
103 changes: 85 additions & 18 deletions viewer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
} from "./theme.ts";
import {
applyRoute,
archiveView,
bootstrap,
checkVersion,
connect,
Expand All @@ -45,9 +46,11 @@ import {
groupSessions,
initialLoaded,
live,
openArchives,
navOpen,
nearBottom,
pillTarget,
recentSessions,
refreshSessionsQuiet,
select,
selectAdjacent,
Expand All @@ -58,6 +61,7 @@ import {
setPillTarget,
setUnread,
setViewMode,
SIDEBAR_SESSION_LIMIT,
standalonePost,
streamLoading,
posts,
Expand Down Expand Up @@ -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 <body>)
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 (
<Show
Expand Down Expand Up @@ -286,6 +294,11 @@ export default function App() {
</>
)}
</For>
<Show when={sessions.length > SIDEBAR_SESSION_LIMIT}>
<button class="archive-link" type="button" onClick={openArchives}>
Go to archives <span>{sessions.length}</span>
</button>
</Show>
{/* 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
Expand Down Expand Up @@ -343,17 +356,24 @@ export default function App() {
pane (e.g. a cloud Settings page) while the sidebar stays. */}
<slot name={SLOTS.main}>
<Show
when={connectPath()}
when={archiveView()}
fallback={
<>
<Show when={!streamMode()}>
<Onboard />
</Show>
<SessionView />
</>
<Show
when={connectPath()}
fallback={
<>
<Show when={!streamMode()}>
<Onboard />
</Show>
<SessionView />
</>
}
>
<ConnectPage />
</Show>
}
>
<ConnectPage />
<ArchiveView />
</Show>
</slot>
</main>
Expand Down Expand Up @@ -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 (
<section id="archiveView" aria-labelledby="archiveTitle">
<div class="archive-head">
<div>
<h1 id="archiveTitle">Archives</h1>
<p>Every session in this workspace, past and present.</p>
</div>
<label class="archive-search">
<span>Search archives</span>
<input
type="search"
placeholder="Title, agent, or folder"
value={query()}
onInput={(e) => setQuery(e.currentTarget.value)}
/>
</label>
</div>
<div class="archive-list">
<Show when={groups().length > 0} fallback={<p class="archive-empty">No sessions found.</p>}>
<For each={groups()}>
{(group) => (
<section class="archive-group" aria-label={group.label}>
<h2>{group.label}</h2>
<For each={group.sessions}>{(session) => <SessionItem session={session} />}</For>
</section>
)}
</For>
</Show>
</div>
</section>
);
}

function SessionItem(props: { session: SessionRow }) {
const label = () => sessionLabel(props.session);
return (
Expand Down
10 changes: 9 additions & 1 deletion viewer/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]\/([^/]+)/);
Expand All @@ -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}`
Expand Down
Loading
Loading