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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
- A pending chat message is no longer typed, or committed, after its connection lost the right to
send it while herdr was being reached: refused before the paste it stays unsent, after it the
message is marked for a check in the terminal. ([#760](https://github.com/devswha/herdr-web-ui/pull/760))
- A folded PC/workspace section no longer leaves a large empty gap below it: a visible Agents list
takes the unused height, and an empty one follows the folded section directly. The Agents height
cap, independent scrolling and fixed footer stay unchanged.
([#780](https://github.com/devswha/herdr-web-ui/pull/780) by @vibewriter-hand)

## [0.4.6] - 2026-10-11

Expand Down
1 change: 1 addition & 0 deletions scripts/ci-browser.sh
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ run_script scripts/restored-draft-demo-regression.ts
run_script scripts/composer-fit-demo-regression.ts
run_script scripts/held-rows-demo-regression.ts
run_script scripts/sidebar-activity-demo-regression.ts
run_script scripts/sidebar-spacing-demo-regression.ts
run_script scripts/workspace-touch-reorder-demo-regression.ts
run_script scripts/prompt-dock-demo-regression.ts
run_script scripts/plugin-actions-demo-regression.ts
Expand Down
2 changes: 1 addition & 1 deletion scripts/ci-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe("browser lane", () => {
it("summarizes every script and exits zero on success without enabling local evidence", async () => {
const { text, code, evidence } = await lane();
expect(code).toBe(0);
expect(rows(text)).toHaveLength(29);
expect(rows(text)).toHaveLength(30);
expect(rows(text).map((row) => row.script)).toContain("scripts/pending-pane-switch-regression.ts");
expect(rows(text).map((row) => row.script)).toContain("scripts/terminal-viewport-regression.ts");
expect(rows(text).map((row) => row.script)).toContain("scripts/palette-machines-demo-regression.ts");
Expand Down
114 changes: 114 additions & 0 deletions scripts/sidebar-spacing-demo-regression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* Purpose: verify sidebar spacing when PC groups fold in a real browser.
* Tags: sidebar, spacing, responsive, browser, regression.
* Usage: bun scripts/sidebar-spacing-demo-regression.ts
*/
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { chromium, type Page } from "playwright-core";
import { buildDemoApp } from "./demo-build.ts";

const app = mkdtempSync(join(tmpdir(), "herdr-sidebar-spacing-demo-"));
const spacing = 16;

/**
* Closes every demo workspace that runs an agent, through the demo's own API, so the Agents list
* says "No agents running" while the shell workspace keeps the PC's own list in place.
*/
const closeAgentWorkspaces = (page: Page) => page.evaluate(async () => {
for (let closed = 0; closed < 32; closed++) {
const { snapshot } = await (await fetch("/api/session")).json() as { snapshot: { panes: Array<{ pane_id: string; workspace_id: string; agent: string | null }>; agents: Array<{ pane_id: string }> } };
const agentPanes = new Set(snapshot.agents.map((agent) => agent.pane_id));
const pane = snapshot.panes.find((candidate) => agentPanes.has(candidate.pane_id) || (candidate.agent ?? "").trim() !== "");
if (!pane) return closed;
const response = await fetch("/api/workspace/close", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ workspace_id: pane.workspace_id, close_group: true }) });
if (!response.ok) throw new Error(`closing workspace ${pane.workspace_id} answered ${response.status}`);
}
throw new Error("the demo still lists an agent workspace after 32 closes");
});

let server: ReturnType<typeof Bun.serve> | undefined;
try {
await buildDemoApp(app);
const index = join(app, "index.html");
writeFileSync(index, readFileSync(index, "utf8").replace(/<script type="module"/, '<script src="./demo-transport.js"></script>\n <script type="module"'));
server = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch(request) {
const path = new URL(request.url).pathname;
const prefix = "/app/";
if (!path.startsWith(prefix)) return new Response("not found", { status: 404 });
let file = decodeURIComponent(path.slice(prefix.length));
if (!file || file.endsWith("/")) file += "index.html";
if (file.split("/").includes("..") || file.includes("\\")) return new Response("bad path", { status: 400 });
const body = Bun.file(join(app, file));
return await body.exists() ? new Response(body) : new Response("not found", { status: 404 });
} });

for (const [mobile, empty] of [[false, false], [true, false], [false, true], [true, true]] as const) {
const label = `${mobile ? "390px" : "desktop"}${empty ? " with no agents" : ""}`;
const browser = await chromium.launch({ executablePath: process.env.CHROME_PATH ?? chromium.executablePath(), headless: true, args: ["--no-sandbox"] });
try {
const context = await browser.newContext({
viewport: mobile ? { width: 390, height: 844 } : { width: 1280, height: 900 },
isMobile: mobile,
hasTouch: mobile,
locale: "en-US",
});
await context.addInitScript(() => localStorage.removeItem("herdr-web-ui:pc-collapsed:local"));
const page = await context.newPage();
await page.goto(`http://127.0.0.1:${server.port}/app/`);
await page.locator(".conn-live").waitFor({ state: "attached" });
// before the drawer opens: the selection leaves with its workspace, and a new one closes the drawer
if (empty) assert.ok(await closeAgentWorkspaces(page) > 0, `${label} closed the demo's agent workspaces`);
if (mobile) await page.locator(".drawer-toggle").tap();
const machineToggle = page.locator(".machine-toggle").first();
const agentToggle = page.locator(".agents-sidebar .agent-section-toggle");
await machineToggle.waitFor();
if (empty) await page.locator(".agents-sidebar.is-empty .agent-empty").waitFor({ state: "attached" });
if (await agentToggle.getAttribute("aria-expanded") !== "true") await agentToggle.click();
const before = await page.locator(".agents-sidebar .agent-title").allTextContents();
assert.equal(before.length > 0, !empty, `${label} ${empty ? "has no agent rows" : "has agent rows"} before folding`);
assert.equal(await machineToggle.getAttribute("aria-expanded"), "true");

await machineToggle.click();
await page.waitForFunction(() => !document.querySelector(".machine-workspaces"));
const metrics = await page.evaluate(() => {
const box = (selector: string) => document.querySelector<HTMLElement>(selector)?.getBoundingClientRect();
const shell = box(".sidebar-shell")!;
const machine = box(".machine-group")!;
const list = box(".machine-list")!;
const agents = box(".agents-sidebar")!;
const contents = document.querySelector<HTMLElement>(".agent-list-contents")!;
const footer = box(".sidebar-footer")!;
return {
shellHeight: shell.height,
machineBottom: machine.bottom,
listBottom: list.bottom,
agentsTop: agents.top,
agentsHeight: agents.height,
contentsOverflow: getComputedStyle(contents).overflowY,
footerBottom: footer.bottom,
shellBottom: shell.bottom,
};
});
assert.ok(metrics.agentsTop - metrics.machineBottom <= spacing, `${label} folded PC gap is ${metrics.agentsTop - metrics.machineBottom}px`);
assert.ok(metrics.listBottom - metrics.machineBottom <= spacing, `${label} folded list padding is ${metrics.listBottom - metrics.machineBottom}px`);
assert.ok(metrics.agentsHeight <= metrics.shellHeight / 2 + 1, `${label} Agents keeps its half-sidebar cap`);
assert.equal(metrics.contentsOverflow, "auto", `${label} Agents keeps scroll ownership`);
assert.equal(Math.round(metrics.footerBottom), Math.round(metrics.shellBottom), `${label} footer stays at the bottom`);
assert.deepEqual(await page.locator(".agents-sidebar .agent-title").allTextContents(), before, `${label} agent order is unchanged`);

await agentToggle.click();
assert.equal(await agentToggle.getAttribute("aria-expanded"), "false");
assert.equal(await page.locator(".agent-list-contents").getAttribute("hidden"), "");
console.log(`PASS ${label} sidebar spacing after folding the PC section`);
await context.close();
} finally {
await browser.close();
}
}
} finally {
server?.stop(true);
rmSync(app, { recursive: true, force: true });
}
5 changes: 5 additions & 0 deletions src/components/AgentSidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
.agents-sidebar.is-collapsed,
.agents-sidebar.is-empty { flex: 0 0 auto; }

/* A visible Agents list fills the space released by folded PC groups, up to its existing cap. */
.sidebar-shell:not(:has(.machine-list .machine-workspaces)):has(.agents-sidebar:not(.is-collapsed):not(.is-empty)) .agents-sidebar {
flex: 1 1 auto;
}

/* positioned: a row's visually hidden label is absolute, and must not size the page instead */
.agent-list-contents {
position: relative;
Expand Down
7 changes: 7 additions & 0 deletions src/components/Machines.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
scroll-padding-bottom: var(--space-4);
}

/* When every PC is folded, keep this section at its headings instead of reserving the whole
flexible column, whether or not Agents has rows: a visible Agents list takes the space below
it (AgentSidebar.css) and the footer stays at the bottom (Sidebar.css). */
.sidebar-shell:not(:has(.machine-list .machine-workspaces)) .machine-list {
flex: 0 1 auto;
}

/* a list that is cut fades out at its lower edge */
.machine-list::after {
content: "";
Expand Down
5 changes: 5 additions & 0 deletions src/components/Sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,11 @@ button.sidebar-section-label:focus-visible { outline-offset: calc(var(--ring-off
padding: var(--space-2) var(--side-gutter) calc(var(--space-2) + env(safe-area-inset-bottom, 0px));
}

/* Keep the footer at the bottom when both lists are short after the upper section folds. */
.sidebar-shell:not(:has(.machine-list .machine-workspaces)) .sidebar-footer {
margin-top: auto;
}

.sidebar-footer-action {
justify-content: flex-start;
gap: 0;
Expand Down
Loading