fix: keep Console composer editable in every state

This commit is contained in:
2026-10-11 02:33:34 +09:00
parent a9a67fd6ad
commit 04bdf1da23
9 changed files with 200 additions and 115 deletions
@@ -181,6 +181,67 @@ async function attach(page: Page, name: string): Promise<void> {
buffer: Buffer.from("Synthetic fixture document"),
});
}
Deno.test("production Composer stays editable before connection and during admission without losing history focus or later edits", () =>
fixture(async (page, base, errors) => {
await page.evaluate(() => localStorage.setItem(
"yoi.composer-history.v1.workspace.console-live-review",
JSON.stringify({ version: 1, entries: [{ segments: [{ kind: "text", content: "previous input" }], preserveExactText: false }] }),
));
let release!: () => void;
const waiting = new Promise<void>((resolve) => { release = resolve; });
await page.route("**/workers/worker-a/session", async (route) => {
await waiting;
await route.continue();
});
try {
await page.reload({ waitUntil: "domcontentloaded" });
await page.locator(".cm-content[contenteditable=true]").waitFor();
await draft(page, "offline draft");
const editor = page.locator(".cm-content");
const assertFocused = async () => assert(await editor.evaluate((node) =>
node === document.activeElement && node.getAttribute("contenteditable") === "true"
));
await page.keyboard.press("Control+Enter");
assertEquals((await state(base)).methods.filter((method) => method.method === "submit"), []);
assert(await page.locator(".composer-send-button").isDisabled());
await page.keyboard.press("ArrowUp");
assertEquals(await editor.textContent(), "previous input");
await assertFocused();
await page.keyboard.press("ArrowDown");
assertEquals(await editor.textContent(), "offline draft");
await assertFocused();
await page.keyboard.type(" edited");
assertEquals(await editor.textContent(), "offline draft edited");
release();
await page.getByText("Ready. Choose a Feature", { exact: false }).waitFor();
await page.waitForFunction(() => !(document.querySelector(".composer-send-button") as HTMLButtonElement)?.disabled);
await assertFocused();
await control(base, { submit: "hold" });
await page.keyboard.press("Control+Enter");
await eventually(base, (value) => value.methods.filter((method) => method.method === "submit").length === 1);
assert(await page.locator(".composer-send-button").isDisabled());
await assertFocused();
await page.keyboard.type(" while pending");
await page.keyboard.press("Control+Enter");
assertEquals((await state(base)).methods.filter((method) => method.method === "submit").length, 1);
await control(base, { accept_pending: true });
await page.getByRole("button", { name: "Stop Worker", exact: true }).waitFor();
assertEquals(await editor.textContent(), "offline draft edited while pending");
await assertFocused();
await page.keyboard.type(" while running");
assertEquals(await editor.textContent(), "offline draft edited while pending while running");
await page.keyboard.press("ArrowUp");
assertEquals(await editor.textContent(), "offline draft edited");
await assertFocused();
await page.keyboard.press("ArrowDown");
assertEquals(await editor.textContent(), "offline draft edited while pending while running");
await assertFocused();
assertEquals(errors, []);
} finally {
release();
}
}));
Deno.test("production leading slash remains responsive after loop fix", () =>
fixture(async (page, base, errors, caseOutput) => {
await draft(page, "/");
@@ -27,6 +27,42 @@ function deferred<T>() {
return { promise, resolve };
}
test("history, clear and restore keep editing and focus without stealing focus from elsewhere", async () => {
const ui = render(ComposerInput, { historyScope: "always-editable" });
const cm = editor(ui.container);
type(cm, "previous input");
ui.component.recordHistory(ui.component.snapshot());
ui.component.clear();
type(cm, "draft");
const editable = () => {
expect(cm.state.readOnly).toBe(false);
expect(cm.contentDOM.getAttribute("contenteditable")).toBe("true");
expect(cm.hasFocus).toBe(true);
};
await fireEvent.keyDown(cm.contentDOM, { key: "ArrowUp" });
expect(cm.state.doc.toString()).toBe("previous input");
editable();
await fireEvent.keyDown(cm.contentDOM, { key: "ArrowDown" });
expect(cm.state.doc.toString()).toBe("draft");
editable();
await fireEvent.paste(cm.contentDOM, { clipboardData: { getData: () => " pasted" } });
expect(cm.state.doc.toString()).toBe("draft pasted");
expect(undo(cm)).toBe(true);
expect(cm.state.doc.toString()).toBe("draft");
expect(redo(cm)).toBe(true);
expect(cm.state.doc.toString()).toBe("draft pasted");
editable();
ui.component.clear();
editable();
ui.component.restoreSegments([{ kind: "text", content: "restored" }]);
editable();
ui.component.replaceRange(8, 8, " edit");
expect(cm.state.doc.toString()).toBe("restored edit");
cm.contentDOM.blur();
ui.component.restoreSegments([{ kind: "text", content: "background restore" }]);
expect(cm.hasFocus).toBe(false);
});
test("commands auto-open with descriptions; ambiguous Tab does not choose the first; arrows beat history", async () => {
const oncommand = vi.fn();
const onsubmit = vi.fn();
@@ -122,7 +158,7 @@ test("file popup scrolls selection, drills into directories and Enter accepts wi
expect(onsubmit).not.toHaveBeenCalled();
});
test("cursor changes, Escape, scope changes and disable discard late file results", async () => {
test("cursor changes, Escape and scope changes discard late file results", async () => {
const old = deferred<Array<{ value: string }>>();
const resolveFileCompletions = vi.fn(() => old.promise);
const ui = render(ComposerInput, {
@@ -145,10 +181,7 @@ test("cursor changes, Escape, scope changes and disable discard late file result
await ui.findByRole("listbox");
await ui.rerender({ completionScope: "b" });
expect(ui.queryByRole("listbox")).toBeNull();
type(cm, ":rew");
await ui.findByRole("listbox");
await ui.rerender({ disabled: true });
expect(ui.queryByRole("listbox")).toBeNull();
});
test("synchronous draft and cursor ABA invalidate old Feature completion generations", async () => {
@@ -1,6 +1,5 @@
<script lang="ts">
import {
Compartment,
EditorSelection,
EditorState,
Prec,
@@ -62,7 +61,6 @@
} from "#lib/workspace/console/composer-invocation.ts";
interface Props {
disabled?: boolean;
ariaLabel?: string;
ariaKeyShortcuts?: string;
historyScope: string;
@@ -85,7 +83,6 @@
}
let {
disabled = false,
ariaLabel = "Message",
ariaKeyShortcuts = "Meta+Enter Control+Enter",
historyScope,
@@ -137,7 +134,6 @@
return retained;
},
});
const editable = new Compartment();
const completionId = $props.id();
let completionToken = $state<ComposerCompletionToken | null>(null);
@@ -151,7 +147,7 @@
const completionStart = $derived(Math.max(0, (completionSelected ?? 0) - 5));
const visibleCompletions = $derived(completionEntries.slice(completionStart, completionStart + 6));
function closeCompletion(): void {
export function closeCompletion(): void {
completionVersion++;
completionAbort?.abort();
completionAbort = null;
@@ -170,7 +166,7 @@
if (!force && context === completionContext) return;
completionContext = context;
closeCompletion();
if (disabled || !view.hasFocus || view.composing || !selection.empty) return;
if (!view.hasFocus || view.composing || !selection.empty) return;
const token = completionTokenAt(
document,
selection.head,
@@ -213,7 +209,7 @@
}
function acceptCompletion(action: "tab" | "accept", index = completionSelected): void {
if (!view || !completionToken || disabled) return;
if (!view || !completionToken) return;
const token = completionToken;
const document = view.state.doc.toString();
if (token.kind === "command" && index === null) {
@@ -256,7 +252,7 @@
}
function completionKeydown(event: KeyboardEvent): boolean {
if (disabled || event.isComposing || view?.composing || event.keyCode === 229) return false;
if (event.isComposing || view?.composing || event.keyCode === 229) return false;
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return false;
if (event.key === "Escape" && completionToken) {
event.preventDefault(); closeCompletion(); return true;
@@ -299,12 +295,6 @@
completionContext = "";
});
$effect(() => {
void disabled;
closeCompletion();
completionContext = "";
});
const registerPaste = StateEffect.define<{ key: number; paste: ComposerPaste }>();
const pasteRegistry = StateField.define<ReadonlyMap<number, ComposerPaste>>({
create: () => new Map(),
@@ -503,7 +493,7 @@
segment: Segment,
options: { from?: number; to?: number; cleanup?: boolean; label?: string; uploadReservation?: boolean } = {},
): number | null {
if (!view || view.state.readOnly) return null;
if (!view) return null;
const selection = view.state.selection.main;
const from = options.from ?? selection.from;
const to = options.to ?? selection.to;
@@ -525,7 +515,7 @@
}
function chipifySelectedInvocation(strict = false): void {
if (!view || view.composing || view.state.readOnly) return;
if (!view || view.composing) return;
// Only completion-selected or explicitly edited calls become structured input.
// Descriptor discovery must never promote unrelated typed or pasted prose.
for (const selected of [...view.state.field(selectedInvocationState)].reverse()) {
@@ -553,7 +543,7 @@
let editVersion = 0;
let editorGeneration = 0;
async function editInvocation(key: number): Promise<void> {
if (!view || disabled) return;
if (!view) return;
const scope = completionScope;
const version = ++editVersion;
const document = view.state.doc.toString();
@@ -570,7 +560,7 @@
descriptor = entries.find((entry) => entry.invocation?.identity === invocation.identity)?.invocation ?? undefined;
} catch { return; }
}
if (!descriptor || !view || disabled || version !== editVersion || scope !== completionScope ||
if (!descriptor || !view || version !== editVersion || scope !== completionScope ||
generation !== editorGeneration || document !== view.state.doc.toString() ||
selection.from !== view.state.selection.main.from || selection.to !== view.state.selection.main.to) return;
atom = composerTypedAtoms(view.state.doc.toString(), view.state.field(typedRegistry))
@@ -622,7 +612,6 @@
cursorLine,
lineCount: currentView.state.doc.lines,
selectionEmpty: selection.empty,
readOnly: currentView.state.readOnly,
composing: currentView.composing,
})) return false;
@@ -679,7 +668,6 @@
}
function handlePasteEvent(event: ClipboardEvent): boolean {
if (disabled || view?.state.readOnly) return false;
if (handleComposerImagePaste(event, onpasteimages)) return true;
const content = event.clipboardData?.getData("text/plain");
if (!content) return false;
@@ -718,7 +706,6 @@
currentView: EditorView,
direction: "backward" | "forward",
): boolean {
if (currentView.state.readOnly) return false;
const selection = currentView.state.selection.main;
const atoms = [
...composerPasteAtoms(
@@ -753,18 +740,6 @@
history(),
Prec.highest(EditorView.domEventHandlers({ keydown: completionKeydown })),
Prec.highest(keymap.of([
{
key: "Mod-z",
run: (currentView) => currentView.state.readOnly,
},
{
key: "Mod-Shift-z",
run: (currentView) => currentView.state.readOnly,
},
{
key: "Mod-y",
run: (currentView) => currentView.state.readOnly,
},
{
key: "ArrowUp",
run: (currentView) => browseHistory(currentView, "older"),
@@ -791,10 +766,6 @@
: []),
pasteChips,
typedChips,
editable.of([
EditorView.editable.of(!disabled),
EditorState.readOnly.of(disabled),
]),
EditorState.allowMultipleSelections.of(false),
EditorView.lineWrapping,
EditorView.contentAttributes.of({
@@ -840,7 +811,7 @@
const target = event.target instanceof Element
? event.target.closest(".composer-typed-chip")
: null;
if (!target || currentView.state.readOnly) return false;
if (!target) return false;
let position: number;
try {
position = currentView.posAtDOM(target);
@@ -867,7 +838,6 @@
return true;
},
cut(event, currentView) {
if (disabled) return false;
const content = selectedClipboardContent(currentView.state);
if (content === null || !event.clipboardData) return false;
event.preventDefault();
@@ -887,7 +857,7 @@
shouldSubmitChatKey(event, {
mode: "mod-enter",
modKey: "auto",
enabled: !disabled,
enabled: true,
})
) {
event.preventDefault();
@@ -918,16 +888,6 @@
};
});
$effect(() => {
const isDisabled = disabled;
view?.dispatch({
effects: editable.reconfigure([
EditorView.editable.of(!isDisabled),
EditorState.readOnly.of(isDisabled),
]),
});
});
export function snapshot(): ComposerDraftSnapshot {
chipifySelectedInvocation(true);
return currentSnapshot();
@@ -957,7 +917,7 @@
}
export function replaceRange(from: number, to: number, content: string): void {
if (!view || view.state.readOnly) return;
if (!view) return;
view.dispatch({
changes: { from, to, insert: content },
selection: EditorSelection.cursor(from + content.length),
@@ -1007,9 +967,6 @@
closeCompletion();
completionContext = "";
view.setState(EditorState.create({ extensions: editorExtensions }));
view.dispatch({ effects: editable.reconfigure([
EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled),
]) });
emitChange();
}
@@ -1070,7 +1027,7 @@
}
</script>
<div class="composer-input" class:disabled>
<div class="composer-input">
{#if completionToken}
<div class="completion-popup">
<div id={completionId} role="listbox" aria-label="Composer completions" aria-busy={completionBusy}>
@@ -1147,10 +1104,6 @@
}
.completion-hint { padding: var(--space-1) var(--space-2); color: var(--text-muted); }
.composer-input.disabled {
opacity: 0.56;
}
.composer-input :global(.cm-editor) {
min-height: 2.65rem;
max-height: 10rem;
@@ -37,7 +37,7 @@ export class ComposerAdmissions {
const prior = this.get(target);
if (prior && prior.status !== "rejected") {
throw new Error(
"Resolve the in-flight admission before editing or sending a new draft.",
"Resolve the in-flight admission before sending a new draft.",
);
}
const unchanged = prior && prior.method.method === method.method &&
@@ -11,7 +11,6 @@ export type ComposerHistoryCursor = {
cursorLine: number;
lineCount: number;
selectionEmpty: boolean;
readOnly: boolean;
composing: boolean;
};
@@ -85,7 +84,7 @@ function sameEntry(
export function shouldBrowseComposerHistory(
cursor: ComposerHistoryCursor,
): boolean {
if (cursor.readOnly || cursor.composing || !cursor.selectionEmpty) {
if (cursor.composing || !cursor.selectionEmpty) {
return false;
}
return cursor.direction === "older"
@@ -1050,13 +1050,13 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
composerInput.includes(
"composerDeletionRange(selection, atoms, direction)",
) &&
composerInput.includes("EditorState.readOnly.of(isDisabled)") &&
composerInput.includes('key: "Mod-z"') &&
composerInput.includes("if (!view || view.state.readOnly) return") &&
composerInput.includes("if (currentView.state.readOnly) return false") &&
!composerInput.includes("readOnly") &&
!composerInput.includes("disabled") &&
!composerInput.includes("EditorView.editable") &&
composerInput.includes("...historyKeymap") &&
consolePage.includes("completionScope={activeComposerTargetKey}") &&
composerInput.includes("version !== completionVersion") &&
consolePage.includes("if (!composerEditable) return") &&
consolePage.includes("if (!canUploadAttachments) return") &&
composerInput.includes('chip.setAttribute("aria-label", label)') &&
composerInput.includes("preserveExactText = false") &&
consolePage.includes("const command = buildComposerSegmentsRequest(") &&
@@ -1083,20 +1083,18 @@ Deno.test("Worker Console routes image paste through the existing attachment upl
input.indexOf("function selectedClipboardContent"),
);
assert(
paste.indexOf("if (disabled || view?.state.readOnly)") <
paste.indexOf("handleComposerImagePaste(event, onpasteimages)") &&
paste.indexOf("handleComposerImagePaste(event, onpasteimages)") <
paste.indexOf('getData("text/plain")') &&
consolePage.includes("onpasteimages={addPastedImages}") &&
consolePage.includes("addAttachmentFiles(files.map(namePastedImage))"),
"image paste must respect editability and take priority over text before using the attachment callback",
"image paste must take priority over text before using the attachment callback",
);
const attach = consolePage.slice(
consolePage.indexOf("function addAttachmentFiles"),
consolePage.indexOf("async function removeAttachment"),
);
assert(
attach.includes("if (!composerEditable) return") &&
attach.includes("if (!canUploadAttachments) return") &&
attach.includes("MAX_FILES_PER_SUBMISSION - attachments.length") &&
attach.includes(".slice(0, available)") &&
attach.includes("startAttachmentUpload(attachment)"),
@@ -1273,14 +1271,15 @@ Deno.test("Worker Console route resolves logical Worker authority before Runtime
'const workerRunning = $derived(workerState === "running");',
) &&
consolePage.includes(
'const composerEditable = $derived(protocolState === "open" && !sending);',
'const canSendComposerControl = $derived(protocolState === "open" && !sending);',
) &&
consolePage.includes('sendWorkerControl("cancel")') &&
consolePage.includes("lifecycleMethod(command)") &&
consolePage.includes("command_id: commandId") &&
!consolePage.includes("expected_execution_generation") &&
consolePage.includes("onsubmit={handleComposerSubmit}") &&
consolePage.includes("disabled={!composerEditable}") &&
!consolePage.includes("disabled={!composerEditable}") &&
consolePage.includes("if (!canSendComposerControl) return;") &&
consolePage.includes("class:stop={workerRunning}") &&
consolePage.includes('"Stop Worker"') &&
consolePage.includes("disabled={composerSubmitDisabled}") &&
@@ -114,6 +114,7 @@
let workerError = $state<string | null>(untrack(() => data.workerError));
type ComposerInputHandle = {
snapshot(): ComposerDraftSnapshot;
closeCompletion(): void;
focus(): void;
containsTarget(target: EventTarget | null): boolean;
cursor(): number;
@@ -313,7 +314,8 @@
);
const workerRunning = $derived(workerState === "running");
const workerPaused = $derived(workerState === "paused");
const composerEditable = $derived(protocolState === "open" && !sending);
const canSendComposerControl = $derived(protocolState === "open" && !sending);
const canUploadAttachments = $derived(consoleTarget !== null);
const draftHasText = $derived(draft.content.trim().length > 0);
const draftHasAttachments = $derived(
attachments.length > 0 || draft.segments.some((segment) => segment.kind === "uploaded_file"),
@@ -348,7 +350,7 @@
hasAttachments: draftHasAttachments,
}),
);
const canStopFromComposer = $derived(workerRunning && composerEditable);
const canStopFromComposer = $derived(workerRunning && canSendComposerControl);
const composerSubmitDisabled = $derived(
workerRunning ? !canStopFromComposer : !canSubmitDraft,
);
@@ -1024,12 +1026,12 @@
activeComposerTargetKey = nextKey;
syncAdmission();
const pending = admissions.get(nextKey);
const restored = pending && pending.status !== "rejected"
// A pending request owns its snapshot, not subsequent edits to the draft.
const restored = composerDrafts.get(nextKey) ?? (
pending && pending.status !== "rejected"
? cachedComposerDraft(pending.snapshot)
: composerDrafts.get(nextKey) ?? {
segments: [],
preserveExactText: false,
};
: { segments: [], preserveExactText: false }
);
void tick().then(() => {
if (activeComposerTargetKey !== nextKey) return;
composerInputElement?.restoreSegments(
@@ -1055,6 +1057,7 @@
}
function handleComposerSubmit() {
if (!canSendComposerControl) return;
const value = composerSnapshotOrReport();
if (!value) return;
if (value.document.trimStart().startsWith(":")) {
@@ -1108,7 +1111,7 @@
reference: NonNullable<ComposerAttachment["reference"]>,
): void {
if (!attachments.some((candidate) => candidate.id === attachment.id) ||
!composerInputElement || !composerEditable || attachment.uploadPath !== attachmentPath()) {
!composerInputElement || !consoleTarget || attachment.uploadPath !== attachmentPath()) {
const reservation = uploadReservations.get(attachment.id);
uploadReservations.delete(attachment.id);
if (reservation !== undefined) composerInputElement?.cancelUpload(reservation);
@@ -1164,7 +1167,7 @@
}
function addAttachmentFiles(files: Iterable<File>): void {
if (!composerEditable) return;
if (!canUploadAttachments) return;
const stagedFiles = draft.segments.filter((segment) => segment.kind === "uploaded_file").length;
const available = Math.max(0, MAX_FILES_PER_SUBMISSION - attachments.length - stagedFiles);
for (const file of Array.from(files).slice(0, available)) {
@@ -1289,7 +1292,7 @@
// Commands are controls, not idle-only chat submissions. Keep the
// removed header controls available while the Worker is busy too.
const isCommand = delivery === "submit" && command.request.kind !== "user";
if (isCommand ? !composerEditable : !canDeliverComposerDraft(deliveryState)) {
if (isCommand ? !canSendComposerControl : !canDeliverComposerDraft(deliveryState)) {
return;
}
@@ -1590,8 +1593,8 @@
if (segment.kind === "uploaded_file") stagedAttachmentPaths.delete(segment.file.artifact_id);
}
composerInputElement?.recordHistory(record.snapshot);
// A late acceptance of a previously rejected attempt must not erase
// edits made while the recovered draft was unlocked.
// Admission never locks the editor. Do not erase edits made
// after this request's snapshot, including after a rejection.
if (JSON.stringify(draft.segments) === JSON.stringify(record.snapshot.segments)) {
composerInputElement?.clear(true);
attachments = [];
@@ -1600,8 +1603,8 @@
liveWorkerState = "running";
}
} else {
// The editor has stayed frozen with the exact typed input, Undo state
// and staged resources. Unlock it; unchanged retries reuse the IDs.
// Keep the current draft and Undo state; the pending snapshot
// remains available for unchanged retries with the same IDs.
reportComposerError(message ?? "Worker rejected the input.");
}
syncAdmission();
@@ -1652,6 +1655,8 @@
function rejectPendingCompletion(error: Error, targetKey = activeComposerTargetKey) {
admissionDisconnected(targetKey);
// Invalidate suggestions on disconnect without locking the draft.
composerInputElement?.closeCompletion();
fileCompletions.close(error);
featureCompletions.close(error);
for (const lane of featureArgumentCompletions.values()) lane.close(error);
@@ -2151,7 +2156,6 @@
historyScope={workspaceId}
ariaLabel="Console input"
ariaKeyShortcuts="Meta+Enter Control+Enter"
disabled={!composerEditable}
onchange={handleComposerChange}
onkeydown={handleComposerKeydown}
completionScope={activeComposerTargetKey}
@@ -2212,7 +2216,7 @@
type="button"
aria-label="Attach file"
title="Attach file"
disabled={!composerEditable || attachments.length >= MAX_FILES_PER_SUBMISSION}
disabled={!canUploadAttachments || attachments.length >= MAX_FILES_PER_SUBMISSION}
onclick={() => fileInput?.click()}
>
<svg
@@ -397,6 +397,40 @@ test("Feature and argument completion correlate same-prefix ABA, context and oth
expect((await screen.findByRole("option")).textContent).toContain("a b/資料");
});
test.each(["running", "paused"] as const)("Composer edits remain available while Worker is %s", async (state) => {
const { cm, view } = await liveComposer();
protocolEvent({ event: "worker_state", data: { snapshot: { last_command_id: 0, state: { kind: "busy", state: { kind: "run", state } } } } });
await settleMicrotasks();
cm.focus();
await fireEvent.paste(cm.contentDOM, { clipboardData: { getData: () => "next input" } });
expect(cm.state.doc.toString()).toBe("next input");
expect(cm.state.readOnly).toBe(false);
expect(cm.contentDOM.getAttribute("contenteditable")).toBe("true");
expect(undo(cm)).toBe(true);
expect(cm.state.doc.toString()).toBe("");
expect(cm.hasFocus).toBe(true);
expect(view.container.querySelector(".composer-input.disabled")).toBeNull();
});
test("stopped Worker with retained conversation allows drafting but not sending", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => Response.json(
String(input).includes("/session/history") ? emptyHistoryPage() : { availability: "retained_snapshot", snapshot: emptySession() },
)));
const data = pageData();
data.worker.state = "stopped";
const view = render(ConsolePage, { data });
await screen.findByText("Read-only retained conversation");
const cm = EditorView.findFromDOM(view.container.querySelector(".cm-editor") as HTMLElement)!;
cm.focus();
await fireEvent.paste(cm.contentDOM, { clipboardData: { getData: () => "draft for later" } });
expect(cm.state.doc.toString()).toBe("draft for later");
expect(cm.state.readOnly).toBe(false);
expect(cm.contentDOM.getAttribute("contenteditable")).toBe("true");
await fireEvent.submit(view.container.querySelector(".console-composer")!);
expect(multiplexer.sendWorkerMethod).not.toHaveBeenCalled();
expect(cm.state.doc.toString()).toBe("draft for later");
});
test("Submit rejection keeps exact typed draft, request/invocation IDs, and only acceptance clears", async () => {
const alert = vi.spyOn(alerts, "pushWorkspaceAlert");
const { cm, form } = await liveComposer();
@@ -406,13 +440,16 @@ test("Submit rejection keeps exact typed draft, request/invocation IDs, and only
expect(first.method).toBe("submit");
expect(first.params.input[0]).toMatchObject({ kind: "feature_invoke", invocation: { identity: runDescriptor.identity, arguments: [{ name: "path", value: { kind: "string", value: "a b/資料" } }] } });
const doc = cm.state.doc.toString();
await waitFor(() => expect(cm.state.readOnly).toBe(true));
expect(cm.state.readOnly).toBe(false);
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
await fireEvent.keyDown(cm.contentDOM, { key: "z", ctrlKey: true });
expect(cm.state.doc.toString()).not.toBe(doc);
expect(undo(cm)).toBe(true);
expect(cm.state.doc.toString()).toBe(doc);
await fireEvent.submit(form);
expect(multiplexer.sendWorkerMethod.mock.calls.filter(([method]) => method.method === "submit")).toHaveLength(1);
protocolEvent({ event: "submission_rejected", data: { submission_request_id: "other-client", message: "foreign" } });
await settleMicrotasks();
expect(cm.state.readOnly).toBe(true);
expect(cm.state.readOnly).toBe(false);
protocolEvent({ event: "submission_rejected", data: { submission_request_id: first.params.submission_request_id, message: "Feature validation failed" } });
await waitFor(() => expect(cm.state.readOnly).toBe(false));
expect(alert).toHaveBeenCalledWith("error", "Feature validation failed", expect.any(Object));
@@ -505,12 +542,18 @@ test("cached unsent typed draft survives target round-trip and remains sendable"
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith({ method: "submit", params: { submission_request_id: expect.any(String), input: [expect.objectContaining({ kind: "feature_invoke" })] } });
});
test("unknown Submit survives target ABA and cannot be deleted into a new admission", async () => {
test("unknown Submit keeps its original retry while edits survive disconnect and target ABA", async () => {
const { cm, form, view } = await liveComposer();
await selectRun(cm);
await fireEvent.submit(form);
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "submit" }>;
await waitFor(() => expect(cm.state.readOnly).toBe(true));
latestListener().onStatus?.("closed", { kind: "transport", message: "ack lost" });
await settleMicrotasks();
expect(cm.state.readOnly).toBe(false);
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
cm.dispatch({ changes: { from: 0, to: cm.state.doc.length, insert: "new draft while disconnected" }, selection: { anchor: 28 } });
await fireEvent.submit(form);
expect(multiplexer.sendWorkerMethod.mock.calls.filter(([method]) => method.method === "submit")).toHaveLength(1);
await view.rerender({ data: pageData("worker-b", "runtime-b") });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(2));
latestListener().onFrame(subscribedFrame());
@@ -518,13 +561,15 @@ test("unknown Submit survives target ABA and cannot be deleted into a new admiss
await view.rerender({ data: pageData() });
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(3));
latestListener().onFrame(subscribedFrame());
await waitFor(() => expect(cm.dom.querySelector(".composer-typed-chip")).not.toBeNull());
expect(cm.state.readOnly).toBe(true);
const doc = cm.state.doc.toString();
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
expect(cm.state.doc.toString()).toBe(doc);
await waitFor(() => expect(cm.state.doc.toString()).toBe("new draft while disconnected"));
expect(cm.state.readOnly).toBe(false);
await fireEvent.submit(form);
expect(multiplexer.sendWorkerMethod.mock.calls.filter(([method]) => method.method === "submit")).toHaveLength(1);
await fireEvent.click(screen.getByRole("button", { name: "Retry admission" }));
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith(first);
protocolEvent({ event: "submission_accepted", data: { submission_request_id: first.params.submission_request_id, submission_id: "accepted", disposition: "queued" } });
await settleMicrotasks();
expect(cm.state.doc.toString()).toBe("new draft while disconnected");
view.unmount();
});
@@ -76,7 +76,6 @@ Deno.test("Composer history uses only the multiline input boundaries", () => {
const base = {
lineCount: 3,
selectionEmpty: true,
readOnly: false,
composing: false,
};
@@ -108,14 +107,6 @@ Deno.test("Composer history uses only the multiline input boundaries", () => {
selectionEmpty: false,
}),
);
assert(
!shouldBrowseComposerHistory({
...base,
direction: "newer",
cursorLine: 3,
readOnly: true,
}),
);
assert(
!shouldBrowseComposerHistory({
...base,