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 fb99edd9e..902c3ef29 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
@@ -957,8 +957,9 @@ Deno.test("Worker Console keeps Notify beside Submit without persistent notices"
!consolePage.includes("composer-notice") &&
!consolePage.includes('class="composer-actions"') &&
!consolePage.includes("Sent through Worker protocol.") &&
- consolePage.includes('{#if sendError}
'),
- "Composer must remove persistent notices and empty action rows but retain send errors",
+ !consolePage.includes("sendError") &&
+ consolePage.includes("reportComposerError(command.message)"),
+ "Composer must not render notices or errors below its input shell; validation uses Workspace alerts",
);
});
diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte
index d6ce44f7c..66474e15e 100644
--- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte
+++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte
@@ -137,7 +137,6 @@
let fileInput: HTMLInputElement | null = null;
let isDraggingFiles = $state(false);
let sending = $state(false);
- let sendError = $state(null);
let rewindTargets = $state([]);
let rewindHeadEntries = $state(0);
let protocolState = $state<"connecting" | "open" | "closed" | "error">(
@@ -765,7 +764,6 @@
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
- sendError = message;
pushWorkspaceAlert("error", message, {
id: controlAlertId,
title: "Worker control failed",
@@ -780,7 +778,7 @@
): ProtocolMethod | null {
const state = consoleProjection.workerState;
if (!state) {
- sendError = "Worker state snapshot is not available; reconnect before sending control.";
+ reportComposerError("Worker state snapshot is not available; reconnect before sending control.");
return null;
}
const commandId = Math.max(
@@ -1082,21 +1080,28 @@
if (event.dataTransfer?.files) addAttachmentFiles(event.dataTransfer.files);
}
+ function reportComposerError(message: string) {
+ pushWorkspaceAlert("error", message, {
+ id: controlAlertId,
+ title: "Console input",
+ });
+ }
+
async function submitDraft(
value: ComposerDraftSnapshot,
delivery: ComposerDelivery = "submit",
) {
if (delivery === "notify" && attachments.length > 0) {
- sendError = "Notify accepts text only; remove attachments or queue a Submit.";
+ reportComposerError("Notify accepts text only; remove attachments or queue a Submit.");
return;
}
const incompleteAttachment = attachments.find((attachment) =>
attachment.state !== "uploaded" || !attachment.reference
);
if (incompleteAttachment) {
- sendError = incompleteAttachment.state === "uploading"
+ reportComposerError(incompleteAttachment.state === "uploading"
? "Wait for file uploads to finish before sending."
- : incompleteAttachment.error ?? "Retry or remove the failed attachment.";
+ : incompleteAttachment.error ?? "Retry or remove the failed attachment.");
return;
}
const attachmentSegments: Segment[] = attachments.map((attachment) => ({
@@ -1109,7 +1114,7 @@
preserveExactText: value.textPastes.length > 0,
});
if (!command.ok) {
- sendError = command.message;
+ reportComposerError(command.message);
return;
}
if (!command.request) {
@@ -1134,18 +1139,17 @@
let request: WorkerConsoleInputRequest = command.request;
if (delivery === "queue" && request.kind !== "user") {
- sendError = "Queue accepts ordinary input, not a Composer command.";
+ reportComposerError("Queue accepts ordinary input, not a Composer command.");
return;
}
if (delivery === "notify") {
if (request.kind !== "user") {
- sendError = "Notify accepts ordinary text, not a Composer command.";
+ reportComposerError("Notify accepts ordinary text, not a Composer command.");
return;
}
request = { kind: "notify", content: request.content };
}
sending = true;
- sendError = null;
try {
const method = composerRequestToProtocolMethod(request);
if (isCommand) {
@@ -1160,7 +1164,7 @@
liveWorkerState = "running";
}
} catch (error) {
- sendError = error instanceof Error ? error.message : String(error);
+ reportComposerError(error instanceof Error ? error.message : String(error));
} finally {
sending = false;
}
@@ -2065,7 +2069,6 @@
- {#if sendError}{sendError}
{/if}
diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts
index f652275cc..44e61f63a 100644
--- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts
+++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts
@@ -16,6 +16,7 @@ import type {
} from "$lib/generated/protocol";
import type { Worker } from "$lib/workspace/sidebar/types";
import { EditorView } from "@codemirror/view";
+import * as alerts from "$lib/workspace/alerts/store";
import ConsolePage from "./+page.svelte";
const multiplexer = vi.hoisted(() => {
@@ -263,6 +264,38 @@ test.each(["live_protocol", "retained_snapshot"] as const)("overlapping history
expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1);
});
+test.each([
+ { text: ":peer", message: "Invalid arguments. Usage: :peer ", transportFailure: false },
+ { text: "keep this draft", message: "test transport failure", transportFailure: true },
+])("Composer keeps $text on failure without adding any footer message", async ({ text, message, transportFailure }) => {
+ const alert = vi.spyOn(alerts, "pushWorkspaceAlert");
+ vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
+ const view = render(ConsolePage, { data: pageData() });
+ await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
+ latestListener().onFrame(subscribedFrame(emptySession()));
+ await screen.findByText("No conversation to display");
+ const cm = EditorView.findFromDOM(view.container.querySelector(".cm-editor") as HTMLElement)!;
+ const form = view.container.querySelector(".console-composer") as HTMLFormElement;
+ cm.dispatch({ changes: { from: 0, insert: text }, selection: { anchor: text.length } });
+ if (transportFailure) multiplexer.sendWorkerMethod.mockImplementationOnce(() => { throw new Error(message); });
+ await fireEvent.submit(form);
+ await waitFor(() => expect(alert).toHaveBeenCalledWith("error", message, expect.any(Object)));
+ expect(cm.state.doc.toString()).toBe(text);
+ expect(multiplexer.sendWorkerMethod).toHaveBeenCalledTimes(transportFailure ? 1 : 0);
+ expect(form.children).toHaveLength(1);
+ expect(form.firstElementChild?.classList.contains("composer-input-shell")).toBe(true);
+ expect(form.querySelector('[role="alert"]')).toBeNull();
+ expect(form.textContent).not.toContain(message);
+
+ cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: ":peer teammate" } });
+ await fireEvent.submit(form);
+ await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith({
+ method: "register_peer", params: { name: "teammate" },
+ }));
+ expect(cm.state.doc.toString()).toBe("");
+ expect(form.children).toHaveLength(1);
+});
+
test("pending inputs show literal previews above Composer with revision-fenced icon actions", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
const view = render(ConsolePage, { data: pageData() });