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(
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") &&
@@ -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<string | null>(null);
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(() => {
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);
});
});
</script>
@@ -60,7 +66,7 @@
{:else}
{#if error}<p class="section-state error">{error}</p>{/if}
<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)}
<li>
<a
@@ -84,5 +90,29 @@
</li>
{/each}
</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}
</section>
@@ -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);
@@ -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");
});
@@ -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;