ui: move worker launch to page
This commit is contained in:
parent
d149f7e936
commit
a3cb59af43
|
|
@ -1144,7 +1144,7 @@ fn embedded_runtime_options() -> EmbeddedRuntimeOptions {
|
||||||
.expect("embedded runtime id is a non-empty literal");
|
.expect("embedded runtime id is a non-empty literal");
|
||||||
EmbeddedRuntimeOptions {
|
EmbeddedRuntimeOptions {
|
||||||
runtime_id: Some(runtime_id),
|
runtime_id: Some(runtime_id),
|
||||||
display_name: Some("Workspace backend embedded Runtime".to_string()),
|
display_name: Some("embedded".to_string()),
|
||||||
..EmbeddedRuntimeOptions::default()
|
..EmbeddedRuntimeOptions::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1177,7 +1177,7 @@ impl EmbeddedWorkerRuntime {
|
||||||
FsRuntimeStoreOptions {
|
FsRuntimeStoreOptions {
|
||||||
root: store_root.into(),
|
root: store_root.into(),
|
||||||
runtime_id: Some(runtime_id),
|
runtime_id: Some(runtime_id),
|
||||||
display_name: Some("Workspace backend embedded Runtime".to_string()),
|
display_name: Some("embedded".to_string()),
|
||||||
limits: EmbeddedRuntimeOptions::default().limits,
|
limits: EmbeddedRuntimeOptions::default().limits,
|
||||||
},
|
},
|
||||||
backend,
|
backend,
|
||||||
|
|
@ -1351,7 +1351,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||||
vec![HostSummary {
|
vec![HostSummary {
|
||||||
runtime_id: self.runtime_id.clone(),
|
runtime_id: self.runtime_id.clone(),
|
||||||
host_id: self.host_id.clone(),
|
host_id: self.host_id.clone(),
|
||||||
label: "Workspace backend embedded Runtime".to_string(),
|
label: "embedded".to_string(),
|
||||||
kind: EMBEDDED_HOST_KIND.to_string(),
|
kind: EMBEDDED_HOST_KIND.to_string(),
|
||||||
status: "available".to_string(),
|
status: "available".to_string(),
|
||||||
observed_at: Utc::now().to_rfc3339(),
|
observed_at: Utc::now().to_rfc3339(),
|
||||||
|
|
|
||||||
|
|
@ -1228,19 +1228,23 @@
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding: 0.2rem 0.55rem;
|
padding: 0.2rem 0.55rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-new-form {
|
.section-action:hover,
|
||||||
|
.section-action:focus-visible,
|
||||||
|
.section-action.active {
|
||||||
|
background: var(--interactive-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-launch-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.65rem;
|
gap: 0.65rem;
|
||||||
margin: 0.6rem 0 0.75rem;
|
margin: 0;
|
||||||
padding: 0.65rem;
|
padding: 0;
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-new-form label,
|
.worker-launch-form label,
|
||||||
.settings-runtime-form label {
|
.settings-runtime-form label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
|
@ -1248,9 +1252,9 @@
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-new-form input,
|
.worker-launch-form input,
|
||||||
.worker-new-form select,
|
.worker-launch-form select,
|
||||||
.worker-new-form textarea,
|
.worker-launch-form textarea,
|
||||||
.settings-runtime-form input {
|
.settings-runtime-form input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
|
@ -1261,11 +1265,11 @@
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-new-form textarea {
|
.worker-launch-form textarea {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-new-form button,
|
.worker-launch-form button,
|
||||||
.settings-runtime-form button,
|
.settings-runtime-form button,
|
||||||
.settings-action-row button {
|
.settings-action-row button {
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
@ -1277,7 +1281,7 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-new-form button:disabled,
|
.worker-launch-form button:disabled,
|
||||||
.settings-runtime-form button:disabled,
|
.settings-runtime-form button:disabled,
|
||||||
.settings-action-row button:disabled {
|
.settings-action-row button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|
@ -1412,6 +1416,128 @@
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.worker-new-page {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
max-width: 68rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-new-page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-new-page-header h1,
|
||||||
|
.worker-new-page-header p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-new-page-header h1 {
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: clamp(1.6rem, 4vw, 2.4rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-new-page-header p:not(.eyebrow) {
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-launch-form {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
max-width: 48rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-form-section {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-form-section h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-launch-sentence {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-inline-select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-inline-select.wd-select {
|
||||||
|
min-width: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-working-directory-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-left: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-working-directory-panel h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-working-directory-fields,
|
||||||
|
.worker-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-new-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-link {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-link:hover,
|
||||||
|
.secondary-link:focus-visible {
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.worker-launch-sentence {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.worker-inline-select,
|
||||||
|
.worker-inline-select.wd-select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-working-directory-fields,
|
||||||
|
.worker-detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.worker-submit-error {
|
.worker-submit-error {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,83 @@ Deno.test("projectConsole projects initial console output and live visible proto
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("projectConsole suppresses replayed live conversation rows already present in initial transcript", () => {
|
||||||
|
const projection = projectConsole(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
sequence: 1,
|
||||||
|
role: "user",
|
||||||
|
content: "hello",
|
||||||
|
event_id: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequence: 2,
|
||||||
|
role: "assistant",
|
||||||
|
content: "world",
|
||||||
|
event_id: 11,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
cursor: "12",
|
||||||
|
event: {
|
||||||
|
event: "user_message",
|
||||||
|
data: { segments: [{ kind: "text", content: "hello" }] },
|
||||||
|
} satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cursor: "13",
|
||||||
|
event: { event: "text_delta", data: { text: "wo" } } satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cursor: "14",
|
||||||
|
event: { event: "text_delta", data: { text: "rld" } } satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cursor: "15",
|
||||||
|
event: { event: "text_done", data: { text: "world" } } satisfies Event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cursor: "16",
|
||||||
|
event: { event: "status", data: { status: "idle" } } satisfies Event,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
projection.lines.map((line) => `${line.source}:${line.kind}:${line.body}`),
|
||||||
|
["initial:user:hello", "initial:assistant:world"],
|
||||||
|
);
|
||||||
|
assertEquals(projection.status, "idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("projectConsole preserves live assistant stream not yet present in initial transcript", () => {
|
||||||
|
const projection = projectConsole(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
sequence: 1,
|
||||||
|
role: "user",
|
||||||
|
content: "hello",
|
||||||
|
event_id: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
cursor: "13",
|
||||||
|
event: { event: "text_delta", data: { text: "new" } } satisfies Event,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
projection.lines.some((line) =>
|
||||||
|
line.source === "live" && line.kind === "assistant" &&
|
||||||
|
line.body === "new" && line.streaming
|
||||||
|
),
|
||||||
|
"live in-progress assistant stream should remain visible",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("projectConsole keeps protocol lifecycle events out of the console surface", () => {
|
Deno.test("projectConsole keeps protocol lifecycle events out of the console surface", () => {
|
||||||
const projection = projectConsole([], [
|
const projection = projectConsole([], [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,8 @@ export function projectConsole(
|
||||||
initialItems: WorkerTranscriptItem[],
|
initialItems: WorkerTranscriptItem[],
|
||||||
events: Array<{ cursor: string; event: ProtocolEvent }> = [],
|
events: Array<{ cursor: string; event: ProtocolEvent }> = [],
|
||||||
): ConsoleProjection {
|
): ConsoleProjection {
|
||||||
return events.reduce(applyProtocolEvent, {
|
const visibleEvents = dedupeInitialTranscriptReplay(initialItems, events);
|
||||||
|
return visibleEvents.reduce(applyProtocolEvent, {
|
||||||
lines: initialConsoleLines(initialItems),
|
lines: initialConsoleLines(initialItems),
|
||||||
status: null,
|
status: null,
|
||||||
usage: null,
|
usage: null,
|
||||||
|
|
@ -82,6 +83,80 @@ export function projectConsole(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EventEnvelope = { cursor: string; event: ProtocolEvent };
|
||||||
|
|
||||||
|
function dedupeInitialTranscriptReplay(
|
||||||
|
initialItems: WorkerTranscriptItem[],
|
||||||
|
events: EventEnvelope[],
|
||||||
|
): EventEnvelope[] {
|
||||||
|
const remainingInitial = new Map<string, number>();
|
||||||
|
for (const item of initialItems) {
|
||||||
|
if (item.role !== "user" && item.role !== "assistant") continue;
|
||||||
|
const key = transcriptKey(item.role, item.content);
|
||||||
|
remainingInitial.set(key, (remainingInitial.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output: EventEnvelope[] = [];
|
||||||
|
let pendingAssistant: EventEnvelope[] = [];
|
||||||
|
let pendingAssistantText = "";
|
||||||
|
|
||||||
|
const flushPendingAssistant = () => {
|
||||||
|
if (pendingAssistant.length === 0) return;
|
||||||
|
output.push(...pendingAssistant);
|
||||||
|
pendingAssistant = [];
|
||||||
|
pendingAssistantText = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const envelope of events) {
|
||||||
|
const event = envelope.event;
|
||||||
|
if (event.event === "user_message") {
|
||||||
|
flushPendingAssistant();
|
||||||
|
const body = segmentsToText(event.data.segments);
|
||||||
|
if (consumeTranscriptKey(remainingInitial, "user", body)) continue;
|
||||||
|
output.push(envelope);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event.event === "text_delta") {
|
||||||
|
pendingAssistant.push(envelope);
|
||||||
|
pendingAssistantText += event.data.text;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event.event === "text_done") {
|
||||||
|
const body = event.data.text || pendingAssistantText;
|
||||||
|
if (!consumeTranscriptKey(remainingInitial, "assistant", body)) {
|
||||||
|
output.push(...pendingAssistant, envelope);
|
||||||
|
}
|
||||||
|
pendingAssistant = [];
|
||||||
|
pendingAssistantText = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
flushPendingAssistant();
|
||||||
|
output.push(envelope);
|
||||||
|
}
|
||||||
|
flushPendingAssistant();
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeTranscriptKey(
|
||||||
|
remainingInitial: Map<string, number>,
|
||||||
|
role: "user" | "assistant",
|
||||||
|
body: string,
|
||||||
|
): boolean {
|
||||||
|
const key = transcriptKey(role, body);
|
||||||
|
const remaining = remainingInitial.get(key) ?? 0;
|
||||||
|
if (remaining <= 0) return false;
|
||||||
|
if (remaining === 1) {
|
||||||
|
remainingInitial.delete(key);
|
||||||
|
} else {
|
||||||
|
remainingInitial.set(key, remaining - 1);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transcriptKey(role: "user" | "assistant", body: string): string {
|
||||||
|
return `${role}\0${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function applyProtocolEvent(
|
export function applyProtocolEvent(
|
||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
envelope: { cursor: string; event: ProtocolEvent },
|
envelope: { cursor: string; event: ProtocolEvent },
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,28 @@ Deno.test("workspace Worker list and sidebar attach through Worker Console hrefs
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("workspace Worker sidebar links New to the dedicated create page", async () => {
|
||||||
|
const workersNav = await Deno.readTextFile(
|
||||||
|
new URL("../workspace-sidebar/WorkersNavSection.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
const newWorkerPage = await Deno.readTextFile(
|
||||||
|
new URL("./../../routes/w/[workspaceId]/workers/new/+page.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
workersNav.includes('href={`/w/${workspaceId}/workers/new`}') &&
|
||||||
|
!workersNav.includes('worker-launch-form') &&
|
||||||
|
!workersNav.includes('createWorker()'),
|
||||||
|
"Workers sidebar should link to the dedicated New Worker page instead of owning the form",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
newWorkerPage.includes('worker-launch-form') &&
|
||||||
|
newWorkerPage.includes('buildBrowserCreateWorkerRequest') &&
|
||||||
|
newWorkerPage.includes("/workers/launch-options"),
|
||||||
|
"New Worker page should own launch options and creation form behavior",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Worker Console page is routed by runtime_id and worker_id through backend APIs", async () => {
|
Deno.test("Worker Console page is routed by runtime_id and worker_id through backend APIs", async () => {
|
||||||
const consolePage = await Deno.readTextFile(
|
const consolePage = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { workspaceApiPath } from '$lib/workspace-api/http';
|
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||||
import { workerConsoleHref } from '$lib/workspace-console/model';
|
import { workerConsoleHref } from '$lib/workspace-console/model';
|
||||||
import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from './worker-launch';
|
import type { ListResponse, Worker } from './types';
|
||||||
import type {
|
|
||||||
BrowserCreateWorkerResponse,
|
|
||||||
BrowserWorkingDirectoryCreateResponse,
|
|
||||||
Diagnostic,
|
|
||||||
ListResponse,
|
|
||||||
Worker,
|
|
||||||
WorkerLaunchOptionsResponse,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
const MAX_VISIBLE_WORKERS = 6;
|
const MAX_VISIBLE_WORKERS = 6;
|
||||||
|
|
||||||
|
|
@ -18,17 +10,6 @@
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DisplayError = {
|
|
||||||
message: string;
|
|
||||||
diagnostics: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ErrorPayload = {
|
|
||||||
error?: { message?: string; code?: string } | string;
|
|
||||||
message?: string;
|
|
||||||
diagnostics?: Diagnostic[];
|
|
||||||
};
|
|
||||||
|
|
||||||
let { currentPath = '/', workspaceId }: Props = $props();
|
let { currentPath = '/', workspaceId }: Props = $props();
|
||||||
|
|
||||||
function workerApiPath(path: string): string {
|
function workerApiPath(path: string): string {
|
||||||
|
|
@ -39,32 +20,16 @@
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let workers = $state<Worker[]>([]);
|
let workers = $state<Worker[]>([]);
|
||||||
let placeholder = $state<string | null>(null);
|
let placeholder = $state<string | null>(null);
|
||||||
let options = $state<WorkerLaunchOptionsResponse | null>(null);
|
|
||||||
let optionsError = $state<string | null>(null);
|
|
||||||
let showNewWorker = $state(false);
|
|
||||||
let submitting = $state(false);
|
|
||||||
let submitError = $state<DisplayError | null>(null);
|
|
||||||
let displayName = $state('Coding Worker');
|
|
||||||
let runtimeId = $state('');
|
|
||||||
let profile = $state('builtin:coder');
|
|
||||||
let initialText = $state('');
|
|
||||||
let workingDirectoryId = $state('');
|
|
||||||
let workingDirectoryRepositoryId = $state('');
|
|
||||||
let workingDirectorySelector = $state('HEAD');
|
|
||||||
let relativeCwd = $state('');
|
|
||||||
let creatingWorkingDirectory = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
loading = false;
|
loading = false;
|
||||||
workers = [];
|
workers = [];
|
||||||
options = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
void loadWorkers(controller.signal);
|
void loadWorkers(controller.signal);
|
||||||
void loadLaunchOptions(controller.signal);
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -99,250 +64,24 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLaunchOptions(signal?: AbortSignal) {
|
|
||||||
optionsError = null;
|
|
||||||
try {
|
|
||||||
const response = await fetch(workerApiPath('/workers/launch-options'), { signal });
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`worker launch options failed (${response.status})`);
|
|
||||||
}
|
|
||||||
const payload = (await response.json()) as WorkerLaunchOptionsResponse;
|
|
||||||
options = payload;
|
|
||||||
const form = defaultWorkerLaunchForm(payload, {
|
|
||||||
runtime_id: runtimeId,
|
|
||||||
display_name: displayName,
|
|
||||||
profile,
|
|
||||||
initial_text: initialText,
|
|
||||||
working_directory_id: workingDirectoryId,
|
|
||||||
working_directory_repository_id: workingDirectoryRepositoryId,
|
|
||||||
working_directory_selector: workingDirectorySelector,
|
|
||||||
relative_cwd: relativeCwd,
|
|
||||||
});
|
|
||||||
runtimeId = form.runtime_id;
|
|
||||||
displayName = form.display_name;
|
|
||||||
profile = form.profile;
|
|
||||||
workingDirectoryId = form.working_directory_id;
|
|
||||||
workingDirectoryRepositoryId = form.working_directory_repository_id;
|
|
||||||
workingDirectorySelector = form.working_directory_selector;
|
|
||||||
relativeCwd = form.relative_cwd;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
optionsError = err instanceof Error ? err.message : 'worker launch options failed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createWorkingDirectory() {
|
|
||||||
if (!workingDirectoryRepositoryId) {
|
|
||||||
submitError = { message: 'select a repository before creating a working directory', diagnostics: [] };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
creatingWorkingDirectory = true;
|
|
||||||
submitError = null;
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
workerApiPath(`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
runtime_id: runtimeId,
|
|
||||||
repository_id: workingDirectoryRepositoryId,
|
|
||||||
selector: workingDirectorySelector || null,
|
|
||||||
policy: { dirty_state: 'clean_point_only', cleanup: 'manual_or_worker_stop' },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
submitError = await responseDisplayError(response, 'working directory create failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = (await response.json()) as BrowserWorkingDirectoryCreateResponse;
|
|
||||||
const items = options?.working_directories ?? [];
|
|
||||||
options = options
|
|
||||||
? { ...options, working_directories: [...items.filter((item) => item.working_directory_id !== payload.item.working_directory_id), payload.item] }
|
|
||||||
: options;
|
|
||||||
workingDirectoryId = payload.item.working_directory_id;
|
|
||||||
} catch (err) {
|
|
||||||
submitError = exceptionDisplayError(err, 'working directory create failed');
|
|
||||||
} finally {
|
|
||||||
creatingWorkingDirectory = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createWorker() {
|
|
||||||
if (!workspaceId) {
|
|
||||||
submitError = { message: 'workspace id is unavailable', diagnostics: [] };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
submitError = null;
|
|
||||||
submitting = true;
|
|
||||||
try {
|
|
||||||
const response = await fetch(workerApiPath('/workers'), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify(buildBrowserCreateWorkerRequest({
|
|
||||||
runtime_id: runtimeId,
|
|
||||||
display_name: displayName,
|
|
||||||
profile,
|
|
||||||
initial_text: initialText,
|
|
||||||
working_directory_id: workingDirectoryId,
|
|
||||||
working_directory_repository_id: workingDirectoryRepositoryId,
|
|
||||||
working_directory_selector: workingDirectorySelector,
|
|
||||||
relative_cwd: relativeCwd,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
submitError = await responseDisplayError(response, 'worker create failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = (await response.json()) as BrowserCreateWorkerResponse;
|
|
||||||
await loadWorkers();
|
|
||||||
window.location.href = payload.console_href;
|
|
||||||
} catch (err) {
|
|
||||||
submitError = exceptionDisplayError(err, 'worker create failed');
|
|
||||||
} finally {
|
|
||||||
submitting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function responseDisplayError(response: Response, fallback: string): Promise<DisplayError> {
|
|
||||||
try {
|
|
||||||
const payload = (await response.json()) as ErrorPayload;
|
|
||||||
const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
|
|
||||||
if (typeof payload.error === 'object' && payload.error?.message) {
|
|
||||||
return {
|
|
||||||
message: `${payload.error.code ?? 'request_failed'}: ${payload.error.message}`,
|
|
||||||
diagnostics,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (payload.message) {
|
|
||||||
const code = typeof payload.error === 'string' ? payload.error : 'request_failed';
|
|
||||||
return { message: `${code}: ${payload.message}`, diagnostics };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fall through
|
|
||||||
}
|
|
||||||
return { message: `${fallback} (${response.status})`, diagnostics: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function exceptionDisplayError(err: unknown, fallback: string): DisplayError {
|
|
||||||
return {
|
|
||||||
message: err instanceof Error ? err.message : fallback,
|
|
||||||
diagnostics: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="nav-section" aria-labelledby="workers-heading">
|
<section class="nav-section" aria-labelledby="workers-heading">
|
||||||
<div class="section-heading-row">
|
<div class="section-heading-row">
|
||||||
<h2 id="workers-heading">workers</h2>
|
<h2 id="workers-heading">workers</h2>
|
||||||
<button type="button" class="section-action" onclick={() => (showNewWorker = !showNewWorker)}>
|
<a
|
||||||
{showNewWorker ? 'Close' : 'New'}
|
class="section-action"
|
||||||
</button>
|
class:active={currentPath === `/w/${workspaceId}/workers/new`}
|
||||||
|
href={`/w/${workspaceId}/workers/new`}
|
||||||
|
aria-current={currentPath === `/w/${workspaceId}/workers/new` ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
New
|
||||||
|
</a>
|
||||||
{#if !loading && !error && workers.length > 0}
|
{#if !loading && !error && workers.length > 0}
|
||||||
<span class="section-count">{workers.length}</span>
|
<span class="section-count">{workers.length}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showNewWorker}
|
|
||||||
<form class="worker-new-form" onsubmit={(event) => { event.preventDefault(); void createWorker(); }}>
|
|
||||||
<label>
|
|
||||||
<span>Display name</span>
|
|
||||||
<input bind:value={displayName} required maxlength="80" autocomplete="off" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Runtime</span>
|
|
||||||
<select bind:value={runtimeId} required>
|
|
||||||
{#if options?.runtimes.length}
|
|
||||||
{#each options.runtimes as runtime}
|
|
||||||
<option value={runtime.runtime_id} disabled={!runtime.can_spawn_worker}>
|
|
||||||
{runtime.display_name} · {runtime.status}{runtime.built_in ? ' · embedded' : ''}
|
|
||||||
</option>
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
<option value="" disabled>No Runtime options</option>
|
|
||||||
{/if}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Profile</span>
|
|
||||||
<select bind:value={profile} required>
|
|
||||||
{#if options?.profiles.length}
|
|
||||||
{#each options.profiles as candidate}
|
|
||||||
<option value={candidate.id}>{candidate.label}</option>
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
<option value="" disabled>No profile candidates</option>
|
|
||||||
{/if}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<fieldset class="worker-working-directory">
|
|
||||||
<legend>Working directory</legend>
|
|
||||||
<label>
|
|
||||||
<span>Working directory</span>
|
|
||||||
<select bind:value={workingDirectoryId}>
|
|
||||||
<option value="">No working directory selected</option>
|
|
||||||
{#each options?.working_directories ?? [] as directory}
|
|
||||||
<option value={directory.working_directory_id} disabled={directory.status !== 'active'}>
|
|
||||||
{directory.repository_id} · {directory.requested_selector ?? 'HEAD'} · {directory.resolved_commit.slice(0, 12)} · {directory.status}
|
|
||||||
</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Repository</span>
|
|
||||||
<select bind:value={workingDirectoryRepositoryId}>
|
|
||||||
{#if options?.repositories.length}
|
|
||||||
{#each options.repositories as repository}
|
|
||||||
<option value={repository.id}>{repository.display_name}</option>
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
<option value="" disabled>No configured repositories</option>
|
|
||||||
{/if}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Selector</span>
|
|
||||||
<input bind:value={workingDirectorySelector} autocomplete="off" placeholder="HEAD" />
|
|
||||||
</label>
|
|
||||||
<button type="button" disabled={creatingWorkingDirectory || !workingDirectoryRepositoryId} onclick={() => void createWorkingDirectory()}>
|
|
||||||
{creatingWorkingDirectory ? 'Creating…' : 'Create working directory'}
|
|
||||||
</button>
|
|
||||||
<label>
|
|
||||||
<span>Relative cwd</span>
|
|
||||||
<input bind:value={relativeCwd} autocomplete="off" placeholder="Optional path inside working directory" />
|
|
||||||
</label>
|
|
||||||
</fieldset>
|
|
||||||
<label>
|
|
||||||
<span>Initial text</span>
|
|
||||||
<textarea bind:value={initialText} rows="3" placeholder="Optional first instruction"></textarea>
|
|
||||||
</label>
|
|
||||||
{#if optionsError}
|
|
||||||
<p class="section-state error">{optionsError}</p>
|
|
||||||
{/if}
|
|
||||||
{#if submitError}
|
|
||||||
<div class="section-state error worker-submit-error">
|
|
||||||
<p>{submitError.message}</p>
|
|
||||||
{#if submitError.diagnostics.length > 0}
|
|
||||||
<ul class="worker-error-diagnostics">
|
|
||||||
{#each submitError.diagnostics as diagnostic}
|
|
||||||
<li class={diagnostic.severity}>
|
|
||||||
<strong>{diagnostic.code}</strong>
|
|
||||||
<span>{diagnostic.message}</span>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<button type="submit" disabled={submitting || !runtimeId || !profile || !workingDirectoryId}>
|
|
||||||
{submitting ? 'Starting…' : 'Start Coding Worker'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<p class="section-state">Checking workers…</p>
|
<p class="section-state">Checking workers…</p>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { workspaceApiPath } from '$lib/workspace-api/http';
|
||||||
|
import { buildBrowserCreateWorkerRequest, defaultWorkerLaunchForm } from '$lib/workspace-sidebar/worker-launch';
|
||||||
|
import type {
|
||||||
|
BrowserCreateWorkerResponse,
|
||||||
|
BrowserWorkingDirectoryCreateResponse,
|
||||||
|
Diagnostic,
|
||||||
|
WorkerLaunchOptionsResponse,
|
||||||
|
} from '$lib/workspace-sidebar/types';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
type DisplayError = {
|
||||||
|
message: string;
|
||||||
|
diagnostics: Diagnostic[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ErrorPayload = {
|
||||||
|
error?: { message?: string; code?: string } | string;
|
||||||
|
message?: string;
|
||||||
|
diagnostics?: Diagnostic[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
let workspaceId = $derived(data.workspaceId);
|
||||||
|
|
||||||
|
const NEW_WORKING_DIRECTORY_VALUE = '__new_working_directory__';
|
||||||
|
|
||||||
|
let loading = $state(true);
|
||||||
|
let options = $state<WorkerLaunchOptionsResponse | null>(null);
|
||||||
|
let optionsError = $state<string | null>(null);
|
||||||
|
let submitting = $state(false);
|
||||||
|
let submitError = $state<DisplayError | null>(null);
|
||||||
|
let displayName = $state('Coding Worker');
|
||||||
|
let runtimeId = $state('');
|
||||||
|
let profile = $state('builtin:coder');
|
||||||
|
let initialText = $state('');
|
||||||
|
let workingDirectoryId = $state('');
|
||||||
|
let workingDirectoryRepositoryId = $state('');
|
||||||
|
let workingDirectorySelector = $state('HEAD');
|
||||||
|
let relativeCwd = $state('');
|
||||||
|
let creatingWorkingDirectory = $state(false);
|
||||||
|
let isNewWorkingDirectorySelected = $derived(workingDirectoryId === NEW_WORKING_DIRECTORY_VALUE);
|
||||||
|
let canStartWorker = $derived(Boolean(runtimeId && profile && workingDirectoryId && !isNewWorkingDirectorySelected));
|
||||||
|
|
||||||
|
function workerApiPath(path: string): string {
|
||||||
|
return workspaceApiPath(workspaceId, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!workspaceId) {
|
||||||
|
loading = false;
|
||||||
|
optionsError = 'workspace id is unavailable';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
void loadLaunchOptions(controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadLaunchOptions(signal?: AbortSignal) {
|
||||||
|
loading = true;
|
||||||
|
optionsError = null;
|
||||||
|
try {
|
||||||
|
const response = await fetch(workerApiPath('/workers/launch-options'), { signal });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`worker launch options failed (${response.status})`);
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as WorkerLaunchOptionsResponse;
|
||||||
|
options = payload;
|
||||||
|
const form = defaultWorkerLaunchForm(payload, {
|
||||||
|
runtime_id: runtimeId,
|
||||||
|
display_name: displayName,
|
||||||
|
profile,
|
||||||
|
initial_text: initialText,
|
||||||
|
working_directory_id: workingDirectoryId,
|
||||||
|
working_directory_repository_id: workingDirectoryRepositoryId,
|
||||||
|
working_directory_selector: workingDirectorySelector,
|
||||||
|
relative_cwd: relativeCwd,
|
||||||
|
});
|
||||||
|
runtimeId = form.runtime_id;
|
||||||
|
displayName = form.display_name;
|
||||||
|
profile = form.profile;
|
||||||
|
workingDirectoryId = form.working_directory_id || NEW_WORKING_DIRECTORY_VALUE;
|
||||||
|
workingDirectoryRepositoryId = form.working_directory_repository_id;
|
||||||
|
workingDirectorySelector = form.working_directory_selector;
|
||||||
|
relativeCwd = form.relative_cwd;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
optionsError = err instanceof Error ? err.message : 'worker launch options failed';
|
||||||
|
} finally {
|
||||||
|
if (!signal?.aborted) {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWorkingDirectory() {
|
||||||
|
if (!runtimeId) {
|
||||||
|
submitError = { message: 'select a runtime before creating a working directory', diagnostics: [] };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!workingDirectoryRepositoryId) {
|
||||||
|
submitError = { message: 'select a repository before creating a working directory', diagnostics: [] };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
creatingWorkingDirectory = true;
|
||||||
|
submitError = null;
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
workerApiPath(`/runtimes/${encodeURIComponent(runtimeId)}/working-directories`), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
runtime_id: runtimeId,
|
||||||
|
repository_id: workingDirectoryRepositoryId,
|
||||||
|
selector: workingDirectorySelector || null,
|
||||||
|
policy: { dirty_state: 'clean_point_only', cleanup: 'manual_or_worker_stop' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
submitError = await responseDisplayError(response, 'working directory create failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as BrowserWorkingDirectoryCreateResponse;
|
||||||
|
const items = options?.working_directories ?? [];
|
||||||
|
options = options
|
||||||
|
? {
|
||||||
|
...options,
|
||||||
|
working_directories: [
|
||||||
|
...items.filter((item) => item.working_directory_id !== payload.item.working_directory_id),
|
||||||
|
payload.item,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: options;
|
||||||
|
workingDirectoryId = payload.item.working_directory_id;
|
||||||
|
} catch (err) {
|
||||||
|
submitError = exceptionDisplayError(err, 'working directory create failed');
|
||||||
|
} finally {
|
||||||
|
creatingWorkingDirectory = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWorker() {
|
||||||
|
if (!workspaceId) {
|
||||||
|
submitError = { message: 'workspace id is unavailable', diagnostics: [] };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNewWorkingDirectorySelected || !workingDirectoryId) {
|
||||||
|
submitError = { message: 'select or create a working directory before starting a Worker', diagnostics: [] };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitError = null;
|
||||||
|
submitting = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(workerApiPath('/workers'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(buildBrowserCreateWorkerRequest({
|
||||||
|
runtime_id: runtimeId,
|
||||||
|
display_name: displayName,
|
||||||
|
profile,
|
||||||
|
initial_text: initialText,
|
||||||
|
working_directory_id: workingDirectoryId,
|
||||||
|
working_directory_repository_id: workingDirectoryRepositoryId,
|
||||||
|
working_directory_selector: workingDirectorySelector,
|
||||||
|
relative_cwd: relativeCwd,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
submitError = await responseDisplayError(response, 'worker create failed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as BrowserCreateWorkerResponse;
|
||||||
|
await goto(payload.console_href);
|
||||||
|
} catch (err) {
|
||||||
|
submitError = exceptionDisplayError(err, 'worker create failed');
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function responseDisplayError(response: Response, fallback: string): Promise<DisplayError> {
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as ErrorPayload;
|
||||||
|
const diagnostics = Array.isArray(payload.diagnostics) ? payload.diagnostics : [];
|
||||||
|
if (typeof payload.error === 'object' && payload.error?.message) {
|
||||||
|
return {
|
||||||
|
message: `${payload.error.code ?? 'request_failed'}: ${payload.error.message}`,
|
||||||
|
diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (payload.message) {
|
||||||
|
const code = typeof payload.error === 'string' ? payload.error : 'request_failed';
|
||||||
|
return { message: `${code}: ${payload.message}`, diagnostics };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return { message: `${fallback} (${response.status})`, diagnostics: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function exceptionDisplayError(err: unknown, fallback: string): DisplayError {
|
||||||
|
return {
|
||||||
|
message: err instanceof Error ? err.message : fallback,
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>New Worker · Yoi Workspace</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="worker-new-page" aria-labelledby="new-worker-heading">
|
||||||
|
<header class="worker-new-page-header">
|
||||||
|
<div>
|
||||||
|
<h1 id="new-worker-heading">New Worker</h1>
|
||||||
|
<p>Create a Worker on a selected Runtime and working directory.</p>
|
||||||
|
</div>
|
||||||
|
<a class="secondary-link" href={`/w/${workspaceId}`}>Back to workspace</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="section-state">Loading launch options…</p>
|
||||||
|
{:else if optionsError}
|
||||||
|
<p class="section-state error">{optionsError}</p>
|
||||||
|
{:else}
|
||||||
|
<form class="worker-launch-form" onsubmit={(event) => { event.preventDefault(); void createWorker(); }}>
|
||||||
|
<section class="worker-form-section" aria-labelledby="worker-location-heading">
|
||||||
|
<h2 id="worker-location-heading">Location</h2>
|
||||||
|
|
||||||
|
<div class="worker-launch-sentence">
|
||||||
|
<span>Run at</span>
|
||||||
|
<select class="worker-inline-select wd-select" bind:value={workingDirectoryId} aria-label="Working directory">
|
||||||
|
<option value="">Select working directory</option>
|
||||||
|
{#each options?.working_directories ?? [] as directory}
|
||||||
|
<option value={directory.working_directory_id} disabled={directory.status !== 'active'}>
|
||||||
|
{directory.repository_id} · {directory.requested_selector ?? 'HEAD'}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
<option value={NEW_WORKING_DIRECTORY_VALUE}>New working directory…</option>
|
||||||
|
</select>
|
||||||
|
<span>in</span>
|
||||||
|
<select class="worker-inline-select runtime-select" bind:value={runtimeId} required aria-label="Runtime">
|
||||||
|
{#if options?.runtimes.length}
|
||||||
|
{#each options.runtimes as runtime}
|
||||||
|
<option value={runtime.runtime_id} disabled={!runtime.can_spawn_worker}>
|
||||||
|
{runtime.display_name}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<option value="" disabled>No Runtime options</option>
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isNewWorkingDirectorySelected}
|
||||||
|
<div class="new-working-directory-panel">
|
||||||
|
<h3>New working directory</h3>
|
||||||
|
<div class="new-working-directory-fields">
|
||||||
|
<label>
|
||||||
|
<span>Repository</span>
|
||||||
|
<select bind:value={workingDirectoryRepositoryId}>
|
||||||
|
{#if options?.repositories.length}
|
||||||
|
{#each options.repositories as repository}
|
||||||
|
<option value={repository.id}>{repository.display_name}</option>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<option value="" disabled>No configured repositories</option>
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Selector</span>
|
||||||
|
<input bind:value={workingDirectorySelector} autocomplete="off" placeholder="HEAD" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" disabled={creatingWorkingDirectory || !runtimeId || !workingDirectoryRepositoryId} onclick={() => void createWorkingDirectory()}>
|
||||||
|
{creatingWorkingDirectory ? 'Creating…' : 'Create working directory'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<label class="relative-cwd-field">
|
||||||
|
<span>Relative cwd inside WD</span>
|
||||||
|
<input bind:value={relativeCwd} autocomplete="off" placeholder="Optional path inside working directory" />
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="worker-form-section" aria-labelledby="worker-details-heading">
|
||||||
|
<h2 id="worker-details-heading">Worker</h2>
|
||||||
|
<div class="worker-detail-grid">
|
||||||
|
<label>
|
||||||
|
<span>Display name</span>
|
||||||
|
<input bind:value={displayName} required maxlength="80" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Profile</span>
|
||||||
|
<select bind:value={profile} required>
|
||||||
|
{#if options?.profiles.length}
|
||||||
|
{#each options.profiles as candidate}
|
||||||
|
<option value={candidate.id}>{candidate.label}</option>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<option value="" disabled>No profile candidates</option>
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Initial text</span>
|
||||||
|
<textarea bind:value={initialText} rows="7" placeholder="Optional first instruction"></textarea>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if submitError}
|
||||||
|
<div class="section-state error worker-submit-error">
|
||||||
|
<p>{submitError.message}</p>
|
||||||
|
{#if submitError.diagnostics.length > 0}
|
||||||
|
<ul class="worker-error-diagnostics">
|
||||||
|
{#each submitError.diagnostics as diagnostic}
|
||||||
|
<li class={diagnostic.severity}>
|
||||||
|
<strong>{diagnostic.code}</strong>
|
||||||
|
<span>{diagnostic.message}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="worker-new-actions">
|
||||||
|
<button type="submit" disabled={submitting || !canStartWorker}>
|
||||||
|
{submitting ? 'Starting…' : 'Start Worker'}
|
||||||
|
</button>
|
||||||
|
<a class="secondary-link" href={`/w/${workspaceId}`}>Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export function load({ params }: { params: { workspaceId: string } }) {
|
||||||
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user