diff --git a/.changeset/aside-empty-slot.md b/.changeset/aside-empty-slot.md
new file mode 100644
index 0000000..98581df
--- /dev/null
+++ b/.changeset/aside-empty-slot.md
@@ -0,0 +1,5 @@
+---
+"sideshow": minor
+---
+
+Add a host-overridable `ss:aside-empty` slot for the sidebar's empty state. When the session list is empty (post-load), it now shows a lightweight native "Connect an agent" row — the first item of an otherwise-empty list — with a plug icon and a one-line helper, instead of a blank list area. Clicking it scrolls to the empty-board pane (`ss:empty`) that holds the connect instructions. An embedder can project a `slot="ss:aside-empty"` child to replace the fallback with its own empty-list nudge; once a session exists, neither renders. Self-hosted gets the affordance via the fallback; the slot lets embedders override it.
diff --git a/e2e/embed-aside-empty-slot.spec.ts b/e2e/embed-aside-empty-slot.spec.ts
new file mode 100644
index 0000000..c42e90d
--- /dev/null
+++ b/e2e/embed-aside-empty-slot.spec.ts
@@ -0,0 +1,130 @@
+// End-to-end browser proof for the `ss:aside-empty` slot — the empty-sidebar
+// affordance shown in the session list when no sessions exist. The fallback is
+// a native "Connect an agent" row; an embedder projects a `slot="ss:aside-empty"`
+// child to replace it. Same embed harness as embed-slots.spec.ts: the embed page
+// + built dist-embed bundle are served on the server's own origin so same-origin
+// /api/* reads hit real data.
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { expect, publish, test } from "./fixtures.ts";
+import type { Page } from "@playwright/test";
+
+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";
+ if (path.endsWith(".wasm")) return "application/wasm";
+ if (path.endsWith(".css")) return "text/css";
+ return "application/octet-stream";
+}
+
+// Mounts the engine in "full" layout over an empty board. `slotChild` is the
+// light-DOM child (with a slot= attribute) projected into the mount element, or
+// none. The router points at no session so the empty board shows.
+const embedHtml = (slotChild: string) => `
+
+${slotChild}
+`;
+
+// Same harness, but routing to a real session so the sidebar lists it.
+const embedHtmlWithSession = (sessionId: string) => `
+
+
+`;
+
+function serveEmbed(page: Page, html: string) {
+ page.on("pageerror", (e) => console.error("[pageerror]", e.message));
+ page.on("console", (m) => m.type() === "error" && console.error("[console]", m.text()));
+ return Promise.all([
+ page.route("**/__embedtest", (route) =>
+ route.fulfill({ contentType: "text/html", body: html }),
+ ),
+ page.route("**/__embed/**", (route) => {
+ const name = new URL(route.request().url()).pathname.replace("/__embed/", "");
+ route.fulfill({ contentType: contentType(name), body: readFileSync(`${embedDir}/${name}`) });
+ }),
+ ]);
+}
+
+test.describe("embedded engine: ss:aside-empty slot", () => {
+ test("fallback 'Connect an agent' row renders when nothing is projected and there are no sessions", async ({
+ page,
+ server,
+ }) => {
+ await serveEmbed(page, embedHtml(""));
+ await page.goto(`${server.url}/__embedtest`);
+
+ // The sidebar (full layout) renders with an empty session list.
+ await expect(page.locator("aside")).toBeVisible();
+
+ // The native fallback affordance renders as the first list row, with its
+ // label and helper text.
+ const row = page.locator(".aside-empty");
+ await expect(row).toBeVisible();
+ await expect(row).toContainText("Connect an agent");
+ await expect(row).toContainText("Your sessions will appear here once an agent connects.");
+ await expect(page.locator("aside slot[name='ss:aside-empty']")).toHaveCount(1);
+ });
+
+ test("projected content replaces the fallback through the shadow boundary", async ({
+ page,
+ server,
+ }) => {
+ await serveEmbed(
+ page,
+ embedHtml('host empty nudge
'),
+ );
+ await page.goto(`${server.url}/__embedtest`);
+
+ // The host's light-DOM projection shows in place of the native fallback.
+ const hostEmpty = page.locator("#hostEmpty");
+ await expect(hostEmpty).toBeVisible();
+ await expect(hostEmpty).toContainText("host empty nudge");
+
+ // The engine's fallback row is present in the DOM (native default
+ // content) but not rendered — the projection is what the user sees.
+ await expect(page.locator(".aside-empty")).toBeHidden();
+ });
+
+ test("neither fallback nor projection renders once a session exists", async ({
+ page,
+ server,
+ }) => {
+ const surface = await publish(
+ server.url,
+ { html: "board card
", title: "Board card", agent: "e2e" },
+ "",
+ );
+
+ await serveEmbed(page, embedHtmlWithSession(surface.sessionId));
+ await page.goto(`${server.url}/__embedtest`);
+
+ // A real session row shows in the sidebar …
+ await expect(page.locator("aside .sess")).toBeVisible();
+
+ // … and the empty-sidebar affordance is gone entirely (the gating
+ // removes the slot + fallback when sessions exist).
+ await expect(page.locator(".aside-empty")).toHaveCount(0);
+ await expect(page.locator("aside slot[name='ss:aside-empty']")).toHaveCount(0);
+ });
+});
diff --git a/viewer/embed.d.ts b/viewer/embed.d.ts
index 24c6a0d..9abe835 100644
--- a/viewer/embed.d.ts
+++ b/viewer/embed.d.ts
@@ -78,6 +78,13 @@ export function mountViewer(el: Element, host?: SideshowHost): ViewerHandle;
export declare const SLOTS: {
/** Sidebar footer: doc links, connect action, theme picker. */
readonly asideFoot: "ss:aside-foot";
+ /**
+ * Empty-sidebar affordance shown in the session list when no sessions exist.
+ * Fallback is a native "Connect an agent" row that scrolls to the empty-board
+ * pane (ss:empty); project a `slot="ss:aside-empty"` child for a host-specific
+ * nudge. Renders only on an empty (post-load) board.
+ */
+ readonly asideEmpty: "ss:aside-empty";
/** Empty-board onboarding shown before any session exists. */
readonly empty: "ss:empty";
/** Per-session actions in the session header, beside the stream/timeline toggle. */
diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx
index a1d798d..922ee98 100644
--- a/viewer/src/App.tsx
+++ b/viewer/src/App.tsx
@@ -13,6 +13,7 @@ import { host, isShadow, navHostEl, root, SLOTS } from "./host.ts";
import { applyFrameHeight, Card, cardEls, frameForSource } from "./Card.tsx";
import { renderNotes } from "./notes.ts";
import { SessionTimeline } from "./SessionTimeline.tsx";
+import { PlugIcon } from "./icons.tsx";
import { activeTheme, initTheme, setTheme, themeOptions } from "./theme.ts";
import {
applyRoute,
@@ -186,6 +187,26 @@ export default function App() {
>
)}
+ {/* 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
+ scrolls to the empty-board pane (ss:empty) holding the connect
+ instructions. An embedder projects its own empty-list nudge
+ here; either shows only on a post-load empty board, and
+ neither renders once a session exists. */}
+
+
+ {/* The native fallback is a connect affordance, so it only
+ makes sense when the board is writable — readonly boards
+ show "Nothing here yet" in the empty pane, not connect
+ instructions, so the row would point at a contradiction.
+ The slot itself stays mounted so an embedder can still
+ project its own (possibly readonly-appropriate) nudge. */}
+
+
+
+
+