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. */} + + + + +
{/* ThemePicker is a generic feature, not deployment-specific @@ -476,6 +497,45 @@ function SessionItem(props: { session: SessionRow }) { ); } +// The native empty-sidebar affordance: the fallback content for the +// ss:aside-empty slot, shown when the board has no sessions. It reads as the +// first item of an otherwise-empty list (it reuses the .sess row chrome) — a +// plug icon + "Connect an agent" label, with one line of muted helper text. +// Clicking it scrolls to the empty-board pane (#onboard, wrapped by ss:empty) +// that holds the connect instructions; generic, no deployment-specific logic. +function AsideEmptyRow() { + const activate = () => { + setNavOpen(false); + // #onboard is the empty-board pane wrapped by ss:empty. When an embedder + // projects ss:main (taking over the main pane), #onboard isn't in the DOM + // and this is a silent no-op — acceptable: there's nothing to scroll to. + root().querySelector("#onboard")?.scrollIntoView({ behavior: "smooth", block: "start" }); + }; + return ( +
{ + if (e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + activate(); + } + }} + > +
+ + + + Connect an agent +
+
Your sessions will appear here once an agent connects.
+
+ ); +} + function SessionView() { const current = createMemo(() => sessions.find((x) => x.id === selected())); return ( diff --git a/viewer/src/host.ts b/viewer/src/host.ts index 2975cff..bf118aa 100644 --- a/viewer/src/host.ts +++ b/viewer/src/host.ts @@ -78,6 +78,11 @@ export const SLOTS = { // Sidebar footer: design-guide / agent-setup links, the connect action, and the // theme picker. (`#onboard` aside, App.tsx) asideFoot: "ss:aside-foot", + // Empty-sidebar affordance shown in the session list when no sessions exist. + // (`#sessionList`, App.tsx) Fallback is a native "Connect an agent" row that + // scrolls to the empty-board pane (ss:empty); an embedder projects its own + // empty-list nudge here. Renders only on an empty (post-load) board. + asideEmpty: "ss:aside-empty", // Empty-board onboarding shown before any session exists. (`#onboard`, App.tsx) empty: "ss:empty", // Per-session actions in the session header, beside the stream/timeline toggle. diff --git a/viewer/src/icons.tsx b/viewer/src/icons.tsx index 789a981..8f4522f 100644 --- a/viewer/src/icons.tsx +++ b/viewer/src/icons.tsx @@ -60,6 +60,18 @@ export function ImageIcon() { ); } +// lucide: plug — a connect glyph for the empty-sidebar affordance. +export function PlugIcon() { + return ( + + + + + + + ); +} + // lucide: trash-2 export function TrashIcon() { return ( diff --git a/viewer/src/styles.css b/viewer/src/styles.css index b3b7df1..5b6d332 100644 --- a/viewer/src/styles.css +++ b/viewer/src/styles.css @@ -225,6 +225,33 @@ aside > .brand { color: var(--text); background: var(--hover); } +/* Empty-sidebar affordance: the native "Connect an agent" row that is the + ss:aside-empty slot's fallback, shown as the first item of an otherwise- + empty list. It reuses .sess row chrome (padding/radius/cursor/hover) so it's + visually indistinguishable from a real list row; the inner layout carries an + accent plug icon + a 13px label, with one muted helper line beneath. */ +.aside-empty-head { + display: flex; + align-items: center; + gap: 7px; +} +.aside-empty-icon { + flex: none; + width: 16px; + height: 16px; + color: var(--accent); +} +.aside-empty-label { + font-size: 13px; + font-weight: 500; + color: var(--text); +} +.aside-empty-help { + font-size: 12px; + color: var(--faint); + line-height: 1.5; + margin-top: 2px; +} .aside-foot { padding: 12px 16px; border-top: 0.5px solid var(--border);