diff --git a/web/workspace/deno.json b/web/workspace/deno.json index a8b34b18..582f9fa5 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "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", "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 tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.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", + "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 tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.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", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/workspace/console/composer-delivery.test.ts b/web/workspace/src/lib/workspace/console/composer-delivery.test.ts new file mode 100644 index 00000000..4d052215 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/composer-delivery.test.ts @@ -0,0 +1,136 @@ +declare const Deno: { + test(name: string, fn: () => void): void; +}; + +import { + canDeliverComposerDraft, + sendComposerDelivery, +} from "./composer-delivery.ts"; + +function assertEquals(actual: unknown, expected: unknown): void { + if (actual !== expected) { + throw new Error(`Expected ${String(expected)}, got ${String(actual)}`); + } +} + +const base = { + protocolOpen: true, + sending: false, + hasText: true, + hasAttachments: false, +}; + +Deno.test("running Composer enables Queue Submit and Notify but not immediate Submit", () => { + assertEquals( + canDeliverComposerDraft({ + ...base, + delivery: "queue", + workerState: "running", + }), + true, + ); + assertEquals( + canDeliverComposerDraft({ + ...base, + delivery: "notify", + workerState: "running", + }), + true, + ); + assertEquals( + canDeliverComposerDraft({ + ...base, + delivery: "submit", + workerState: "running", + }), + false, + ); +}); + +Deno.test("running Queue Submit and Notify dispatch their protocol methods", () => { + const sent: string[] = []; + assertEquals( + sendComposerDelivery( + { ...base, delivery: "queue", workerState: "running" }, + "submit", + (method) => sent.push(method), + ), + true, + ); + assertEquals( + sendComposerDelivery( + { ...base, delivery: "notify", workerState: "running" }, + "notify", + (method) => sent.push(method), + ), + true, + ); + assertEquals(sent.join(","), "submit,notify"); +}); + +Deno.test("idle Composer enables only immediate Submit", () => { + assertEquals( + canDeliverComposerDraft({ + ...base, + delivery: "submit", + workerState: "idle", + }), + true, + ); + assertEquals( + canDeliverComposerDraft({ + ...base, + delivery: "queue", + workerState: "idle", + }), + false, + ); + assertEquals( + canDeliverComposerDraft({ + ...base, + delivery: "notify", + workerState: "idle", + }), + false, + ); +}); + +Deno.test("running delivery remains fenced by protocol, send state, and payload kind", () => { + assertEquals( + canDeliverComposerDraft({ + ...base, + delivery: "queue", + workerState: "running", + protocolOpen: false, + }), + false, + ); + assertEquals( + canDeliverComposerDraft({ + ...base, + delivery: "notify", + workerState: "running", + sending: true, + }), + false, + ); + assertEquals( + canDeliverComposerDraft({ + ...base, + delivery: "notify", + workerState: "running", + hasAttachments: true, + }), + false, + ); + assertEquals( + canDeliverComposerDraft({ + ...base, + delivery: "queue", + workerState: "running", + hasText: false, + hasAttachments: true, + }), + true, + ); +}); diff --git a/web/workspace/src/lib/workspace/console/composer-delivery.ts b/web/workspace/src/lib/workspace/console/composer-delivery.ts new file mode 100644 index 00000000..adf17883 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/composer-delivery.ts @@ -0,0 +1,39 @@ +export type ComposerDelivery = "submit" | "queue" | "notify"; + +export type ComposerDeliveryState = { + delivery: ComposerDelivery; + workerState: string; + protocolOpen: boolean; + sending: boolean; + hasText: boolean; + hasAttachments: boolean; +}; + +/** + * Resolve whether the current Composer draft can use one delivery action. + * Immediate Submit is idle-only; Queue and Notify are running-only. + */ +export function canDeliverComposerDraft(state: ComposerDeliveryState): boolean { + if (!state.protocolOpen || state.sending) return false; + + const hasInput = state.hasText || state.hasAttachments; + switch (state.delivery) { + case "submit": + return state.workerState === "idle" && hasInput; + case "queue": + return state.workerState === "running" && hasInput; + case "notify": + return state.workerState === "running" && state.hasText && + !state.hasAttachments; + } +} + +export function sendComposerDelivery( + state: ComposerDeliveryState, + method: T, + send: (method: T) => void, +): boolean { + if (!canDeliverComposerDraft(state)) return false; + send(method); + return true; +} 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 30e64ccd..a67016f6 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 @@ -1068,21 +1068,29 @@ Deno.test("Web Console uses Notify while running and exposes durable pending con ), ); - for (const token of [ - 'method: "submit"', - 'method: "notify"', - "notification_request_id: crypto.randomUUID()", - "submission_request_id: crypto.randomUUID()", - 'payload.event === "pending_submissions_changed"', - 'method: "cancel_pending_submission"', - 'method: "clear_pending_submissions"', - 'method: "continue_pending"', - "handleQueueSubmit", - "handleNotifySubmit", - ">Queue Submit", - ">Notify", - ]) { - assert(consolePage.includes(token), `missing durable pending control token: ${token}`); + for ( + const token of [ + 'method: "submit"', + 'method: "notify"', + "notification_request_id: crypto.randomUUID()", + "submission_request_id: crypto.randomUUID()", + 'payload.event === "pending_submissions_changed"', + 'method: "cancel_pending_submission"', + 'method: "clear_pending_submissions"', + 'method: "continue_pending"', + "handleQueueSubmit", + "handleNotifySubmit", + 'submitDraft(composerInputElement?.snapshot() ?? draft, "queue")', + "disabled={!canQueueDraft}", + "disabled={!canNotifyDraft}", + ">Queue Submit", + ">Notify", + ] + ) { + assert( + consolePage.includes(token), + `missing durable pending control token: ${token}`, + ); } const userCase = consolePage.slice( diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte index 60bf188d..c7852975 100644 --- a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte @@ -5,6 +5,11 @@ import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte"; import ComposerInput from "$lib/workspace/console/ComposerInput.svelte"; import type { ComposerDraftSnapshot } from "$lib/workspace/console/composer-draft"; + import { + canDeliverComposerDraft, + sendComposerDelivery, + type ComposerDelivery, + } from "$lib/workspace/console/composer-delivery"; import { buildComposerSegmentsRequest, type WorkerConsoleInputRequest, @@ -247,13 +252,42 @@ const workerState = $derived(liveWorkerState ?? worker?.state ?? "loading"); const workerRunning = $derived(workerState === "running"); const workerPaused = $derived(workerState === "paused"); - const inputReady = $derived(workerState === "idle"); const composerEditable = $derived(protocolState === "open" && !sending); - const canSubmitDraft = $derived(inputReady && composerEditable); - const canSend = $derived(canSubmitDraft && draft.content.trim().length > 0); + const draftHasText = $derived(draft.content.trim().length > 0); + const draftHasAttachments = $derived(attachments.length > 0); + const canSubmitDraft = $derived( + canDeliverComposerDraft({ + delivery: "submit", + workerState, + protocolOpen: protocolState === "open", + sending, + hasText: draftHasText, + hasAttachments: draftHasAttachments, + }), + ); + const canQueueDraft = $derived( + canDeliverComposerDraft({ + delivery: "queue", + workerState, + protocolOpen: protocolState === "open", + sending, + hasText: draftHasText, + hasAttachments: draftHasAttachments, + }), + ); + const canNotifyDraft = $derived( + canDeliverComposerDraft({ + delivery: "notify", + workerState, + protocolOpen: protocolState === "open", + sending, + hasText: draftHasText, + hasAttachments: draftHasAttachments, + }), + ); const canStopFromComposer = $derived(workerRunning && composerEditable); const composerSubmitDisabled = $derived( - workerRunning ? !canStopFromComposer : !canSend, + workerRunning ? !canStopFromComposer : !canSubmitDraft, ); async function getJson(path: string): Promise { @@ -664,7 +698,7 @@ } function handleQueueSubmit() { - void submitDraft(composerInputElement?.snapshot() ?? draft); + void submitDraft(composerInputElement?.snapshot() ?? draft, "queue"); } function handleNotifySubmit() { @@ -772,7 +806,7 @@ async function submitDraft( value: ComposerDraftSnapshot, - delivery: "submit" | "notify" = "submit", + delivery: ComposerDelivery = "submit", ) { if (delivery === "notify" && attachments.length > 0) { composerNotice = null; @@ -808,7 +842,15 @@ composerInputElement?.clear(); return; } - if (sending || !inputReady) { + const deliveryState = { + delivery, + workerState, + protocolOpen: protocolState === "open", + sending, + hasText: value.content.trim().length > 0, + hasAttachments: attachments.length > 0, + }; + if (!canDeliverComposerDraft(deliveryState)) { return; } @@ -825,7 +867,9 @@ sendError = null; try { const method = composerRequestToProtocolMethod(request); - sendProtocolMethod(method); + if (!sendComposerDelivery(deliveryState, method, sendProtocolMethod)) { + return; + } composerInputElement?.recordHistory(value); composerInputElement?.clear(); attachments = []; @@ -1963,12 +2007,12 @@ {#if workerRunning} {/if}