Skip to content
Merged
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/aside-empty-slot.md
Original file line number Diff line number Diff line change
@@ -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.
130 changes: 130 additions & 0 deletions e2e/embed-aside-empty-slot.spec.ts
Original file line number Diff line number Diff line change
@@ -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) => `<!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">${slotChild}</div>
<script type="module">
import { mountViewer } from "/__embed/engine.js";
mountViewer(document.getElementById("m"), {
basePath: "",
router: {
get: () => ({}),
navigate() {},
subscribe() { return () => {}; },
},
});
</script></body></html>`;

// Same harness, but routing to a real session so the sidebar lists it.
const embedHtmlWithSession = (sessionId: string) => `<!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";
mountViewer(document.getElementById("m"), {
basePath: "",
router: {
get: () => ({ sessionId: ${JSON.stringify(sessionId)} }),
navigate() {},
subscribe() { return () => {}; },
},
});
</script></body></html>`;

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('<div slot="ss:aside-empty" id="hostEmpty">host empty nudge</div>'),
);
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 <slot> 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: "<p>board card</p>", 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 <Show>
// 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);
});
});
7 changes: 7 additions & 0 deletions viewer/embed.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
60 changes: 60 additions & 0 deletions viewer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -186,6 +187,26 @@ export default function App() {
</>
)}
</For>
{/* 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. */}
<Show when={initialLoaded() && sessions.length === 0}>
<slot name={SLOTS.asideEmpty}>
{/* 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. */}
<Show when={!isReadonly()}>
<AsideEmptyRow />
</Show>
</slot>
</Show>
</div>
<div class="aside-foot">
{/* ThemePicker is a generic feature, not deployment-specific
Expand Down Expand Up @@ -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 (
<div
class="sess aside-empty"
role="button"
tabIndex={0}
aria-label="Connect an agent"
onClick={activate}
onKeyDown={(e) => {
if (e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
activate();
}
}}
>
<div class="aside-empty-head">
<span class="aside-empty-icon">
<PlugIcon />
</span>
<span class="aside-empty-label">Connect an agent</span>
</div>
<div class="aside-empty-help">Your sessions will appear here once an agent connects.</div>
</div>
);
}

function SessionView() {
const current = createMemo(() => sessions.find((x) => x.id === selected()));
return (
Expand Down
5 changes: 5 additions & 0 deletions viewer/src/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions viewer/src/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ export function ImageIcon() {
);
}

// lucide: plug — a connect glyph for the empty-sidebar affordance.
export function PlugIcon() {
return (
<Icon>
<path d="M12 22v-5" />
<path d="M9 8V2" />
<path d="M15 8V2" />
<path d="M18 8v5a4 4 0 0 1-4 5h-4a4 4 0 0 1-4-5V8Z" />
</Icon>
);
}

// lucide: trash-2
export function TrashIcon() {
return (
Expand Down
27 changes: 27 additions & 0 deletions viewer/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading