fix: remove composer footer error messages

This commit is contained in:
2026-10-01 10:09:52 +09:00
parent 6b2f40a2a2
commit 2ddf547308
3 changed files with 51 additions and 14 deletions
@@ -957,8 +957,9 @@ Deno.test("Worker Console keeps Notify beside Submit without persistent notices"
!consolePage.includes("composer-notice") && !consolePage.includes("composer-notice") &&
!consolePage.includes('class="composer-actions"') && !consolePage.includes('class="composer-actions"') &&
!consolePage.includes("Sent through Worker protocol.") && !consolePage.includes("Sent through Worker protocol.") &&
consolePage.includes('{#if sendError}<p class="error" role="alert">'), !consolePage.includes("sendError") &&
"Composer must remove persistent notices and empty action rows but retain send errors", consolePage.includes("reportComposerError(command.message)"),
"Composer must not render notices or errors below its input shell; validation uses Workspace alerts",
); );
}); });
@@ -137,7 +137,6 @@
let fileInput: HTMLInputElement | null = null; let fileInput: HTMLInputElement | null = null;
let isDraggingFiles = $state(false); let isDraggingFiles = $state(false);
let sending = $state(false); let sending = $state(false);
let sendError = $state<string | null>(null);
let rewindTargets = $state<RewindTarget[]>([]); let rewindTargets = $state<RewindTarget[]>([]);
let rewindHeadEntries = $state(0); let rewindHeadEntries = $state(0);
let protocolState = $state<"connecting" | "open" | "closed" | "error">( let protocolState = $state<"connecting" | "open" | "closed" | "error">(
@@ -765,7 +764,6 @@
); );
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
sendError = message;
pushWorkspaceAlert("error", message, { pushWorkspaceAlert("error", message, {
id: controlAlertId, id: controlAlertId,
title: "Worker control failed", title: "Worker control failed",
@@ -780,7 +778,7 @@
): ProtocolMethod | null { ): ProtocolMethod | null {
const state = consoleProjection.workerState; const state = consoleProjection.workerState;
if (!state) { 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; return null;
} }
const commandId = Math.max( const commandId = Math.max(
@@ -1082,21 +1080,28 @@
if (event.dataTransfer?.files) addAttachmentFiles(event.dataTransfer.files); if (event.dataTransfer?.files) addAttachmentFiles(event.dataTransfer.files);
} }
function reportComposerError(message: string) {
pushWorkspaceAlert("error", message, {
id: controlAlertId,
title: "Console input",
});
}
async function submitDraft( async function submitDraft(
value: ComposerDraftSnapshot, value: ComposerDraftSnapshot,
delivery: ComposerDelivery = "submit", delivery: ComposerDelivery = "submit",
) { ) {
if (delivery === "notify" && attachments.length > 0) { 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; return;
} }
const incompleteAttachment = attachments.find((attachment) => const incompleteAttachment = attachments.find((attachment) =>
attachment.state !== "uploaded" || !attachment.reference attachment.state !== "uploaded" || !attachment.reference
); );
if (incompleteAttachment) { if (incompleteAttachment) {
sendError = incompleteAttachment.state === "uploading" reportComposerError(incompleteAttachment.state === "uploading"
? "Wait for file uploads to finish before sending." ? "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; return;
} }
const attachmentSegments: Segment[] = attachments.map((attachment) => ({ const attachmentSegments: Segment[] = attachments.map((attachment) => ({
@@ -1109,7 +1114,7 @@
preserveExactText: value.textPastes.length > 0, preserveExactText: value.textPastes.length > 0,
}); });
if (!command.ok) { if (!command.ok) {
sendError = command.message; reportComposerError(command.message);
return; return;
} }
if (!command.request) { if (!command.request) {
@@ -1134,18 +1139,17 @@
let request: WorkerConsoleInputRequest = command.request; let request: WorkerConsoleInputRequest = command.request;
if (delivery === "queue" && request.kind !== "user") { 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; return;
} }
if (delivery === "notify") { if (delivery === "notify") {
if (request.kind !== "user") { if (request.kind !== "user") {
sendError = "Notify accepts ordinary text, not a Composer command."; reportComposerError("Notify accepts ordinary text, not a Composer command.");
return; return;
} }
request = { kind: "notify", content: request.content }; request = { kind: "notify", content: request.content };
} }
sending = true; sending = true;
sendError = null;
try { try {
const method = composerRequestToProtocolMethod(request); const method = composerRequestToProtocolMethod(request);
if (isCommand) { if (isCommand) {
@@ -1160,7 +1164,7 @@
liveWorkerState = "running"; liveWorkerState = "running";
} }
} catch (error) { } catch (error) {
sendError = error instanceof Error ? error.message : String(error); reportComposerError(error instanceof Error ? error.message : String(error));
} finally { } finally {
sending = false; sending = false;
} }
@@ -2065,7 +2069,6 @@
</div> </div>
</div> </div>
</div> </div>
{#if sendError}<p class="error" role="alert">{sendError}</p>{/if}
</form> </form>
</div> </div>
@@ -16,6 +16,7 @@ import type {
} from "$lib/generated/protocol"; } from "$lib/generated/protocol";
import type { Worker } from "$lib/workspace/sidebar/types"; import type { Worker } from "$lib/workspace/sidebar/types";
import { EditorView } from "@codemirror/view"; import { EditorView } from "@codemirror/view";
import * as alerts from "$lib/workspace/alerts/store";
import ConsolePage from "./+page.svelte"; import ConsolePage from "./+page.svelte";
const multiplexer = vi.hoisted(() => { 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); expect(view.container.querySelectorAll(".activity-summary")).toHaveLength(1);
}); });
test.each([
{ text: ":peer", message: "Invalid arguments. Usage: :peer <worker-name>", 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 () => { 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" }))); vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
const view = render(ConsolePage, { data: pageData() }); const view = render(ConsolePage, { data: pageData() });