diff --git a/tools/web-ux/browser-tests/console_live_admission_shell_test.ts b/tools/web-ux/browser-tests/console_live_admission_shell_test.ts index afabf87a8..1e2b2292a 100644 --- a/tools/web-ux/browser-tests/console_live_admission_shell_test.ts +++ b/tools/web-ux/browser-tests/console_live_admission_shell_test.ts @@ -181,6 +181,67 @@ async function attach(page: Page, name: string): Promise { 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((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, "/"); diff --git a/web/workspace/src/lib/workspace/console/ComposerInput.browser.spec.ts b/web/workspace/src/lib/workspace/console/ComposerInput.browser.spec.ts index ad21c7c6a..ceb5ce568 100644 --- a/web/workspace/src/lib/workspace/console/ComposerInput.browser.spec.ts +++ b/web/workspace/src/lib/workspace/console/ComposerInput.browser.spec.ts @@ -27,6 +27,42 @@ function deferred() { 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>(); 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 () => { diff --git a/web/workspace/src/lib/workspace/console/ComposerInput.svelte b/web/workspace/src/lib/workspace/console/ComposerInput.svelte index c60639e85..8814020aa 100644 --- a/web/workspace/src/lib/workspace/console/ComposerInput.svelte +++ b/web/workspace/src/lib/workspace/console/ComposerInput.svelte @@ -1,6 +1,5 @@ -
+
{#if completionToken}
@@ -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; diff --git a/web/workspace/src/lib/workspace/console/composer-delivery.ts b/web/workspace/src/lib/workspace/console/composer-delivery.ts index d79cf0c82..e06e99891 100644 --- a/web/workspace/src/lib/workspace/console/composer-delivery.ts +++ b/web/workspace/src/lib/workspace/console/composer-delivery.ts @@ -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 && diff --git a/web/workspace/src/lib/workspace/console/composer-history.ts b/web/workspace/src/lib/workspace/console/composer-history.ts index 7a258cb03..86d7f9dd0 100644 --- a/web/workspace/src/lib/workspace/console/composer-history.ts +++ b/web/workspace/src/lib/workspace/console/composer-history.ts @@ -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" 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 45cb8ed87..785a2e7e5 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 @@ -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("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}") && 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 9a6f9b4c8..c0a62fc79 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 @@ -114,6 +114,7 @@ let workerError = $state(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" - ? cachedComposerDraft(pending.snapshot) - : composerDrafts.get(nextKey) ?? { - segments: [], - preserveExactText: false, - }; + // A pending request owns its snapshot, not subsequent edits to the draft. + const restored = composerDrafts.get(nextKey) ?? ( + pending && pending.status !== "rejected" + ? cachedComposerDraft(pending.snapshot) + : { 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, ): 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): 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()} > { + 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; - 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(); }); diff --git a/web/workspace/test/composer-history.test.ts b/web/workspace/test/composer-history.test.ts index 4bde593ab..3044a8c6e 100644 --- a/web/workspace/test/composer-history.test.ts +++ b/web/workspace/test/composer-history.test.ts @@ -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,