diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index d9463ecb..f04cbd9a 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -1144,7 +1144,7 @@ fn embedded_runtime_options() -> EmbeddedRuntimeOptions { .expect("embedded runtime id is a non-empty literal"); EmbeddedRuntimeOptions { runtime_id: Some(runtime_id), - display_name: Some("Workspace backend embedded Runtime".to_string()), + display_name: Some("embedded".to_string()), ..EmbeddedRuntimeOptions::default() } } @@ -1177,7 +1177,7 @@ impl EmbeddedWorkerRuntime { FsRuntimeStoreOptions { root: store_root.into(), runtime_id: Some(runtime_id), - display_name: Some("Workspace backend embedded Runtime".to_string()), + display_name: Some("embedded".to_string()), limits: EmbeddedRuntimeOptions::default().limits, }, backend, @@ -1351,7 +1351,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { vec![HostSummary { runtime_id: self.runtime_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(), status: "available".to_string(), observed_at: Utc::now().to_rfc3339(), diff --git a/web/workspace/src/app.css b/web/workspace/src/app.css index e11f749b..d880ecdb 100644 --- a/web/workspace/src/app.css +++ b/web/workspace/src/app.css @@ -1228,19 +1228,23 @@ font-size: 0.72rem; padding: 0.2rem 0.55rem; 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; gap: 0.65rem; - margin: 0.6rem 0 0.75rem; - padding: 0.65rem; - border: 1px solid var(--line); - border-radius: 0.75rem; - background: rgba(255, 255, 255, 0.03); + margin: 0; + padding: 0; } -.worker-new-form label, +.worker-launch-form label, .settings-runtime-form label { display: grid; gap: 0.25rem; @@ -1248,9 +1252,9 @@ font-size: 0.78rem; } -.worker-new-form input, -.worker-new-form select, -.worker-new-form textarea, +.worker-launch-form input, +.worker-launch-form select, +.worker-launch-form textarea, .settings-runtime-form input { width: 100%; border: 1px solid var(--line); @@ -1261,11 +1265,11 @@ font: inherit; } -.worker-new-form textarea { +.worker-launch-form textarea { resize: vertical; } -.worker-new-form button, +.worker-launch-form button, .settings-runtime-form button, .settings-action-row button { border: 0; @@ -1277,7 +1281,7 @@ cursor: pointer; } -.worker-new-form button:disabled, +.worker-launch-form button:disabled, .settings-runtime-form button:disabled, .settings-action-row button:disabled { cursor: not-allowed; @@ -1412,6 +1416,128 @@ 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 { .worker-submit-error { display: grid; diff --git a/web/workspace/src/lib/workspace-console/model.test.ts b/web/workspace/src/lib/workspace-console/model.test.ts index 42eaf18f..b1d92330 100644 --- a/web/workspace/src/lib/workspace-console/model.test.ts +++ b/web/workspace/src/lib/workspace-console/model.test.ts @@ -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", () => { const projection = projectConsole([], [ { diff --git a/web/workspace/src/lib/workspace-console/model.ts b/web/workspace/src/lib/workspace-console/model.ts index 489c5169..ea1f3c37 100644 --- a/web/workspace/src/lib/workspace-console/model.ts +++ b/web/workspace/src/lib/workspace-console/model.ts @@ -74,7 +74,8 @@ export function projectConsole( initialItems: WorkerTranscriptItem[], events: Array<{ cursor: string; event: ProtocolEvent }> = [], ): ConsoleProjection { - return events.reduce(applyProtocolEvent, { + const visibleEvents = dedupeInitialTranscriptReplay(initialItems, events); + return visibleEvents.reduce(applyProtocolEvent, { lines: initialConsoleLines(initialItems), status: 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(); + 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, + 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( projection: ConsoleProjection, envelope: { cursor: string; event: ProtocolEvent }, 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 2027b115..70503742 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 @@ -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 () => { const consolePage = await Deno.readTextFile( new URL( diff --git a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte index e6385d8c..cc05a5f0 100644 --- a/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte +++ b/web/workspace/src/lib/workspace-sidebar/WorkersNavSection.svelte @@ -1,15 +1,7 @@