From db5c28df37d27663ecc1775a26838a25ebecdb97 Mon Sep 17 00:00:00 2001
From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
Date: Fri, 6 Nov 2026 05:53:03 +0800
Subject: [PATCH] fix(chat): preserve queue edits when dismissing actions
Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com>
---
.../personal-workspace/conversation-queue.tsx | 5 +-
.../attached-host-follow-up.mjs | 14 +++-
.../managed-agent-sessions.mjs | 84 +++++++++++++++++++
3 files changed, 101 insertions(+), 2 deletions(-)
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/conversation-queue.tsx b/apps/presentation/dashboard/src/features/personal-workspace/conversation-queue.tsx
index 939a9fa733..333ebf0d19 100644
--- a/apps/presentation/dashboard/src/features/personal-workspace/conversation-queue.tsx
+++ b/apps/presentation/dashboard/src/features/personal-workspace/conversation-queue.tsx
@@ -113,7 +113,10 @@ function QueuedMessage({sessionId, turn, index, targetTurnId, onChange, onInputC
return
{
if (composing.current || event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return;
if (event.key === "Escape" && !busy && !unresolved) {
- event.preventDefault(); event.stopPropagation(); discard(); return;
+ event.preventDefault(); event.stopPropagation();
+ if (menu) {setMenu(false); menuButton.current?.focus();}
+ else discard();
+ return;
}
if (editing) {
if (composerKeyAction({...event, isComposing: event.nativeEvent.isComposing, keyCode: event.nativeEvent.keyCode}, false) === "steer") {
diff --git a/examples/personal-workspace-browser/attached-host-follow-up.mjs b/examples/personal-workspace-browser/attached-host-follow-up.mjs
index 0f269fefc1..a0c4ebc083 100644
--- a/examples/personal-workspace-browser/attached-host-follow-up.mjs
+++ b/examples/personal-workspace-browser/attached-host-follow-up.mjs
@@ -178,7 +178,19 @@ export const attachedHostFollowUpScenario = {
const retainedDraft = "保留这份尚未保存的更正。\n原消息启动后也不能把它重新发送或丢失。";
await queuedFollowUp.getByRole("button", {name: "更多操作:待处理消息 1", exact: true}).click();
await queuedFollowUp.getByRole("button", {name: "编辑", exact: true}).click();
- await page.getByRole("textbox", {name: "编辑待处理消息 1", exact: true}).fill(retainedDraft);
+ const editor = page.getByRole("textbox", {name: "编辑待处理消息 1", exact: true});
+ await editor.fill(retainedDraft);
+ const queueMenu = queuedFollowUp.getByRole("button", {name: "更多操作:待处理消息 1", exact: true});
+ await queueMenu.click();
+ await page.keyboard.press("Escape");
+ await page.screenshot({path: resolve(outputDir, "queue-editor-menu-escape.png"), fullPage: false});
+ if (!await editor.isVisible() || await editor.inputValue() !== retainedDraft) {
+ throw new Error("Closing the queue action menu discarded the independent edit draft");
+ }
+ if (await queueMenu.getAttribute("aria-expanded") !== "false"
+ || !await queueMenu.evaluate(element => element === document.activeElement)) {
+ throw new Error("Escape must close the queue action menu and restore its trigger focus");
+ }
await composer.fill("主输入框的另一份草稿");
page.__loopxRuntime.queuedTurns.set(attachedSessionId,
page.__loopxRuntime.queuedTurns.get(attachedSessionId).filter(turn => turn.turn_id !== "turn-queued-1"));
diff --git a/examples/personal-workspace-browser/managed-agent-sessions.mjs b/examples/personal-workspace-browser/managed-agent-sessions.mjs
index 5b8335007f..83d95f77de 100644
--- a/examples/personal-workspace-browser/managed-agent-sessions.mjs
+++ b/examples/personal-workspace-browser/managed-agent-sessions.mjs
@@ -6,6 +6,88 @@ import {openWorkspacePage} from "./scenario-context.mjs";
import {resolveTestPython} from "../../scripts/test-python.mjs";
import {withManagedFixture} from "./managed-fixture-process.mjs";
+async function verifyQueuedEditor({page, origin, sessionId, turnId, composer, control}) {
+ const nativeStarts = (await control("inspect")).calls_after_setup.filter(call => call.method === "turn/start").length;
+ const source = "# Source note\nPreserve the original attachment.\n";
+ const attachment = {id: "queue-source", name: "source.md", mime_type: "text/markdown",
+ size: Buffer.byteLength(source), data_url: `data:text/markdown;base64,${Buffer.from(source).toString("base64")}`};
+ const path = `/api/chat/sessions/${sessionId}`;
+ const submitted = await page.request.post(`${origin}${path}/turns`, {
+ data: {message: "Read the source before the next handoff.", attachments: [attachment],
+ client_turn_id: "managed-queue-editor", delivery: "queue"},
+ });
+ assert.equal(submitted.status(), 202);
+ const queuedId = (await submitted.json()).turn_id;
+ const inputPath = `${path}/queue/${queuedId}/input`;
+ const posts = [];
+ const observe = request => {if (request.method() === "POST" && new URL(request.url()).pathname.startsWith(path)) posts.push(request.url());};
+ page.on("request", observe);
+ try {
+ const row = page.locator(".personal-conversation-queue li");
+ const menu = row.getByRole("button", {name: "更多操作:待处理消息 1", exact: true});
+ await menu.click();
+ await row.getByRole("button", {name: "编辑", exact: true}).click();
+ const editor = row.getByRole("textbox", {name: "编辑待处理消息 1", exact: true});
+ const draft = "先核对原始材料,再继续下一步。\n保留这份独立的队列修改草稿和原附件。";
+ await editor.fill(draft);
+ await composer.fill("另一份尚未发送的主输入框草稿");
+ await menu.click();
+ await menu.dispatchEvent("keydown", {key: "Escape", isComposing: true});
+ assert.equal(await menu.getAttribute("aria-expanded"), "true", "IME Escape does not close the menu or discard the edit");
+ await page.keyboard.press("Escape");
+ assert.equal(await menu.getAttribute("aria-expanded"), "false");
+ assert.equal(await editor.inputValue(), draft);
+ assert.equal(await menu.evaluate(node => node === document.activeElement), true);
+ // Dismissing a menu is independent of storage availability. It must never
+ // call the editor's destructive discard path, even if that path would fail.
+ await page.evaluate(() => {window.queueRemoveItem = Storage.prototype.removeItem;
+ Storage.prototype.removeItem = function() {throw new Error("isolated storage failure");};});
+ try {
+ await menu.click(); await page.keyboard.press("Escape");
+ assert.equal(await menu.getAttribute("aria-expanded"), "false");
+ assert.equal(await editor.inputValue(), draft);
+ assert.equal(await row.getByRole("alert").count(), 0);
+ } finally {await page.evaluate(() => {Storage.prototype.removeItem = window.queueRemoveItem; delete window.queueRemoveItem;});}
+ assert.deepEqual(posts, [], "Menu dismissal neither edits, cancels, steers nor sends a message");
+ await page.screenshot({path: resolve(outputDir, "queue-editor-native-desktop.png")});
+ await page.setViewportSize({width: 390, height: 844});
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
+ await page.screenshot({path: resolve(outputDir, "queue-editor-native-narrow.png")});
+ await page.reload({waitUntil: "domcontentloaded"});
+ await editor.waitFor();
+ assert.equal(await editor.inputValue(), draft, "Reload retains the edit under its original queued Turn");
+ assert.equal(await composer.inputValue(), "另一份尚未发送的主输入框草稿");
+ const before = await (await page.request.get(`${origin}${path}/queue`)).json();
+ assert.equal(before.active_turn_id, turnId);
+ assert.deepEqual(before.turns.map(turn => [turn.turn_id, turn.input_revision, turn.attachment_count]), [[queuedId, 0, 1]]);
+ const saved = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === inputPath);
+ await editor.press("Meta+Enter");
+ assert.equal((await (await saved).json()).input_revision, 1);
+ await editor.waitFor({state: "hidden"});
+ const history = await (await page.request.get(`${origin}${path}?turn_id=${queuedId}&message_limit=200`)).json();
+ const input = history.messages.find(message => message.role === "user" && message.turn_id === queuedId);
+ assert.equal(input.text, draft);
+ assert.deepEqual(input.attachments, [attachment]);
+ await menu.click();
+ await row.getByRole("button", {name: "编辑", exact: true}).click();
+ await editor.fill("明确放弃的修改");
+ await editor.press("Escape");
+ await editor.waitFor({state: "hidden"});
+ assert.equal(posts.length, 1, "Escape with no open menu still discards only the local edit");
+ await menu.click();
+ const cancelled = page.waitForResponse(response => response.request().method() === "POST" && new URL(response.url()).pathname === inputPath);
+ await row.getByRole("button", {name: "取消排队", exact: true}).click();
+ assert.equal((await (await cancelled).json()).input_revision, 2);
+ await row.waitFor({state: "hidden"});
+ const after = await (await page.request.get(`${origin}${path}/queue`)).json();
+ assert.equal(after.active_turn_id, turnId);
+ assert.deepEqual(after.turns, []);
+ assert.equal(posts.length, 2, "Only explicit save and cancel mutate the original queued input");
+ assert.equal((await control("inspect")).calls_after_setup.filter(call => call.method === "turn/start").length, nativeStarts,
+ "Queue editing adds no native execution");
+ } finally {page.off("request", observe);}
+}
+
async function verifyManagedTurnObservers({page, origin, sessions, panel, chooser, composer, control}) {
const {session_id: sessionId} = await control("observer-session");
const before = (await control("inspect")).calls_after_setup.filter(row => row.method === "turn/start").length;
@@ -91,6 +173,8 @@ async function verifyManagedTurnObservers({page, origin, sessions, panel, choose
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
await page.screenshot({path: resolve(outputDir, "managed-observer-narrow.png")});
await assertSingleObserver(ownTurn, 1);
+ await page.setViewportSize({width: 1440, height: 900});
+ await verifyQueuedEditor({page, origin, sessionId, turnId: ownTurn, composer, control});
await interrupt(ownTurn);
// Start through the same real ingress while another Session is selected.