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/yellow-hoops-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": patch
---

Add a minimal control to collapse the desktop session sidebar into a narrow rail.
10 changes: 10 additions & 0 deletions e2e/embed-aside-head-slot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,15 @@ test.describe("embedded engine: ss:aside-head slot", () => {
expect(headBox).not.toBeNull();
expect(listBox).not.toBeNull();
expect(headBox!.y).toBeLessThan(listBox!.y);

// Collapsing hides host-projected sidebar content without unmounting it;
// expanding restores the projection intact.
const aside = page.locator("aside");
await page.getByRole("button", { name: "Collapse sidebar" }).click();
await expect(aside).toHaveCSS("width", "40px");
await expect(hostHead).toBeHidden();
await page.getByRole("button", { name: "Expand sidebar" }).click();
await expect(aside).toHaveCSS("width", "248px");
await expect(hostHead).toBeVisible();
});
});
10 changes: 8 additions & 2 deletions e2e/embed-main-slot.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,14 @@ test("embedded engine: ss:main slot takes over the main pane while the sidebar s
await expect(page.locator("main slot[name='ss:main']")).toHaveCount(1);

// The sidebar (full layout) stays — the override is the main pane only, not the
// whole viewport.
await expect(page.locator("aside")).toBeVisible();
// whole viewport — and its engine-owned collapse control works across the shadow root.
const aside = page.locator("aside");
await expect(aside).toBeVisible();
await page.getByRole("button", { name: "Collapse sidebar" }).click();
await expect(aside).toHaveCSS("width", "40px");
await expect(hostMain).toBeVisible();
await page.getByRole("button", { name: "Expand sidebar" }).click();
await expect(aside).toHaveCSS("width", "248px");

// The engine's own board content is replaced: with a child assigned to ss:main,
// the slot's fallback (#sessionView stream) stays in the DOM — native <slot>
Expand Down
35 changes: 35 additions & 0 deletions e2e/viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,40 @@ test("Cmd+Option+Up/Down switches between sessions, wrapping at the ends", async
await expect(page.locator(".sess.sel .sess-title")).toContainText("one session");
});

test("the desktop sidebar can collapse to a minimal rail and expand again", async ({
page,
server,
}) => {
await publish(server.url, { html: "<p>x</p>", title: "Compact", agent: "e2e" });
await page.setViewportSize({ width: 701, height: 800 });
await page.goto(server.url);

const aside = page.locator("aside");
const main = page.locator("main");
const toggle = page.getByRole("button", { name: "Collapse sidebar" });
const expandedMainWidth = (await main.boundingBox())!.width;

await expect(toggle).toBeVisible();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await toggle.click();

const expand = page.getByRole("button", { name: "Expand sidebar" });
await expect(expand).toHaveAttribute("aria-expanded", "false");
await expect(aside).toHaveCSS("width", "40px");
await expect(page.locator("#sessionList")).toBeHidden();
expect((await main.boundingBox())!.width).toBeGreaterThan(expandedMainWidth + 150);

await expand.click();
await expect(page.getByRole("button", { name: "Collapse sidebar" })).toBeVisible();
await expect(aside).toHaveCSS("width", "248px");
await expect(page.locator("#sessionList")).toBeVisible();

// One pixel narrower hands control back to the existing mobile drawer.
await page.setViewportSize({ width: 700, height: 800 });
await expect(page.getByRole("button", { name: "Collapse sidebar" })).toBeHidden();
await expect(aside).not.toBeInViewport();
});

test("at phone width the sidebar collapses into a drawer and actions stay visible", async ({
page,
server,
Expand Down Expand Up @@ -409,6 +443,7 @@ test("at phone width the sidebar collapses into a drawer and actions stay visibl
const card = page.locator(".card:not(#whatsNew)");
await expect(card).toBeVisible();
await expect(page.locator("aside")).not.toBeInViewport();
await expect(page.getByRole("button", { name: "Collapse sidebar" })).toBeHidden();
expect((await card.boundingBox())!.width).toBeGreaterThan(300);

// hover-only card actions are always visible at narrow widths
Expand Down
35 changes: 30 additions & 5 deletions viewer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@ import { ConnectInstructions } from "./Connect.tsx";
import { renderNotes } from "./notes.ts";
import { SessionTimeline } from "./SessionTimeline.tsx";
import { StreamSkeleton } from "./Skeleton.tsx";
import { MoonIcon, PlugIcon, SunIcon, SystemIcon } from "./icons.tsx";
import {
MoonIcon,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlugIcon,
SunIcon,
SystemIcon,
} from "./icons.tsx";
import {
activeTheme,
colorModePreference,
Expand Down Expand Up @@ -112,6 +119,8 @@ function pageTitle(
}

export default function App() {
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false);

onMount(() => {
// Await the initial route resolution (the standalone post fetch, or the
// first session fetch), then mark the workspace decided and tell the host
Expand Down Expand Up @@ -243,10 +252,26 @@ export default function App() {
</Show>
</header>
<Show when={!streamMode()}>
<aside>
<Show when={!host().hideBrand}>
<Brand />
</Show>
<aside classList={{ "sidebar-collapsed": sidebarCollapsed() }}>
<div class="sidebar-head">
<Show when={!host().hideBrand}>
<Brand />
</Show>
<button
class="sidebar-toggle"
type="button"
aria-label={sidebarCollapsed() ? "Expand sidebar" : "Collapse sidebar"}
aria-expanded={!sidebarCollapsed()}
aria-controls="sessionList"
title={sidebarCollapsed() ? "Expand sidebar" : "Collapse sidebar"}
onClick={() => setSidebarCollapsed(!sidebarCollapsed())}
>
<Show when={sidebarCollapsed()} fallback={<PanelLeftCloseIcon />}>
<PanelLeftOpenIcon />
</Show>
<span class="dot" classList={{ show: unread().size > 0 }}></span>
</button>
</div>
<UpdateBanner />
{/* Host-overridable region (SLOTS.asideHead): the sidebar header, above the
session list. Empty by default (self-hosted shows nothing here); an embedder
Expand Down
22 changes: 22 additions & 0 deletions viewer/src/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,28 @@ export function MaximizeIcon() {
);
}

// lucide: panel-left-close — collapse the desktop session sidebar.
export function PanelLeftCloseIcon() {
return (
<Icon>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" />
<path d="m16 15-3-3 3-3" />
</Icon>
);
}

// lucide: panel-left-open — expand the desktop session sidebar.
export function PanelLeftOpenIcon() {
return (
<Icon>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M9 3v18" />
<path d="m14 9 3 3-3 3" />
</Icon>
);
}

// lucide: plug — a connect glyph for the empty-sidebar affordance.
export function PlugIcon() {
return (
Expand Down
83 changes: 78 additions & 5 deletions viewer/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,70 @@ aside {
border-right: 0.5px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
transition: width 160ms var(--ease-out-strong);
}
.sidebar-head {
display: flex;
align-items: flex-start;
min-width: 0;
}
.sidebar-toggle {
position: relative;
flex: none;
width: 28px;
height: 28px;
margin: 12px 10px 0 auto;
padding: 6px;
color: var(--muted);
background: transparent;
border: 0;
border-radius: 7px;
cursor: pointer;
transition:
color 120ms ease,
background-color 120ms ease;
}
.sidebar-toggle:hover {
color: var(--text);
background: var(--hover);
}
.sidebar-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.sidebar-toggle svg {
display: block;
width: 16px;
height: 16px;
}
.sidebar-toggle .dot {
position: absolute;
top: 3px;
right: 3px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
display: none;
}
.sidebar-toggle .dot.show {
display: block;
}
@media (min-width: 701px) {
aside.sidebar-collapsed {
width: 40px;
}
aside.sidebar-collapsed > :not(.sidebar-head),
aside.sidebar-collapsed .sidebar-head > :not(.sidebar-toggle) {
display: none;
}
aside.sidebar-collapsed .sidebar-head {
justify-content: center;
}
aside.sidebar-collapsed .sidebar-toggle {
margin-inline: 0;
}
}
.brand {
display: flex;
Expand All @@ -90,10 +154,12 @@ aside {
text-align: left;
cursor: pointer;
}
/* Fill the sidebar width so the whole wordmark row is a click target; the mobile
topbar wordmark (a flex sibling of the menu button) stays content-width. */
aside > .brand {
width: 100%;
/* Let the sidebar wordmark fill the space beside the collapse control; the
mobile topbar wordmark stays content-width. */
.sidebar-head > .brand {
min-width: 0;
flex: 1;
padding-right: 8px;
}
.brand:hover {
color: var(--accent);
Expand Down Expand Up @@ -1962,11 +2028,18 @@ iframe {
transition: transform 220ms var(--ease-drawer);
will-change: transform;
}
aside > .brand {
.sidebar-head {
display: contents;
}
.sidebar-head > .brand {
min-height: 48px;
padding-top: 12px;
padding-right: 16px;
padding-bottom: 10px;
}
.sidebar-toggle {
display: none;
}
#sessionList {
padding: 6px 8px 10px;
}
Expand Down
Loading