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"),
|
||||
});
|
||||
}
|
||||
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
|
||||
|
||||
+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/資料");
|
||||
});
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user