diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 9108d2be..1808bfca 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -94,6 +94,9 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async () const sidebar = await Deno.readTextFile( new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url), ); + const sidebarCss = await Deno.readTextFile( + new URL("../sidebar/sidebar.css", import.meta.url), + ); assert( workspacePage.includes("ticketsHref") && @@ -132,6 +135,17 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async () !workersNav.includes('aria-disabled="true"'), "Workers sidebar should link to the Worker list page and show state indicators with repository/workdir metadata", ); + assert( + workersNav.includes("COLLAPSED_WORKER_COUNT = 6") && + workersNav.includes("workers.length > COLLAPSED_WORKER_COUNT") && + workersNav.includes("aria-expanded={expanded}") && + workersNav.includes("worker-overflow-chevron") && + !workersNav.includes("MAX_VISIBLE_WORKERS") && + sidebarCss.includes(".worker-overflow-toggle") && + sidebarCss.includes('[aria-expanded="true"] .worker-overflow-chevron') && + sidebarCss.includes("transform: rotate(180deg)"), + "Workers sidebar should collapse overflow behind a graphical chevron without dropping Workers", + ); assert( !sidebar.includes("CompanionNavSection") && sidebar.includes("TicketsNavSection") && diff --git a/web/workspace/src/lib/workspace/sidebar/WorkersNavSection.svelte b/web/workspace/src/lib/workspace/sidebar/WorkersNavSection.svelte index dfb62015..584add88 100644 --- a/web/workspace/src/lib/workspace/sidebar/WorkersNavSection.svelte +++ b/web/workspace/src/lib/workspace/sidebar/WorkersNavSection.svelte @@ -6,7 +6,7 @@ } from './worker-subscription'; import { canShowWorkerInSidebar } from './workers'; - const MAX_VISIBLE_WORKERS = 6; + const COLLAPSED_WORKER_COUNT = 6; type Props = { currentPath?: string; @@ -17,15 +17,21 @@ let loading = $state(true); let error = $state(null); let workers = $state([]); + let expanded = $state(false); + let visibleWorkers = $derived( + expanded ? workers : workers.slice(0, COLLAPSED_WORKER_COUNT), + ); + let hiddenWorkerCount = $derived( + Math.max(0, workers.length - COLLAPSED_WORKER_COUNT), + ); $effect(() => { + expanded = false; const subscription = workspaceWorkersStore(workspaceId); return subscription.subscribe((state) => { loading = state.loading; error = state.error; - workers = state.workers - .filter(canShowWorkerInSidebar) - .slice(0, MAX_VISIBLE_WORKERS); + workers = state.workers.filter(canShowWorkerInSidebar); }); }); @@ -60,7 +66,7 @@ {:else} {#if error}

{error}

{/if} + {#if workers.length > COLLAPSED_WORKER_COUNT} + + {/if} {/if} diff --git a/web/workspace/src/lib/workspace/sidebar/sidebar.css b/web/workspace/src/lib/workspace/sidebar/sidebar.css index 8793f695..b28f020a 100644 --- a/web/workspace/src/lib/workspace/sidebar/sidebar.css +++ b/web/workspace/src/lib/workspace/sidebar/sidebar.css @@ -277,6 +277,47 @@ text-overflow: ellipsis; white-space: nowrap; } + .worker-overflow-toggle { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + gap: var(--space-2); + width: 100%; + margin: var(--space-1) 0 0; + padding: var(--space-1) var(--space-2); + align-items: center; + border: 0; + border-radius: var(--radius-soft); + background: transparent; + color: var(--text-muted); + cursor: pointer; + } + .worker-overflow-toggle:hover, + .worker-overflow-toggle:focus-visible { + background: var(--interactive-hover); + color: var(--text-strong); + } + .worker-overflow-toggle:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 1px; + } + .worker-overflow-line { + height: 1px; + background: currentColor; + opacity: 0.45; + } + .worker-overflow-chevron { + width: 0.875rem; + height: 0.875rem; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.75; + transition: transform 140ms ease; + } + .worker-overflow-toggle[aria-expanded="true"] .worker-overflow-chevron { + transform: rotate(180deg); + } @keyframes worker-status-spin { to { transform: rotate(360deg); diff --git a/web/workspace/src/lib/workspace/sidebar/workers.test.ts b/web/workspace/src/lib/workspace/sidebar/workers.test.ts index 1a678b73..3530380b 100644 --- a/web/workspace/src/lib/workspace/sidebar/workers.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/workers.test.ts @@ -66,7 +66,7 @@ Deno.test("live runtime workers are sidebar targets and console targets", () => assertEquals(canOpenWorkerConsole(liveWorker), true); }); -Deno.test("sidebar workers sort idle then running then stopped", () => { +Deno.test("sidebar workers sort running then idle then stopped", () => { const workers = [ worker({ worker_id: "3", display_name: "Stopped", state: "stopped" }), worker({ worker_id: "2", display_name: "Running", state: "running" }), @@ -74,5 +74,5 @@ Deno.test("sidebar workers sort idle then running then stopped", () => { worker({ worker_id: "1", display_name: "Idle A", state: "idle" }), ]; workers.sort(compareWorkersForSidebar); - assertEquals(workers.map((candidate) => candidate.worker_id).join(","), "1,4,2,3"); + assertEquals(workers.map((candidate) => candidate.worker_id).join(","), "2,1,4,3"); }); diff --git a/web/workspace/src/lib/workspace/sidebar/workers.ts b/web/workspace/src/lib/workspace/sidebar/workers.ts index f857d6a9..2a5cd898 100644 --- a/web/workspace/src/lib/workspace/sidebar/workers.ts +++ b/web/workspace/src/lib/workspace/sidebar/workers.ts @@ -15,9 +15,9 @@ type SortableWorker = Pick< function workerStateRank(state: Worker['state']): number { switch (state) { - case 'idle': - return 0; case 'running': + return 0; + case 'idle': return 1; case 'stopped': return 2;