fix: keep active workers visible in sidebar

This commit is contained in:
2026-08-11 15:32:50 +09:00
parent 133fbbe038
commit c292c01da3
5 changed files with 94 additions and 9 deletions
@@ -94,6 +94,9 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
const sidebar = await Deno.readTextFile( const sidebar = await Deno.readTextFile(
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url), new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
); );
const sidebarCss = await Deno.readTextFile(
new URL("../sidebar/sidebar.css", import.meta.url),
);
assert( assert(
workspacePage.includes("ticketsHref") && workspacePage.includes("ticketsHref") &&
@@ -132,6 +135,17 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
!workersNav.includes('aria-disabled="true"'), !workersNav.includes('aria-disabled="true"'),
"Workers sidebar should link to the Worker list page and show state indicators with repository/workdir metadata", "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( assert(
!sidebar.includes("CompanionNavSection") && !sidebar.includes("CompanionNavSection") &&
sidebar.includes("TicketsNavSection") && sidebar.includes("TicketsNavSection") &&
@@ -6,7 +6,7 @@
} from './worker-subscription'; } from './worker-subscription';
import { canShowWorkerInSidebar } from './workers'; import { canShowWorkerInSidebar } from './workers';
const MAX_VISIBLE_WORKERS = 6; const COLLAPSED_WORKER_COUNT = 6;
type Props = { type Props = {
currentPath?: string; currentPath?: string;
@@ -17,15 +17,21 @@
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let workers = $state<SidebarWorker[]>([]); let workers = $state<SidebarWorker[]>([]);
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(() => { $effect(() => {
expanded = false;
const subscription = workspaceWorkersStore(workspaceId); const subscription = workspaceWorkersStore(workspaceId);
return subscription.subscribe((state) => { return subscription.subscribe((state) => {
loading = state.loading; loading = state.loading;
error = state.error; error = state.error;
workers = state.workers workers = state.workers.filter(canShowWorkerInSidebar);
.filter(canShowWorkerInSidebar)
.slice(0, MAX_VISIBLE_WORKERS);
}); });
}); });
</script> </script>
@@ -60,7 +66,7 @@
{:else} {:else}
{#if error}<p class="section-state error">{error}</p>{/if} {#if error}<p class="section-state error">{error}</p>{/if}
<ul class="nav-list" aria-label="Workers"> <ul class="nav-list" aria-label="Workers">
{#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)} {#each visibleWorkers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
{@const href = workerConsoleHref(worker, workspaceId)} {@const href = workerConsoleHref(worker, workspaceId)}
<li> <li>
<a <a
@@ -84,5 +90,29 @@
</li> </li>
{/each} {/each}
</ul> </ul>
{#if workers.length > COLLAPSED_WORKER_COUNT}
<button
class="worker-overflow-toggle"
type="button"
aria-expanded={expanded}
aria-label={expanded
? 'Collapse Worker list'
: `Show ${hiddenWorkerCount} more Workers`}
title={expanded
? 'Collapse Worker list'
: `Show ${hiddenWorkerCount} more Workers`}
onclick={() => (expanded = !expanded)}
>
<span class="worker-overflow-line" aria-hidden="true"></span>
<svg
class="worker-overflow-chevron"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6"></path>
</svg>
<span class="worker-overflow-line" aria-hidden="true"></span>
</button>
{/if}
{/if} {/if}
</section> </section>
@@ -277,6 +277,47 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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 { @keyframes worker-status-spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
@@ -66,7 +66,7 @@ Deno.test("live runtime workers are sidebar targets and console targets", () =>
assertEquals(canOpenWorkerConsole(liveWorker), true); 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 = [ const workers = [
worker({ worker_id: "3", display_name: "Stopped", state: "stopped" }), worker({ worker_id: "3", display_name: "Stopped", state: "stopped" }),
worker({ worker_id: "2", display_name: "Running", state: "running" }), 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" }), worker({ worker_id: "1", display_name: "Idle A", state: "idle" }),
]; ];
workers.sort(compareWorkersForSidebar); 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");
}); });
@@ -15,9 +15,9 @@ type SortableWorker = Pick<
function workerStateRank(state: Worker['state']): number { function workerStateRank(state: Worker['state']): number {
switch (state) { switch (state) {
case 'idle':
return 0;
case 'running': case 'running':
return 0;
case 'idle':
return 1; return 1;
case 'stopped': case 'stopped':
return 2; return 2;