web: show worker status in sidebar

This commit is contained in:
2026-08-02 04:18:16 +09:00
parent 606cd5fa31
commit 0fa36395e7
9 changed files with 171 additions and 43 deletions
+6
View File
@@ -561,6 +561,8 @@ pub struct SubscriptionWorker {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory_id: Option<SubscriptionWorkdirId>,
}
@@ -570,6 +572,9 @@ impl SubscriptionWorker {
if let Some(runtime_id) = &self.runtime_id {
validate_identifier("runtime_id", runtime_id, MAX_RESOURCE_ID_BYTES)?;
}
if let Some(repository_id) = &self.repository_id {
validate_identifier("repository_id", repository_id, MAX_RESOURCE_ID_BYTES)?;
}
if let Some(working_directory_id) = &self.working_directory_id {
working_directory_id.validate()?;
}
@@ -794,6 +799,7 @@ mod tests {
workspace_id: Some("workspace-1".to_string()),
display_name: Some(format!("Worker {value}")),
profile: Some("builtin:coder".to_string()),
repository_id: None,
working_directory_id: None,
}
}
+5
View File
@@ -1952,6 +1952,10 @@ impl RuntimeState {
) -> Result<SubscriptionWorker, RuntimeError> {
let worker_id = SubscriptionWorkerId::new(worker.worker_id.to_string())
.map_err(subscription_validation_error)?;
let repository_id = worker
.working_directory
.as_ref()
.map(|working_directory| working_directory.summary.repository_id.clone());
let working_directory_id = worker
.working_directory
.as_ref()
@@ -1975,6 +1979,7 @@ impl RuntimeState {
workspace_id: worker.workspace_id.clone(),
display_name: worker.request.display_name.clone(),
profile,
repository_id,
working_directory_id,
})
}
+1 -1
View File
@@ -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);
}