fix: keep Console composer editable in every state
This commit is contained in:
@@ -181,6 +181,67 @@ async function attach(page: Page, name: string): Promise<void> {
|
|||||||
buffer: Buffer.from("Synthetic fixture document"),
|
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", () =>
|
Deno.test("production leading slash remains responsive after loop fix", () =>
|
||||||
fixture(async (page, base, errors, caseOutput) => {
|
fixture(async (page, base, errors, caseOutput) => {
|
||||||
await draft(page, "/");
|
await draft(page, "/");
|
||||||
|
|||||||
@@ -27,6 +27,42 @@ function deferred<T>() {
|
|||||||
return { promise, resolve };
|
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 () => {
|
test("commands auto-open with descriptions; ambiguous Tab does not choose the first; arrows beat history", async () => {
|
||||||
const oncommand = vi.fn();
|
const oncommand = vi.fn();
|
||||||
const onsubmit = 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();
|
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 old = deferred<Array<{ value: string }>>();
|
||||||
const resolveFileCompletions = vi.fn(() => old.promise);
|
const resolveFileCompletions = vi.fn(() => old.promise);
|
||||||
const ui = render(ComposerInput, {
|
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.findByRole("listbox");
|
||||||
await ui.rerender({ completionScope: "b" });
|
await ui.rerender({ completionScope: "b" });
|
||||||
expect(ui.queryByRole("listbox")).toBeNull();
|
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 () => {
|
test("synchronous draft and cursor ABA invalidate old Feature completion generations", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
Compartment,
|
|
||||||
EditorSelection,
|
EditorSelection,
|
||||||
EditorState,
|
EditorState,
|
||||||
Prec,
|
Prec,
|
||||||
@@ -62,7 +61,6 @@
|
|||||||
} from "#lib/workspace/console/composer-invocation.ts";
|
} from "#lib/workspace/console/composer-invocation.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
disabled?: boolean;
|
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
ariaKeyShortcuts?: string;
|
ariaKeyShortcuts?: string;
|
||||||
historyScope: string;
|
historyScope: string;
|
||||||
@@ -85,7 +83,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
disabled = false,
|
|
||||||
ariaLabel = "Message",
|
ariaLabel = "Message",
|
||||||
ariaKeyShortcuts = "Meta+Enter Control+Enter",
|
ariaKeyShortcuts = "Meta+Enter Control+Enter",
|
||||||
historyScope,
|
historyScope,
|
||||||
@@ -137,7 +134,6 @@
|
|||||||
return retained;
|
return retained;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const editable = new Compartment();
|
|
||||||
|
|
||||||
const completionId = $props.id();
|
const completionId = $props.id();
|
||||||
let completionToken = $state<ComposerCompletionToken | null>(null);
|
let completionToken = $state<ComposerCompletionToken | null>(null);
|
||||||
@@ -151,7 +147,7 @@
|
|||||||
const completionStart = $derived(Math.max(0, (completionSelected ?? 0) - 5));
|
const completionStart = $derived(Math.max(0, (completionSelected ?? 0) - 5));
|
||||||
const visibleCompletions = $derived(completionEntries.slice(completionStart, completionStart + 6));
|
const visibleCompletions = $derived(completionEntries.slice(completionStart, completionStart + 6));
|
||||||
|
|
||||||
function closeCompletion(): void {
|
export function closeCompletion(): void {
|
||||||
completionVersion++;
|
completionVersion++;
|
||||||
completionAbort?.abort();
|
completionAbort?.abort();
|
||||||
completionAbort = null;
|
completionAbort = null;
|
||||||
@@ -170,7 +166,7 @@
|
|||||||
if (!force && context === completionContext) return;
|
if (!force && context === completionContext) return;
|
||||||
completionContext = context;
|
completionContext = context;
|
||||||
closeCompletion();
|
closeCompletion();
|
||||||
if (disabled || !view.hasFocus || view.composing || !selection.empty) return;
|
if (!view.hasFocus || view.composing || !selection.empty) return;
|
||||||
const token = completionTokenAt(
|
const token = completionTokenAt(
|
||||||
document,
|
document,
|
||||||
selection.head,
|
selection.head,
|
||||||
@@ -213,7 +209,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function acceptCompletion(action: "tab" | "accept", index = completionSelected): void {
|
function acceptCompletion(action: "tab" | "accept", index = completionSelected): void {
|
||||||
if (!view || !completionToken || disabled) return;
|
if (!view || !completionToken) return;
|
||||||
const token = completionToken;
|
const token = completionToken;
|
||||||
const document = view.state.doc.toString();
|
const document = view.state.doc.toString();
|
||||||
if (token.kind === "command" && index === null) {
|
if (token.kind === "command" && index === null) {
|
||||||
@@ -256,7 +252,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function completionKeydown(event: KeyboardEvent): boolean {
|
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.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return false;
|
||||||
if (event.key === "Escape" && completionToken) {
|
if (event.key === "Escape" && completionToken) {
|
||||||
event.preventDefault(); closeCompletion(); return true;
|
event.preventDefault(); closeCompletion(); return true;
|
||||||
@@ -299,12 +295,6 @@
|
|||||||
completionContext = "";
|
completionContext = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void disabled;
|
|
||||||
closeCompletion();
|
|
||||||
completionContext = "";
|
|
||||||
});
|
|
||||||
|
|
||||||
const registerPaste = StateEffect.define<{ key: number; paste: ComposerPaste }>();
|
const registerPaste = StateEffect.define<{ key: number; paste: ComposerPaste }>();
|
||||||
const pasteRegistry = StateField.define<ReadonlyMap<number, ComposerPaste>>({
|
const pasteRegistry = StateField.define<ReadonlyMap<number, ComposerPaste>>({
|
||||||
create: () => new Map(),
|
create: () => new Map(),
|
||||||
@@ -503,7 +493,7 @@
|
|||||||
segment: Segment,
|
segment: Segment,
|
||||||
options: { from?: number; to?: number; cleanup?: boolean; label?: string; uploadReservation?: boolean } = {},
|
options: { from?: number; to?: number; cleanup?: boolean; label?: string; uploadReservation?: boolean } = {},
|
||||||
): number | null {
|
): number | null {
|
||||||
if (!view || view.state.readOnly) return null;
|
if (!view) return null;
|
||||||
const selection = view.state.selection.main;
|
const selection = view.state.selection.main;
|
||||||
const from = options.from ?? selection.from;
|
const from = options.from ?? selection.from;
|
||||||
const to = options.to ?? selection.to;
|
const to = options.to ?? selection.to;
|
||||||
@@ -525,7 +515,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function chipifySelectedInvocation(strict = false): void {
|
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.
|
// Only completion-selected or explicitly edited calls become structured input.
|
||||||
// Descriptor discovery must never promote unrelated typed or pasted prose.
|
// Descriptor discovery must never promote unrelated typed or pasted prose.
|
||||||
for (const selected of [...view.state.field(selectedInvocationState)].reverse()) {
|
for (const selected of [...view.state.field(selectedInvocationState)].reverse()) {
|
||||||
@@ -553,7 +543,7 @@
|
|||||||
let editVersion = 0;
|
let editVersion = 0;
|
||||||
let editorGeneration = 0;
|
let editorGeneration = 0;
|
||||||
async function editInvocation(key: number): Promise<void> {
|
async function editInvocation(key: number): Promise<void> {
|
||||||
if (!view || disabled) return;
|
if (!view) return;
|
||||||
const scope = completionScope;
|
const scope = completionScope;
|
||||||
const version = ++editVersion;
|
const version = ++editVersion;
|
||||||
const document = view.state.doc.toString();
|
const document = view.state.doc.toString();
|
||||||
@@ -570,7 +560,7 @@
|
|||||||
descriptor = entries.find((entry) => entry.invocation?.identity === invocation.identity)?.invocation ?? undefined;
|
descriptor = entries.find((entry) => entry.invocation?.identity === invocation.identity)?.invocation ?? undefined;
|
||||||
} catch { return; }
|
} catch { return; }
|
||||||
}
|
}
|
||||||
if (!descriptor || !view || disabled || version !== editVersion || scope !== completionScope ||
|
if (!descriptor || !view || version !== editVersion || scope !== completionScope ||
|
||||||
generation !== editorGeneration || document !== view.state.doc.toString() ||
|
generation !== editorGeneration || document !== view.state.doc.toString() ||
|
||||||
selection.from !== view.state.selection.main.from || selection.to !== view.state.selection.main.to) return;
|
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))
|
atom = composerTypedAtoms(view.state.doc.toString(), view.state.field(typedRegistry))
|
||||||
@@ -622,7 +612,6 @@
|
|||||||
cursorLine,
|
cursorLine,
|
||||||
lineCount: currentView.state.doc.lines,
|
lineCount: currentView.state.doc.lines,
|
||||||
selectionEmpty: selection.empty,
|
selectionEmpty: selection.empty,
|
||||||
readOnly: currentView.state.readOnly,
|
|
||||||
composing: currentView.composing,
|
composing: currentView.composing,
|
||||||
})) return false;
|
})) return false;
|
||||||
|
|
||||||
@@ -679,7 +668,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePasteEvent(event: ClipboardEvent): boolean {
|
function handlePasteEvent(event: ClipboardEvent): boolean {
|
||||||
if (disabled || view?.state.readOnly) return false;
|
|
||||||
if (handleComposerImagePaste(event, onpasteimages)) return true;
|
if (handleComposerImagePaste(event, onpasteimages)) return true;
|
||||||
const content = event.clipboardData?.getData("text/plain");
|
const content = event.clipboardData?.getData("text/plain");
|
||||||
if (!content) return false;
|
if (!content) return false;
|
||||||
@@ -718,7 +706,6 @@
|
|||||||
currentView: EditorView,
|
currentView: EditorView,
|
||||||
direction: "backward" | "forward",
|
direction: "backward" | "forward",
|
||||||
): boolean {
|
): boolean {
|
||||||
if (currentView.state.readOnly) return false;
|
|
||||||
const selection = currentView.state.selection.main;
|
const selection = currentView.state.selection.main;
|
||||||
const atoms = [
|
const atoms = [
|
||||||
...composerPasteAtoms(
|
...composerPasteAtoms(
|
||||||
@@ -753,18 +740,6 @@
|
|||||||
history(),
|
history(),
|
||||||
Prec.highest(EditorView.domEventHandlers({ keydown: completionKeydown })),
|
Prec.highest(EditorView.domEventHandlers({ keydown: completionKeydown })),
|
||||||
Prec.highest(keymap.of([
|
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",
|
key: "ArrowUp",
|
||||||
run: (currentView) => browseHistory(currentView, "older"),
|
run: (currentView) => browseHistory(currentView, "older"),
|
||||||
@@ -791,10 +766,6 @@
|
|||||||
: []),
|
: []),
|
||||||
pasteChips,
|
pasteChips,
|
||||||
typedChips,
|
typedChips,
|
||||||
editable.of([
|
|
||||||
EditorView.editable.of(!disabled),
|
|
||||||
EditorState.readOnly.of(disabled),
|
|
||||||
]),
|
|
||||||
EditorState.allowMultipleSelections.of(false),
|
EditorState.allowMultipleSelections.of(false),
|
||||||
EditorView.lineWrapping,
|
EditorView.lineWrapping,
|
||||||
EditorView.contentAttributes.of({
|
EditorView.contentAttributes.of({
|
||||||
@@ -840,7 +811,7 @@
|
|||||||
const target = event.target instanceof Element
|
const target = event.target instanceof Element
|
||||||
? event.target.closest(".composer-typed-chip")
|
? event.target.closest(".composer-typed-chip")
|
||||||
: null;
|
: null;
|
||||||
if (!target || currentView.state.readOnly) return false;
|
if (!target) return false;
|
||||||
let position: number;
|
let position: number;
|
||||||
try {
|
try {
|
||||||
position = currentView.posAtDOM(target);
|
position = currentView.posAtDOM(target);
|
||||||
@@ -867,7 +838,6 @@
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
cut(event, currentView) {
|
cut(event, currentView) {
|
||||||
if (disabled) return false;
|
|
||||||
const content = selectedClipboardContent(currentView.state);
|
const content = selectedClipboardContent(currentView.state);
|
||||||
if (content === null || !event.clipboardData) return false;
|
if (content === null || !event.clipboardData) return false;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -887,7 +857,7 @@
|
|||||||
shouldSubmitChatKey(event, {
|
shouldSubmitChatKey(event, {
|
||||||
mode: "mod-enter",
|
mode: "mod-enter",
|
||||||
modKey: "auto",
|
modKey: "auto",
|
||||||
enabled: !disabled,
|
enabled: true,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
event.preventDefault();
|
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 {
|
export function snapshot(): ComposerDraftSnapshot {
|
||||||
chipifySelectedInvocation(true);
|
chipifySelectedInvocation(true);
|
||||||
return currentSnapshot();
|
return currentSnapshot();
|
||||||
@@ -957,7 +917,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function replaceRange(from: number, to: number, content: string): void {
|
export function replaceRange(from: number, to: number, content: string): void {
|
||||||
if (!view || view.state.readOnly) return;
|
if (!view) return;
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
changes: { from, to, insert: content },
|
changes: { from, to, insert: content },
|
||||||
selection: EditorSelection.cursor(from + content.length),
|
selection: EditorSelection.cursor(from + content.length),
|
||||||
@@ -1007,9 +967,6 @@
|
|||||||
closeCompletion();
|
closeCompletion();
|
||||||
completionContext = "";
|
completionContext = "";
|
||||||
view.setState(EditorState.create({ extensions: editorExtensions }));
|
view.setState(EditorState.create({ extensions: editorExtensions }));
|
||||||
view.dispatch({ effects: editable.reconfigure([
|
|
||||||
EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled),
|
|
||||||
]) });
|
|
||||||
emitChange();
|
emitChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1070,7 +1027,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="composer-input" class:disabled>
|
<div class="composer-input">
|
||||||
{#if completionToken}
|
{#if completionToken}
|
||||||
<div class="completion-popup">
|
<div class="completion-popup">
|
||||||
<div id={completionId} role="listbox" aria-label="Composer completions" aria-busy={completionBusy}>
|
<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); }
|
.completion-hint { padding: var(--space-1) var(--space-2); color: var(--text-muted); }
|
||||||
|
|
||||||
.composer-input.disabled {
|
|
||||||
opacity: 0.56;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-input :global(.cm-editor) {
|
.composer-input :global(.cm-editor) {
|
||||||
min-height: 2.65rem;
|
min-height: 2.65rem;
|
||||||
max-height: 10rem;
|
max-height: 10rem;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export class ComposerAdmissions {
|
|||||||
const prior = this.get(target);
|
const prior = this.get(target);
|
||||||
if (prior && prior.status !== "rejected") {
|
if (prior && prior.status !== "rejected") {
|
||||||
throw new Error(
|
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 &&
|
const unchanged = prior && prior.method.method === method.method &&
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export type ComposerHistoryCursor = {
|
|||||||
cursorLine: number;
|
cursorLine: number;
|
||||||
lineCount: number;
|
lineCount: number;
|
||||||
selectionEmpty: boolean;
|
selectionEmpty: boolean;
|
||||||
readOnly: boolean;
|
|
||||||
composing: boolean;
|
composing: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,7 +84,7 @@ function sameEntry(
|
|||||||
export function shouldBrowseComposerHistory(
|
export function shouldBrowseComposerHistory(
|
||||||
cursor: ComposerHistoryCursor,
|
cursor: ComposerHistoryCursor,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (cursor.readOnly || cursor.composing || !cursor.selectionEmpty) {
|
if (cursor.composing || !cursor.selectionEmpty) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return cursor.direction === "older"
|
return cursor.direction === "older"
|
||||||
|
|||||||
@@ -1050,13 +1050,13 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
|
|||||||
composerInput.includes(
|
composerInput.includes(
|
||||||
"composerDeletionRange(selection, atoms, direction)",
|
"composerDeletionRange(selection, atoms, direction)",
|
||||||
) &&
|
) &&
|
||||||
composerInput.includes("EditorState.readOnly.of(isDisabled)") &&
|
!composerInput.includes("readOnly") &&
|
||||||
composerInput.includes('key: "Mod-z"') &&
|
!composerInput.includes("disabled") &&
|
||||||
composerInput.includes("if (!view || view.state.readOnly) return") &&
|
!composerInput.includes("EditorView.editable") &&
|
||||||
composerInput.includes("if (currentView.state.readOnly) return false") &&
|
composerInput.includes("...historyKeymap") &&
|
||||||
consolePage.includes("completionScope={activeComposerTargetKey}") &&
|
consolePage.includes("completionScope={activeComposerTargetKey}") &&
|
||||||
composerInput.includes("version !== completionVersion") &&
|
composerInput.includes("version !== completionVersion") &&
|
||||||
consolePage.includes("if (!composerEditable) return") &&
|
consolePage.includes("if (!canUploadAttachments) return") &&
|
||||||
composerInput.includes('chip.setAttribute("aria-label", label)') &&
|
composerInput.includes('chip.setAttribute("aria-label", label)') &&
|
||||||
composerInput.includes("preserveExactText = false") &&
|
composerInput.includes("preserveExactText = false") &&
|
||||||
consolePage.includes("const command = buildComposerSegmentsRequest(") &&
|
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"),
|
input.indexOf("function selectedClipboardContent"),
|
||||||
);
|
);
|
||||||
assert(
|
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")') &&
|
paste.indexOf('getData("text/plain")') &&
|
||||||
consolePage.includes("onpasteimages={addPastedImages}") &&
|
consolePage.includes("onpasteimages={addPastedImages}") &&
|
||||||
consolePage.includes("addAttachmentFiles(files.map(namePastedImage))"),
|
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(
|
const attach = consolePage.slice(
|
||||||
consolePage.indexOf("function addAttachmentFiles"),
|
consolePage.indexOf("function addAttachmentFiles"),
|
||||||
consolePage.indexOf("async function removeAttachment"),
|
consolePage.indexOf("async function removeAttachment"),
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
attach.includes("if (!composerEditable) return") &&
|
attach.includes("if (!canUploadAttachments) return") &&
|
||||||
attach.includes("MAX_FILES_PER_SUBMISSION - attachments.length") &&
|
attach.includes("MAX_FILES_PER_SUBMISSION - attachments.length") &&
|
||||||
attach.includes(".slice(0, available)") &&
|
attach.includes(".slice(0, available)") &&
|
||||||
attach.includes("startAttachmentUpload(attachment)"),
|
attach.includes("startAttachmentUpload(attachment)"),
|
||||||
@@ -1273,14 +1271,15 @@ Deno.test("Worker Console route resolves logical Worker authority before Runtime
|
|||||||
'const workerRunning = $derived(workerState === "running");',
|
'const workerRunning = $derived(workerState === "running");',
|
||||||
) &&
|
) &&
|
||||||
consolePage.includes(
|
consolePage.includes(
|
||||||
'const composerEditable = $derived(protocolState === "open" && !sending);',
|
'const canSendComposerControl = $derived(protocolState === "open" && !sending);',
|
||||||
) &&
|
) &&
|
||||||
consolePage.includes('sendWorkerControl("cancel")') &&
|
consolePage.includes('sendWorkerControl("cancel")') &&
|
||||||
consolePage.includes("lifecycleMethod(command)") &&
|
consolePage.includes("lifecycleMethod(command)") &&
|
||||||
consolePage.includes("command_id: commandId") &&
|
consolePage.includes("command_id: commandId") &&
|
||||||
!consolePage.includes("expected_execution_generation") &&
|
!consolePage.includes("expected_execution_generation") &&
|
||||||
consolePage.includes("onsubmit={handleComposerSubmit}") &&
|
consolePage.includes("onsubmit={handleComposerSubmit}") &&
|
||||||
consolePage.includes("disabled={!composerEditable}") &&
|
!consolePage.includes("disabled={!composerEditable}") &&
|
||||||
|
consolePage.includes("if (!canSendComposerControl) return;") &&
|
||||||
consolePage.includes("class:stop={workerRunning}") &&
|
consolePage.includes("class:stop={workerRunning}") &&
|
||||||
consolePage.includes('"Stop Worker"') &&
|
consolePage.includes('"Stop Worker"') &&
|
||||||
consolePage.includes("disabled={composerSubmitDisabled}") &&
|
consolePage.includes("disabled={composerSubmitDisabled}") &&
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
let workerError = $state<string | null>(untrack(() => data.workerError));
|
let workerError = $state<string | null>(untrack(() => data.workerError));
|
||||||
type ComposerInputHandle = {
|
type ComposerInputHandle = {
|
||||||
snapshot(): ComposerDraftSnapshot;
|
snapshot(): ComposerDraftSnapshot;
|
||||||
|
closeCompletion(): void;
|
||||||
focus(): void;
|
focus(): void;
|
||||||
containsTarget(target: EventTarget | null): boolean;
|
containsTarget(target: EventTarget | null): boolean;
|
||||||
cursor(): number;
|
cursor(): number;
|
||||||
@@ -313,7 +314,8 @@
|
|||||||
);
|
);
|
||||||
const workerRunning = $derived(workerState === "running");
|
const workerRunning = $derived(workerState === "running");
|
||||||
const workerPaused = $derived(workerState === "paused");
|
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 draftHasText = $derived(draft.content.trim().length > 0);
|
||||||
const draftHasAttachments = $derived(
|
const draftHasAttachments = $derived(
|
||||||
attachments.length > 0 || draft.segments.some((segment) => segment.kind === "uploaded_file"),
|
attachments.length > 0 || draft.segments.some((segment) => segment.kind === "uploaded_file"),
|
||||||
@@ -348,7 +350,7 @@
|
|||||||
hasAttachments: draftHasAttachments,
|
hasAttachments: draftHasAttachments,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const canStopFromComposer = $derived(workerRunning && composerEditable);
|
const canStopFromComposer = $derived(workerRunning && canSendComposerControl);
|
||||||
const composerSubmitDisabled = $derived(
|
const composerSubmitDisabled = $derived(
|
||||||
workerRunning ? !canStopFromComposer : !canSubmitDraft,
|
workerRunning ? !canStopFromComposer : !canSubmitDraft,
|
||||||
);
|
);
|
||||||
@@ -1024,12 +1026,12 @@
|
|||||||
activeComposerTargetKey = nextKey;
|
activeComposerTargetKey = nextKey;
|
||||||
syncAdmission();
|
syncAdmission();
|
||||||
const pending = admissions.get(nextKey);
|
const pending = admissions.get(nextKey);
|
||||||
const restored = pending && pending.status !== "rejected"
|
// A pending request owns its snapshot, not subsequent edits to the draft.
|
||||||
? cachedComposerDraft(pending.snapshot)
|
const restored = composerDrafts.get(nextKey) ?? (
|
||||||
: composerDrafts.get(nextKey) ?? {
|
pending && pending.status !== "rejected"
|
||||||
segments: [],
|
? cachedComposerDraft(pending.snapshot)
|
||||||
preserveExactText: false,
|
: { segments: [], preserveExactText: false }
|
||||||
};
|
);
|
||||||
void tick().then(() => {
|
void tick().then(() => {
|
||||||
if (activeComposerTargetKey !== nextKey) return;
|
if (activeComposerTargetKey !== nextKey) return;
|
||||||
composerInputElement?.restoreSegments(
|
composerInputElement?.restoreSegments(
|
||||||
@@ -1055,6 +1057,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleComposerSubmit() {
|
function handleComposerSubmit() {
|
||||||
|
if (!canSendComposerControl) return;
|
||||||
const value = composerSnapshotOrReport();
|
const value = composerSnapshotOrReport();
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
if (value.document.trimStart().startsWith(":")) {
|
if (value.document.trimStart().startsWith(":")) {
|
||||||
@@ -1108,7 +1111,7 @@
|
|||||||
reference: NonNullable<ComposerAttachment["reference"]>,
|
reference: NonNullable<ComposerAttachment["reference"]>,
|
||||||
): void {
|
): void {
|
||||||
if (!attachments.some((candidate) => candidate.id === attachment.id) ||
|
if (!attachments.some((candidate) => candidate.id === attachment.id) ||
|
||||||
!composerInputElement || !composerEditable || attachment.uploadPath !== attachmentPath()) {
|
!composerInputElement || !consoleTarget || attachment.uploadPath !== attachmentPath()) {
|
||||||
const reservation = uploadReservations.get(attachment.id);
|
const reservation = uploadReservations.get(attachment.id);
|
||||||
uploadReservations.delete(attachment.id);
|
uploadReservations.delete(attachment.id);
|
||||||
if (reservation !== undefined) composerInputElement?.cancelUpload(reservation);
|
if (reservation !== undefined) composerInputElement?.cancelUpload(reservation);
|
||||||
@@ -1164,7 +1167,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addAttachmentFiles(files: Iterable<File>): void {
|
function addAttachmentFiles(files: Iterable<File>): void {
|
||||||
if (!composerEditable) return;
|
if (!canUploadAttachments) return;
|
||||||
const stagedFiles = draft.segments.filter((segment) => segment.kind === "uploaded_file").length;
|
const stagedFiles = draft.segments.filter((segment) => segment.kind === "uploaded_file").length;
|
||||||
const available = Math.max(0, MAX_FILES_PER_SUBMISSION - attachments.length - stagedFiles);
|
const available = Math.max(0, MAX_FILES_PER_SUBMISSION - attachments.length - stagedFiles);
|
||||||
for (const file of Array.from(files).slice(0, available)) {
|
for (const file of Array.from(files).slice(0, available)) {
|
||||||
@@ -1289,7 +1292,7 @@
|
|||||||
// Commands are controls, not idle-only chat submissions. Keep the
|
// Commands are controls, not idle-only chat submissions. Keep the
|
||||||
// removed header controls available while the Worker is busy too.
|
// removed header controls available while the Worker is busy too.
|
||||||
const isCommand = delivery === "submit" && command.request.kind !== "user";
|
const isCommand = delivery === "submit" && command.request.kind !== "user";
|
||||||
if (isCommand ? !composerEditable : !canDeliverComposerDraft(deliveryState)) {
|
if (isCommand ? !canSendComposerControl : !canDeliverComposerDraft(deliveryState)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1590,8 +1593,8 @@
|
|||||||
if (segment.kind === "uploaded_file") stagedAttachmentPaths.delete(segment.file.artifact_id);
|
if (segment.kind === "uploaded_file") stagedAttachmentPaths.delete(segment.file.artifact_id);
|
||||||
}
|
}
|
||||||
composerInputElement?.recordHistory(record.snapshot);
|
composerInputElement?.recordHistory(record.snapshot);
|
||||||
// A late acceptance of a previously rejected attempt must not erase
|
// Admission never locks the editor. Do not erase edits made
|
||||||
// edits made while the recovered draft was unlocked.
|
// after this request's snapshot, including after a rejection.
|
||||||
if (JSON.stringify(draft.segments) === JSON.stringify(record.snapshot.segments)) {
|
if (JSON.stringify(draft.segments) === JSON.stringify(record.snapshot.segments)) {
|
||||||
composerInputElement?.clear(true);
|
composerInputElement?.clear(true);
|
||||||
attachments = [];
|
attachments = [];
|
||||||
@@ -1600,8 +1603,8 @@
|
|||||||
liveWorkerState = "running";
|
liveWorkerState = "running";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// The editor has stayed frozen with the exact typed input, Undo state
|
// Keep the current draft and Undo state; the pending snapshot
|
||||||
// and staged resources. Unlock it; unchanged retries reuse the IDs.
|
// remains available for unchanged retries with the same IDs.
|
||||||
reportComposerError(message ?? "Worker rejected the input.");
|
reportComposerError(message ?? "Worker rejected the input.");
|
||||||
}
|
}
|
||||||
syncAdmission();
|
syncAdmission();
|
||||||
@@ -1652,6 +1655,8 @@
|
|||||||
|
|
||||||
function rejectPendingCompletion(error: Error, targetKey = activeComposerTargetKey) {
|
function rejectPendingCompletion(error: Error, targetKey = activeComposerTargetKey) {
|
||||||
admissionDisconnected(targetKey);
|
admissionDisconnected(targetKey);
|
||||||
|
// Invalidate suggestions on disconnect without locking the draft.
|
||||||
|
composerInputElement?.closeCompletion();
|
||||||
fileCompletions.close(error);
|
fileCompletions.close(error);
|
||||||
featureCompletions.close(error);
|
featureCompletions.close(error);
|
||||||
for (const lane of featureArgumentCompletions.values()) lane.close(error);
|
for (const lane of featureArgumentCompletions.values()) lane.close(error);
|
||||||
@@ -2151,7 +2156,6 @@
|
|||||||
historyScope={workspaceId}
|
historyScope={workspaceId}
|
||||||
ariaLabel="Console input"
|
ariaLabel="Console input"
|
||||||
ariaKeyShortcuts="Meta+Enter Control+Enter"
|
ariaKeyShortcuts="Meta+Enter Control+Enter"
|
||||||
disabled={!composerEditable}
|
|
||||||
onchange={handleComposerChange}
|
onchange={handleComposerChange}
|
||||||
onkeydown={handleComposerKeydown}
|
onkeydown={handleComposerKeydown}
|
||||||
completionScope={activeComposerTargetKey}
|
completionScope={activeComposerTargetKey}
|
||||||
@@ -2212,7 +2216,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="Attach file"
|
aria-label="Attach file"
|
||||||
title="Attach file"
|
title="Attach file"
|
||||||
disabled={!composerEditable || attachments.length >= MAX_FILES_PER_SUBMISSION}
|
disabled={!canUploadAttachments || attachments.length >= MAX_FILES_PER_SUBMISSION}
|
||||||
onclick={() => fileInput?.click()}
|
onclick={() => fileInput?.click()}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
+55
-10
@@ -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/資料");
|
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 () => {
|
test("Submit rejection keeps exact typed draft, request/invocation IDs, and only acceptance clears", async () => {
|
||||||
const alert = vi.spyOn(alerts, "pushWorkspaceAlert");
|
const alert = vi.spyOn(alerts, "pushWorkspaceAlert");
|
||||||
const { cm, form } = await liveComposer();
|
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.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/資料" } }] } });
|
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();
|
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: "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);
|
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" } });
|
protocolEvent({ event: "submission_rejected", data: { submission_request_id: "other-client", message: "foreign" } });
|
||||||
await settleMicrotasks();
|
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" } });
|
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));
|
await waitFor(() => expect(cm.state.readOnly).toBe(false));
|
||||||
expect(alert).toHaveBeenCalledWith("error", "Feature validation failed", expect.any(Object));
|
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" })] } });
|
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();
|
const { cm, form, view } = await liveComposer();
|
||||||
await selectRun(cm);
|
await selectRun(cm);
|
||||||
await fireEvent.submit(form);
|
await fireEvent.submit(form);
|
||||||
const first = multiplexer.sendWorkerMethod.mock.calls.at(-1)![0] as Extract<Method, { method: "submit" }>;
|
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 view.rerender({ data: pageData("worker-b", "runtime-b") });
|
||||||
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(2));
|
||||||
latestListener().onFrame(subscribedFrame());
|
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 view.rerender({ data: pageData() });
|
||||||
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(3));
|
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledTimes(3));
|
||||||
latestListener().onFrame(subscribedFrame());
|
latestListener().onFrame(subscribedFrame());
|
||||||
await waitFor(() => expect(cm.dom.querySelector(".composer-typed-chip")).not.toBeNull());
|
await waitFor(() => expect(cm.state.doc.toString()).toBe("new draft while disconnected"));
|
||||||
expect(cm.state.readOnly).toBe(true);
|
expect(cm.state.readOnly).toBe(false);
|
||||||
const doc = cm.state.doc.toString();
|
await fireEvent.submit(form);
|
||||||
await fireEvent.keyDown(cm.contentDOM, { key: "Backspace" });
|
expect(multiplexer.sendWorkerMethod.mock.calls.filter(([method]) => method.method === "submit")).toHaveLength(1);
|
||||||
expect(cm.state.doc.toString()).toBe(doc);
|
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Retry admission" }));
|
await fireEvent.click(screen.getByRole("button", { name: "Retry admission" }));
|
||||||
expect(multiplexer.sendWorkerMethod).toHaveBeenLastCalledWith(first);
|
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();
|
view.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ Deno.test("Composer history uses only the multiline input boundaries", () => {
|
|||||||
const base = {
|
const base = {
|
||||||
lineCount: 3,
|
lineCount: 3,
|
||||||
selectionEmpty: true,
|
selectionEmpty: true,
|
||||||
readOnly: false,
|
|
||||||
composing: false,
|
composing: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,14 +107,6 @@ Deno.test("Composer history uses only the multiline input boundaries", () => {
|
|||||||
selectionEmpty: false,
|
selectionEmpty: false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
assert(
|
|
||||||
!shouldBrowseComposerHistory({
|
|
||||||
...base,
|
|
||||||
direction: "newer",
|
|
||||||
cursorLine: 3,
|
|
||||||
readOnly: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
assert(
|
assert(
|
||||||
!shouldBrowseComposerHistory({
|
!shouldBrowseComposerHistory({
|
||||||
...base,
|
...base,
|
||||||
|
|||||||
Reference in New Issue
Block a user