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.