diff --git a/crates/protocol/src/subscription.rs b/crates/protocol/src/subscription.rs index 9fc4181a..71f8cf2d 100644 --- a/crates/protocol/src/subscription.rs +++ b/crates/protocol/src/subscription.rs @@ -561,6 +561,8 @@ pub struct SubscriptionWorker { #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub working_directory_id: Option, } @@ -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, } } diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 52ee3e40..bcc52385 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -1952,6 +1952,10 @@ impl RuntimeState { ) -> Result { 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, }) } diff --git a/web/workspace/src/lib/generated/protocol.ts b/web/workspace/src/lib/generated/protocol.ts index 7a0ff0af..2b9c0e51 100644 --- a/web/workspace/src/lib/generated/protocol.ts +++ b/web/workspace/src/lib/generated/protocol.ts @@ -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, }; 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 fc9170d3..90a08c10 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 @@ -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") && diff --git a/web/workspace/src/lib/workspace/sidebar/WorkersNavSection.svelte b/web/workspace/src/lib/workspace/sidebar/WorkersNavSection.svelte index 67e45cdc..dfb62015 100644 --- a/web/workspace/src/lib/workspace/sidebar/WorkersNavSection.svelte +++ b/web/workspace/src/lib/workspace/sidebar/WorkersNavSection.svelte @@ -1,8 +1,10 @@ @@ -59,15 +63,23 @@ {#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)} {@const href = workerConsoleHref(worker, workspaceId)}
  • - - - {worker.display_name || worker.label} - - - - - 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)}` : ''} + + + {#if worker.state === 'idle'} + + {:else if worker.state === 'running'} + + {/if} + {worker.display_name || worker.label} + + {worker.repository_id ?? '—'}・{worker.working_directory_id ?? '—'} +
  • {/each} diff --git a/web/workspace/src/lib/workspace/sidebar/sidebar.css b/web/workspace/src/lib/workspace/sidebar/sidebar.css index 2ee5c574..8793f695 100644 --- a/web/workspace/src/lib/workspace/sidebar/sidebar.css +++ b/web/workspace/src/lib/workspace/sidebar/sidebar.css @@ -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, diff --git a/web/workspace/src/lib/workspace/sidebar/worker-subscription.ts b/web/workspace/src/lib/workspace/sidebar/worker-subscription.ts index ab55dc38..7f3c72d6 100644 --- a/web/workspace/src/lib/workspace/sidebar/worker-subscription.ts +++ b/web/workspace/src/lib/workspace/sidebar/worker-subscription.ts @@ -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>(); @@ -29,10 +35,7 @@ export function workspaceWorkersStore(workspaceId: string): Readable { 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 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"); +}); diff --git a/web/workspace/src/lib/workspace/sidebar/workers.ts b/web/workspace/src/lib/workspace/sidebar/workers.ts index bcf14592..f857d6a9 100644 --- a/web/workspace/src/lib/workspace/sidebar/workers.ts +++ b/web/workspace/src/lib/workspace/sidebar/workers.ts @@ -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); +}