web: show worker status in sidebar
This commit is contained in:
@@ -122,7 +122,7 @@ runtime_id?: string | null,
|
||||
/**
|
||||
* Producer-owned monotonic revision for this Worker subject.
|
||||
*/
|
||||
subject_revision: number, state: SubscriptionWorkerState, workspace_id?: string | null, display_name?: string | null, profile?: string | null, working_directory_id?: SubscriptionWorkdirId | null, };
|
||||
subject_revision: number, state: SubscriptionWorkerState, workspace_id?: string | null, display_name?: string | null, profile?: string | null, repository_id?: string | null, working_directory_id?: SubscriptionWorkdirId | null, };
|
||||
|
||||
export type SubscriptionWorkdir = { working_directory_id: SubscriptionWorkdirId, repository_id: string, state: string, primary_worker_id?: SubscriptionWorkerId | null, };
|
||||
|
||||
|
||||
@@ -125,9 +125,12 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||
workersNav.includes("href={`/w/${workspaceId}/workers`}") &&
|
||||
workersNav.includes("filter(canShowWorkerInSidebar)") &&
|
||||
workersNav.includes("worker.display_name || worker.label") &&
|
||||
workersNav.includes("worker {worker.worker_id}") &&
|
||||
workersNav.includes("worker-status-dot") &&
|
||||
workersNav.includes("worker-status-spinner") &&
|
||||
workersNav.includes("worker.repository_id ?? '—'") &&
|
||||
workersNav.includes("worker.working_directory_id ?? '—'") &&
|
||||
!workersNav.includes('aria-disabled="true"'),
|
||||
"Workers sidebar should link to the Worker list page and omit registry-only Workers",
|
||||
"Workers sidebar should link to the Worker list page and show state indicators with repository/workdir metadata",
|
||||
);
|
||||
assert(
|
||||
!sidebar.includes("CompanionNavSection") &&
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { workerConsoleHref } from '$lib/workspace/console/model';
|
||||
import { workspaceWorkersStore } from './worker-subscription';
|
||||
import {
|
||||
workspaceWorkersStore,
|
||||
type SidebarWorker,
|
||||
} from './worker-subscription';
|
||||
import { canShowWorkerInSidebar } from './workers';
|
||||
import type { Worker } from './types';
|
||||
|
||||
const MAX_VISIBLE_WORKERS = 6;
|
||||
|
||||
@@ -14,14 +16,16 @@
|
||||
let { currentPath = '/', workspaceId }: Props = $props();
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let workers = $state<Worker[]>([]);
|
||||
let workers = $state<SidebarWorker[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
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)
|
||||
.slice(0, MAX_VISIBLE_WORKERS);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -59,15 +63,23 @@
|
||||
{#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
|
||||
{@const href = workerConsoleHref(worker, workspaceId)}
|
||||
<li>
|
||||
<a href={href} class="nav-item worker-nav-item" class:active={currentPath === href} aria-current={currentPath === href ? 'page' : undefined}>
|
||||
<span class="worker-title-row">
|
||||
<span class="item-title">{worker.display_name || worker.label}</span>
|
||||
<span class="worker-task-title">-</span>
|
||||
</span>
|
||||
<span class="item-meta">
|
||||
worker {worker.worker_id} · {worker.profile ? `${worker.profile} · ` : ''}{worker.state} · 🖥 {worker.host_id}
|
||||
{worker.working_directory?.current_ref ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.current_ref.slice(0, 8)}` : ''}
|
||||
<a
|
||||
href={href}
|
||||
class="worker-nav-link"
|
||||
class:active={currentPath === href}
|
||||
aria-current={currentPath === href ? 'page' : undefined}
|
||||
>
|
||||
<span class="worker-status-indicator">
|
||||
{#if worker.state === 'idle'}
|
||||
<span class="worker-status-dot" aria-label="Idle"></span>
|
||||
{:else if worker.state === 'running'}
|
||||
<span class="worker-status-spinner" aria-label="Running"></span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="worker-nav-label">{worker.display_name || worker.label}</span>
|
||||
<small class="worker-nav-meta">
|
||||
{worker.repository_id ?? '—'}・{worker.working_directory_id ?? '—'}
|
||||
</small>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -211,30 +211,82 @@
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.worker-nav-item {
|
||||
gap: 2px;
|
||||
}
|
||||
.worker-nav-item.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.62;
|
||||
}
|
||||
.worker-nav-item.disabled .item-title {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.worker-title-row {
|
||||
.worker-nav-link {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.worker-task-title {
|
||||
overflow: hidden;
|
||||
grid-template-columns: 0.75rem minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: var(--space-2);
|
||||
row-gap: 0.1rem;
|
||||
margin: 0.0625rem 0;
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-soft);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.worker-nav-link:hover {
|
||||
background: var(--interactive-hover);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.worker-nav-link.active {
|
||||
background: var(--interactive-selected);
|
||||
color: var(--accent);
|
||||
}
|
||||
.worker-status-indicator {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
display: grid;
|
||||
width: 0.75rem;
|
||||
min-height: 1.1rem;
|
||||
place-items: center;
|
||||
}
|
||||
.worker-status-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
}
|
||||
.worker-status-spinner {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border: 0.125rem solid color-mix(in oklch, var(--accent) 25%, transparent);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: worker-status-spin 0.8s linear infinite;
|
||||
}
|
||||
.worker-nav-label {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.worker-nav-meta {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
line-height: 1rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@keyframes worker-status-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.worker-status-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.sidebar-frame,
|
||||
|
||||
@@ -5,12 +5,18 @@ import {
|
||||
applyWorkspaceWorkersFrame,
|
||||
createWorkspaceWorkersProjection,
|
||||
} from './worker-subscription-model';
|
||||
import { compareWorkersForSidebar } from './workers';
|
||||
import type { Worker } from './types';
|
||||
|
||||
export type SidebarWorker = Worker & {
|
||||
repository_id: string | null;
|
||||
working_directory_id: string | null;
|
||||
};
|
||||
|
||||
export type WorkspaceWorkersState = {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
workers: Worker[];
|
||||
workers: SidebarWorker[];
|
||||
};
|
||||
|
||||
const stores = new Map<string, Readable<WorkspaceWorkersState>>();
|
||||
@@ -29,10 +35,7 @@ export function workspaceWorkersStore(workspaceId: string): Readable<WorkspaceWo
|
||||
const publish = (loading = false, error: string | null = null) => {
|
||||
const workers = [...projection.workers.values()]
|
||||
.map(projectWorker)
|
||||
.sort((left, right) =>
|
||||
left.runtime_id.localeCompare(right.runtime_id) ||
|
||||
left.worker_id.localeCompare(right.worker_id)
|
||||
);
|
||||
.sort(compareWorkersForSidebar);
|
||||
set({ loading, error, workers });
|
||||
};
|
||||
const subscription = workspaceMultiplexer(workspaceId).subscribe(
|
||||
@@ -68,7 +71,7 @@ export function workspaceWorkersStore(workspaceId: string): Readable<WorkspaceWo
|
||||
return store;
|
||||
}
|
||||
|
||||
function projectWorker(worker: SubscriptionWorker): Worker {
|
||||
function projectWorker(worker: SubscriptionWorker): SidebarWorker {
|
||||
if (!worker.runtime_id) throw new Error('Workspace Worker projection is missing runtime_id');
|
||||
const displayName = worker.display_name ?? `Worker ${worker.worker_id}`;
|
||||
return {
|
||||
@@ -91,6 +94,8 @@ function projectWorker(worker: SubscriptionWorker): Worker {
|
||||
can_stop: worker.state !== 'stopped' && worker.state !== 'cancelled',
|
||||
can_spawn_followup: false,
|
||||
},
|
||||
repository_id: worker.repository_id ?? null,
|
||||
working_directory_id: worker.working_directory_id ?? null,
|
||||
working_directory: null,
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { canOpenWorkerConsole, canShowWorkerInSidebar } from "./workers.ts";
|
||||
import {
|
||||
canOpenWorkerConsole,
|
||||
canShowWorkerInSidebar,
|
||||
compareWorkersForSidebar,
|
||||
} from "./workers.ts";
|
||||
import type { Worker } from "./types.ts";
|
||||
|
||||
declare const Deno: {
|
||||
@@ -61,3 +65,14 @@ Deno.test("live runtime workers are sidebar targets and console targets", () =>
|
||||
assertEquals(canShowWorkerInSidebar(liveWorker), true);
|
||||
assertEquals(canOpenWorkerConsole(liveWorker), true);
|
||||
});
|
||||
|
||||
Deno.test("sidebar workers sort idle then running then stopped", () => {
|
||||
const workers = [
|
||||
worker({ worker_id: "3", display_name: "Stopped", state: "stopped" }),
|
||||
worker({ worker_id: "2", display_name: "Running", state: "running" }),
|
||||
worker({ worker_id: "4", display_name: "Idle B", state: "idle" }),
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
import type { Worker } from "./types";
|
||||
import type { Worker } from './types';
|
||||
|
||||
export function canShowWorkerInSidebar(worker: Worker): boolean {
|
||||
return worker.implementation.kind !== "backend_worker_registry";
|
||||
return worker.implementation.kind !== 'backend_worker_registry';
|
||||
}
|
||||
|
||||
export function canOpenWorkerConsole(worker: Worker): boolean {
|
||||
return canShowWorkerInSidebar(worker);
|
||||
}
|
||||
|
||||
type SortableWorker = Pick<
|
||||
Worker,
|
||||
'state' | 'display_name' | 'runtime_id' | 'worker_id'
|
||||
>;
|
||||
|
||||
function workerStateRank(state: Worker['state']): number {
|
||||
switch (state) {
|
||||
case 'idle':
|
||||
return 0;
|
||||
case 'running':
|
||||
return 1;
|
||||
case 'stopped':
|
||||
return 2;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
export function compareWorkersForSidebar(
|
||||
left: SortableWorker,
|
||||
right: SortableWorker,
|
||||
): number {
|
||||
const stateOrder = workerStateRank(left.state) - workerStateRank(right.state);
|
||||
if (stateOrder !== 0) return stateOrder;
|
||||
return (left.display_name ?? left.worker_id).localeCompare(
|
||||
right.display_name ?? right.worker_id,
|
||||
) || left.runtime_id.localeCompare(right.runtime_id) ||
|
||||
left.worker_id.localeCompare(right.worker_id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user