feat: add Worker actions to workspace sidebar

This commit is contained in:
2026-09-02 16:24:29 +09:00
parent 175eda9f29
commit e00e675ed1
5 changed files with 530 additions and 3 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
"build": "deno run -A npm:vite@7.2.7 build", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "preview": "deno run -A npm:vite@7.2.7 preview"
}, },
@@ -1,6 +1,12 @@
<script lang="ts"> <script lang="ts">
import Spinner from '$lib/workspace/console/Spinner.svelte'; import Spinner from '$lib/workspace/console/Spinner.svelte';
import { workerConsoleHref } from '$lib/workspace/console/model'; import { workerConsoleHref } from '$lib/workspace/console/model';
import { pushWorkspaceAlert } from '$lib/workspace/alerts/store';
import {
canDeleteSidebarWorker,
deleteSidebarWorker,
stopSidebarWorker,
} from './worker-actions';
import { import {
workspaceWorkersStore, workspaceWorkersStore,
type SidebarWorker, type SidebarWorker,
@@ -8,6 +14,7 @@
import { canShowWorkerInSidebar, sidebarWorkerActivity } from './workers'; import { canShowWorkerInSidebar, sidebarWorkerActivity } from './workers';
const COLLAPSED_WORKER_COUNT = 6; const COLLAPSED_WORKER_COUNT = 6;
type WorkerActionKind = 'stop' | 'delete';
type Props = { type Props = {
currentPath?: string; currentPath?: string;
@@ -19,6 +26,10 @@
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 expanded = $state(false);
let openWorkerKey = $state<string | null>(null);
let menuElement = $state<HTMLElement | null>(null);
let menuTrigger = $state<HTMLButtonElement | null>(null);
let busyAction = $state<{ workerKey: string; kind: WorkerActionKind } | null>(null);
let visibleWorkers = $derived( let visibleWorkers = $derived(
expanded ? workers : workers.slice(0, COLLAPSED_WORKER_COUNT), expanded ? workers : workers.slice(0, COLLAPSED_WORKER_COUNT),
); );
@@ -26,8 +37,95 @@
Math.max(0, workers.length - COLLAPSED_WORKER_COUNT), Math.max(0, workers.length - COLLAPSED_WORKER_COUNT),
); );
function workerKey(worker: SidebarWorker): string {
return `${worker.runtime_id}:${worker.worker_id}`;
}
function isBusy(worker: SidebarWorker, kind: WorkerActionKind): boolean {
return busyAction?.workerKey === workerKey(worker) && busyAction.kind === kind;
}
function closeWorkerMenu(restoreFocus = false) {
const trigger = menuTrigger;
openWorkerKey = null;
menuElement = null;
menuTrigger = null;
if (restoreFocus) queueMicrotask(() => trigger?.focus());
}
function toggleWorkerMenu(worker: SidebarWorker, trigger: HTMLButtonElement) {
const key = workerKey(worker);
if (openWorkerKey === key) {
closeWorkerMenu();
return;
}
openWorkerKey = key;
menuTrigger = trigger;
queueMicrotask(() => {
menuElement?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
});
}
function handleWindowClick(event: MouseEvent) {
if (!openWorkerKey) return;
const target = event.target;
const owner = target instanceof Element ? target.closest('[data-worker-actions]') : null;
if (owner?.getAttribute('data-worker-actions') !== openWorkerKey) closeWorkerMenu();
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || !openWorkerKey) return;
event.preventDefault();
closeWorkerMenu(true);
}
async function stopWorker(worker: SidebarWorker) {
if (busyAction || !worker.capabilities.can_stop) return;
closeWorkerMenu();
busyAction = { workerKey: workerKey(worker), kind: 'stop' };
try {
await stopSidebarWorker(workspaceId, worker);
workers = workers.map((item) =>
workerKey(item) === workerKey(worker)
? { ...item, state: 'stopped', capabilities: { ...item.capabilities, can_stop: false } }
: item
);
pushWorkspaceAlert('info', `${worker.display_name || worker.label} stopped`, {
title: 'Worker stopped',
});
} catch (cause) {
pushWorkspaceAlert('error', cause instanceof Error ? cause.message : 'Worker stop failed', {
title: 'Worker stop failed',
});
} finally {
busyAction = null;
}
}
async function deleteWorker(worker: SidebarWorker) {
if (busyAction || !canDeleteSidebarWorker(worker)) return;
closeWorkerMenu();
busyAction = { workerKey: workerKey(worker), kind: 'delete' };
try {
await deleteSidebarWorker(workspaceId, worker);
workers = workers.filter((item) => workerKey(item) !== workerKey(worker));
pushWorkspaceAlert('info', `${worker.display_name || worker.label} deleted`, {
title: 'Worker deleted',
});
} catch (cause) {
pushWorkspaceAlert('error', cause instanceof Error ? cause.message : 'Worker deletion failed', {
title: 'Worker deletion failed',
});
} finally {
busyAction = null;
}
}
$effect(() => { $effect(() => {
expanded = false; expanded = false;
openWorkerKey = null;
menuElement = null;
menuTrigger = null;
const subscription = workspaceWorkersStore(workspaceId); const subscription = workspaceWorkersStore(workspaceId);
return subscription.subscribe((state) => { return subscription.subscribe((state) => {
loading = state.loading; loading = state.loading;
@@ -37,6 +135,8 @@
}); });
</script> </script>
<svelte:window onclick={handleWindowClick} onkeydown={handleWindowKeydown} />
<section class="sidebar-nav-section" aria-labelledby="workers-heading"> <section class="sidebar-nav-section" aria-labelledby="workers-heading">
<div class="section-heading-row"> <div class="section-heading-row">
<h2 id="workers-heading"> <h2 id="workers-heading">
@@ -70,7 +170,9 @@
{#each visibleWorkers 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)}
{@const activity = sidebarWorkerActivity(worker)} {@const activity = sidebarWorkerActivity(worker)}
<li> {@const key = workerKey(worker)}
{@const label = worker.display_name || worker.label}
<li class="worker-nav-item" data-worker-actions={key}>
<a <a
href={href} href={href}
class="worker-nav-link" class="worker-nav-link"
@@ -86,11 +188,47 @@
<span class="worker-status-dot" aria-label="Idle"></span> <span class="worker-status-dot" aria-label="Idle"></span>
{/if} {/if}
</span> </span>
<span class="worker-nav-label">{worker.display_name || worker.label}</span> <span class="worker-nav-label">{label}</span>
<small class="worker-nav-meta"> <small class="worker-nav-meta">
{worker.repository_id ?? '—'}・{worker.working_directory_id ?? '—'} {worker.repository_id ?? '—'}・{worker.working_directory_id ?? '—'}
</small> </small>
</a> </a>
<button
class="worker-actions-trigger"
class:open={openWorkerKey === key}
type="button"
aria-label={`Actions for ${label}`}
aria-haspopup="menu"
aria-expanded={openWorkerKey === key}
onclick={(event) => toggleWorkerMenu(worker, event.currentTarget)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="5" cy="12" r="1.5"></circle>
<circle cx="12" cy="12" r="1.5"></circle>
<circle cx="19" cy="12" r="1.5"></circle>
</svg>
</button>
{#if openWorkerKey === key}
<div class="worker-actions-menu" role="menu" aria-label={`Actions for ${label}`} bind:this={menuElement}>
<button
type="button"
role="menuitem"
disabled={busyAction !== null || !worker.capabilities.can_stop}
onclick={() => stopWorker(worker)}
>
{isBusy(worker, 'stop') ? 'Stopping…' : 'Stop'}
</button>
<button
class="danger"
type="button"
role="menuitem"
disabled={busyAction !== null || !canDeleteSidebarWorker(worker)}
onclick={() => deleteWorker(worker)}
>
{isBusy(worker, 'delete') ? 'Deleting…' : 'Delete'}
</button>
</div>
{/if}
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -410,6 +410,91 @@
a.sidebar-link.active { a.sidebar-link.active {
background: var(--sidebar-item-active); background: var(--sidebar-item-active);
} }
.worker-nav-item {
position: relative;
min-width: 0;
}
.worker-actions-trigger {
position: absolute;
z-index: 2;
top: 0.35rem;
right: 0.35rem;
display: grid;
width: 1.75rem;
height: 1.75rem;
place-items: center;
border: 0;
border-radius: var(--radius-soft);
background: transparent;
color: var(--text-muted);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease, background 120ms ease, color 120ms ease;
}
.worker-actions-trigger svg {
width: 1rem;
height: 1rem;
fill: currentColor;
}
.worker-nav-item:hover .worker-actions-trigger,
.worker-nav-item:focus-within .worker-actions-trigger,
.worker-actions-trigger.open {
opacity: 1;
pointer-events: auto;
}
.worker-actions-trigger:hover,
.worker-actions-trigger:focus-visible,
.worker-actions-trigger.open {
background: var(--interactive-hover);
color: var(--text-strong);
}
.worker-actions-trigger:focus-visible {
outline: 1px solid var(--accent);
outline-offset: 1px;
}
.worker-actions-menu {
position: absolute;
z-index: 20;
top: 2rem;
right: 0.35rem;
display: grid;
min-width: 7rem;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-soft);
padding: var(--space-1);
background: var(--bg-raised);
box-shadow: var(--shadow-soft);
}
.worker-actions-menu button {
border: 0;
border-radius: var(--radius-soft);
padding: var(--space-2);
background: transparent;
color: var(--text-strong);
font: inherit;
font-size: 0.76rem;
font-weight: 650;
line-height: 1.2;
text-align: left;
cursor: pointer;
}
.worker-actions-menu button:hover:not(:disabled),
.worker-actions-menu button:focus-visible {
background: var(--sidebar-item-hover);
}
.worker-actions-menu button:focus-visible {
outline: 1px solid var(--accent);
outline-offset: -1px;
}
.worker-actions-menu button.danger {
color: var(--danger);
}
.worker-actions-menu button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.worker-nav-link { .worker-nav-link {
display: grid; display: grid;
grid-template-columns: 0.75rem minmax(0, 1fr); grid-template-columns: 0.75rem minmax(0, 1fr);
@@ -418,6 +503,7 @@
row-gap: 0.1rem; row-gap: 0.1rem;
margin: 0.0625rem 0; margin: 0.0625rem 0;
padding: var(--space-2); padding: var(--space-2);
padding-right: 2.35rem;
border-radius: var(--radius-soft); border-radius: var(--radius-soft);
color: var(--text-muted); color: var(--text-muted);
text-decoration: none; text-decoration: none;
@@ -0,0 +1,123 @@
import { workspaceApiPath } from "$lib/workspace/api/http";
import type {
Diagnostic,
RuntimeCleanupExecutionResponse,
RuntimeCleanupPlanResponse,
Worker,
} from "./types";
type FetchFn = typeof fetch;
type WorkerLifecycleResponse = {
state: string;
diagnostics?: Diagnostic[];
};
function workerPath(workspaceId: string, worker: Worker): string {
return workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(worker.runtime_id)}/workers/${
encodeURIComponent(worker.worker_id)
}`,
);
}
async function responseError(response: Response): Promise<string> {
const fallback = `${response.status} ${response.statusText}`.trim();
try {
const payload = await response.json() as {
message?: string;
error?: { message?: string };
};
return payload.error?.message ?? payload.message ?? fallback;
} catch {
return fallback;
}
}
function diagnosticMessage(
diagnostics: Diagnostic[] | undefined,
fallback: string,
): string {
return diagnostics?.find((diagnostic) => diagnostic.severity === "error")
?.message ??
diagnostics?.[0]?.message ??
fallback;
}
export function canDeleteSidebarWorker(worker: Worker): boolean {
return worker.state === "stopped" || worker.state === "cancelled";
}
export async function stopSidebarWorker(
workspaceId: string,
worker: Worker,
fetchFn: FetchFn = fetch,
): Promise<void> {
const response = await fetchFn(`${workerPath(workspaceId, worker)}/stop`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ reason: "stopped from Workspace sidebar" }),
});
if (!response.ok) throw new Error(await responseError(response));
const result = await response.json() as WorkerLifecycleResponse;
if (result.state !== "accepted") {
throw new Error(
diagnosticMessage(result.diagnostics, `Worker stop was ${result.state}`),
);
}
}
export async function deleteSidebarWorker(
workspaceId: string,
worker: Worker,
fetchFn: FetchFn = fetch,
): Promise<void> {
const runtimePath = `/runtimes/${encodeURIComponent(worker.runtime_id)}`;
const planResponse = await fetchFn(
workspaceApiPath(workspaceId, `${runtimePath}/cleanup-plan`),
);
if (!planResponse.ok) throw new Error(await responseError(planResponse));
const plan = await planResponse.json() as RuntimeCleanupPlanResponse;
const candidate = plan.workers.find((item) =>
item.runtime_id === worker.runtime_id &&
item.runtime_worker_id === worker.worker_id
);
if (!candidate) throw new Error("Worker is not available for deletion");
if (candidate.blocking_reason) throw new Error(candidate.blocking_reason);
const executionResponse = await fetchFn(
workspaceApiPath(workspaceId, `${runtimePath}/cleanup-executions`),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
expected_plan_revision: plan.revision,
expected_plan_digest: plan.digest,
worker_target_ids: [candidate.target_id],
workdir_target_ids: [],
confirm_dirty_discard_target_ids: [],
}),
},
);
if (!executionResponse.ok) {
throw new Error(await responseError(executionResponse));
}
const execution = await executionResponse
.json() as RuntimeCleanupExecutionResponse;
const outcome = execution.results.find((result) =>
result.target_id === candidate.target_id
);
if (!outcome || outcome.status !== "deleted") {
throw new Error(
outcome?.message ??
diagnosticMessage(
execution.diagnostics,
"Worker deletion was not completed",
),
);
}
}
@@ -0,0 +1,180 @@
import {
canDeleteSidebarWorker,
deleteSidebarWorker,
stopSidebarWorker,
} from "../../src/lib/workspace/sidebar/worker-actions.ts";
import type { Worker } from "../../src/lib/workspace/sidebar/types.ts";
function assert(
condition: unknown,
message = "assertion failed",
): asserts condition {
if (!condition) throw new Error(message);
}
function assertEquals(actual: unknown, expected: unknown): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(`expected ${expectedJson}, received ${actualJson}`);
}
}
async function assertRejects(
operation: () => Promise<unknown>,
message: string,
): Promise<void> {
try {
await operation();
} catch (cause) {
assert(cause instanceof Error, "expected an Error");
assert(
cause.message.includes(message),
`expected error containing ${message}`,
);
return;
}
throw new Error("expected operation to reject");
}
const worker = {
runtime_id: "runtime /",
worker_id: "worker /",
state: "running",
capabilities: { can_stop: true },
} as Worker;
function jsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json" },
});
}
Deno.test("sidebar Stop uses the workspace-scoped Worker lifecycle endpoint", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ url: input.toString(), init });
return jsonResponse({ state: "accepted", diagnostics: [] });
}) as typeof fetch;
await stopSidebarWorker("team space", worker, fetchFn);
assertEquals(requests.length, 1);
assertEquals(
requests[0]?.url,
"/api/w/team%20space/runtimes/runtime%20%2F/workers/worker%20%2F/stop",
);
assertEquals(requests[0]?.init?.method, "POST");
assertEquals(JSON.parse(String(requests[0]?.init?.body)), {
reason: "stopped from Workspace sidebar",
});
});
Deno.test("sidebar Stop rejects non-accepted lifecycle responses", async () => {
const fetchFn = (() =>
Promise.resolve(
jsonResponse({
state: "rejected",
diagnostics: [{ severity: "error", message: "Worker cannot stop" }],
}),
)) as typeof fetch;
await assertRejects(
() => stopSidebarWorker("workspace", worker, fetchFn),
"Worker cannot stop",
);
});
Deno.test("sidebar Delete executes the authoritative runtime cleanup plan", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ url: input.toString(), init });
if (requests.length === 1) {
return jsonResponse({
revision: 7,
digest: "digest-7",
candidates: [],
workers: [{
target_id: "worker-target",
runtime_id: worker.runtime_id,
runtime_worker_id: worker.worker_id,
blocking_reason: null,
}],
workdirs: [],
diagnostics: [],
});
}
return jsonResponse({
results: [{
target_id: "worker-target",
status: "deleted",
message: null,
}],
diagnostics: [],
});
}) as typeof fetch;
await deleteSidebarWorker("team", { ...worker, state: "stopped" }, fetchFn);
assertEquals(requests.map((request) => request.url), [
"/api/w/team/runtimes/runtime%20%2F/cleanup-plan",
"/api/w/team/runtimes/runtime%20%2F/cleanup-executions",
]);
assertEquals(requests[1]?.init?.method, "POST");
assertEquals(JSON.parse(String(requests[1]?.init?.body)), {
expected_plan_revision: 7,
expected_plan_digest: "digest-7",
worker_target_ids: ["worker-target"],
workdir_target_ids: [],
confirm_dirty_discard_target_ids: [],
});
});
Deno.test("sidebar Delete reports cleanup-plan blocking reasons", async () => {
const fetchFn = (() =>
Promise.resolve(
jsonResponse({
revision: 8,
digest: "digest-8",
candidates: [],
workers: [{
target_id: "worker-target",
runtime_id: worker.runtime_id,
runtime_worker_id: worker.worker_id,
blocking_reason: "Worker is pinned",
}],
workdirs: [],
diagnostics: [],
}),
)) as typeof fetch;
await assertRejects(
() => deleteSidebarWorker("team", { ...worker, state: "stopped" }, fetchFn),
"Worker is pinned",
);
});
Deno.test("sidebar Delete is enabled only for terminal Worker states", () => {
assert(!canDeleteSidebarWorker(worker));
assert(canDeleteSidebarWorker({ ...worker, state: "stopped" }));
assert(canDeleteSidebarWorker({ ...worker, state: "cancelled" }));
});
Deno.test("Worker navigation exposes an accessible hover action menu", async () => {
const source = await Deno.readTextFile(
new URL(
"../../src/lib/workspace/sidebar/WorkersNavSection.svelte",
import.meta.url,
),
);
const styles = await Deno.readTextFile(
new URL("../../src/lib/workspace/sidebar/sidebar.css", import.meta.url),
);
assert(source.includes('aria-haspopup="menu"'));
assert(source.includes('role="menuitem"'));
assert(source.includes("stopSidebarWorker(workspaceId, worker)"));
assert(source.includes("deleteSidebarWorker(workspaceId, worker)"));
assert(styles.includes(".worker-nav-item:hover .worker-actions-trigger"));
});