diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 8c8dc59d3fa6..21cbd8cfff42 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -79,6 +79,20 @@ const OBSERVER_DEMO_RUN_ID = "mock-session-observer-run"; const PLAN_DEMO_RUN_ID = "mock-plan-run"; const CUSTODIAN_CHAT_REPLY_DELAY_MS = 600; const CHAT_SEND_REPLY_DELAY_MS = 200; +const COMPOSER_QUEUE_FIXTURE = [ + "Audit the composer spacing against the desktop reference.", + "Keep the queue attached to the composer in both themes.", + "Check the row actions and make sure the drag handle only appears while hovering the message.", + "This deliberately longer queued message should wrap onto a second line so the compact row anatomy can be reviewed without clipping or hiding the actual operator text.", + "Verify keyboard reordering before the final visual pass.", + "Capture the light and dark states for comparison.", +].map((text, index) => ({ + id: `mock-composer-queue-${index + 1}`, + text, + createdAt: Date.parse("2026-05-22T09:00:00.000Z") + index, + orderKey: index + 1, + kind: "queued" as const, +})); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const uiRoot = path.join(repoRoot, "ui"); @@ -2878,9 +2892,32 @@ function createStatefulMockInitScript(): string { return `(() => { const __name = (target) => target; (${installControlUiStatefulMocks.toString()})(${CUSTODIAN_CHAT_REPLY_DELAY_MS}, ${CHAT_SEND_REPLY_DELAY_MS}); })();`; } +function installComposerQueueFixture(queue: typeof COMPOSER_QUEUE_FIXTURE): void { + const install = () => { + const pane = document.querySelector("openclaw-chat-pane") as + | (HTMLElement & { + state?: { chatQueue: typeof queue }; + requestUpdate?: () => void; + }) + | null; + if (!pane?.state) { + window.setTimeout(install, 50); + return; + } + pane.state.chatQueue = queue; + pane.requestUpdate?.(); + }; + install(); +} + +function createComposerQueueFixtureScript(): string { + return `(() => { const __name = (target) => target; (${installComposerQueueFixture.toString()})(${JSON.stringify(COMPOSER_QUEUE_FIXTURE)}); })();`; +} + function createMockGatewayPlugin(scenario: ControlUiMockGatewayScenario): Plugin { const initScript = escapeScriptContent(createControlUiMockGatewayInitScript(scenario)); const statefulInitScript = escapeScriptContent(createStatefulMockInitScript()); + const composerQueueFixtureScript = escapeScriptContent(createComposerQueueFixtureScript()); const bootstrapBody = JSON.stringify(createControlUiMockBootstrapConfig(scenario)); return { configureServer(server) { @@ -2898,7 +2935,7 @@ function createMockGatewayPlugin(scenario: ControlUiMockGatewayScenario): Plugin transformIndexHtml(html) { return html.replace( "", - ` \n `, + ` \n `, ); }, }; diff --git a/ui/src/components/icons.ts b/ui/src/components/icons.ts index 0213a05ac723..6e344bd92a5a 100644 --- a/ui/src/components/icons.ts +++ b/ui/src/components/icons.ts @@ -288,6 +288,10 @@ export const icons = { `), + queueList: strokeIcon(svg` + + + `), ...toolIcons, } as const; diff --git a/ui/src/e2e/chat-flow.queue-edit.e2e.test.ts b/ui/src/e2e/chat-flow.queue-edit.e2e.test.ts index 46df9acada8d..4c21b99b3daf 100644 --- a/ui/src/e2e/chat-flow.queue-edit.e2e.test.ts +++ b/ui/src/e2e/chat-flow.queue-edit.e2e.test.ts @@ -1,3 +1,4 @@ +import type { Locator } from "playwright"; import { expect, it } from "vitest"; import { createChatFlowE2eSuite, @@ -12,6 +13,11 @@ const suite = createChatFlowE2eSuite(); const QUEUED = ["review the migration", "then update the docs", "finally run the smoke"] as const; +async function openQueuedMessageEditor(row: Locator) { + await row.locator(".chat-queue__more").click(); + await row.locator("wa-dropdown-item[value='edit']").click(); +} + suite.define(() => { it("edits a queued message in its row and returns it to its place", async () => { const context = await suite.newBrowserContext({ @@ -48,7 +54,7 @@ suite.define(() => { await composer.fill("a separate composer draft"); - // Double-click is the shortcut; the pencil on the row is the visible path. + // Double-click remains the shortcut; Edit message in overflow is the visible path. await page.locator(".chat-queue__item").nth(1).dblclick(); const rowEditor = page.locator(".chat-queue__item").nth(1).locator(".chat-queue__edit-input"); @@ -97,7 +103,7 @@ suite.define(() => { await composer.fill("a separate composer draft"); const row = page.locator(".chat-queue__item").nth(1); - await row.locator(".chat-queue__edit").click(); + await openQueuedMessageEditor(row); const rowEditor = row.locator(".chat-queue__edit-input"); await rowEditor.waitFor({ timeout: 10_000 }); await rowEditor.fill("a replacement the operator abandons"); @@ -137,7 +143,7 @@ suite.define(() => { } const row = page.locator(".chat-queue__item").nth(1); - await row.locator(".chat-queue__edit").click(); + await openQueuedMessageEditor(row); const rowEditor = row.locator(".chat-queue__edit-input"); await rowEditor.waitFor({ timeout: 10_000 }); await composer.fill("a separate composer send"); @@ -205,9 +211,7 @@ suite.define(() => { await page.locator(".agent-chat__offline-hint").waitFor({ timeout: 10_000 }); const editRow = page.locator(".chat-queue__item", { hasText: "edit before send" }); - const editButton = editRow.locator(".chat-queue__edit"); - expect(await editButton.isDisabled()).toBe(false); - await editButton.click(); + await openQueuedMessageEditor(editRow); // `hasText` stops matching once the row text becomes a textarea value. const inlineEditor = page.locator(".chat-queue__edit-input"); await inlineEditor.waitFor({ timeout: 10_000 }); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index b7eba52f143b..6fe5e9dab1f7 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -5560,6 +5560,7 @@ export const en: TranslationMap = { steer: "Steer", steerQueuedMessage: "Steer queued message", removeQueuedMessage: "Remove queued message", + moreActions: "More queued message actions", reorderQueuedMessage: "Reorder queued message with the arrow keys", reorderUnavailable: "This message holds its place and cannot be reordered", editQueuedMessage: "Edit queued message", diff --git a/ui/src/pages/chat/chat-composer-actions.test.ts b/ui/src/pages/chat/chat-composer-actions.test.ts index c278adcc295a..fdc07a8a8501 100644 --- a/ui/src/pages/chat/chat-composer-actions.test.ts +++ b/ui/src/pages/chat/chat-composer-actions.test.ts @@ -470,7 +470,7 @@ describe("renderChatComposer controls", () => { expect(onAbort).not.toHaveBeenCalled(); }); - it("renders the queued author's avatar before the turn is submitted", async () => { + it("renders the queue glyph before the turn is submitted", async () => { const { container } = renderComposer({ queue: [ { @@ -478,15 +478,12 @@ describe("renderChatComposer controls", () => { text: "queued during the run", createdAt: 4, sendState: "waiting-idle", - sender: { id: "profile_123", name: "Alice Example" }, }, ], }); await vi.waitFor(() => { - expect( - container.querySelector(".chat-queue__item .chat-author-avatar__initials")?.textContent, - ).toContain("AE"); + expect(container.querySelector(".chat-queue__item .chat-queue__icon")).not.toBeNull(); }); }); @@ -504,8 +501,7 @@ describe("renderChatComposer controls", () => { }); const item = container.querySelector(".chat-queue__item"); expect(item?.classList.contains("chat-queue__item--reconnect")).toBe(true); - expect(item?.querySelector(".chat-queue__dot")).not.toBeNull(); - expect(item?.querySelector(".chat-queue__icon")).toBeNull(); + expect(item?.querySelector(".chat-queue__icon")).not.toBeNull(); expect(item?.querySelector(".chat-queue__error")).toBeNull(); const badge = item?.querySelector(".chat-queue__badge"); expect(badge?.textContent?.trim()).toBe("Waiting for reconnect"); diff --git a/ui/src/pages/chat/chat-composer-queue.test.ts b/ui/src/pages/chat/chat-composer-queue.test.ts index 4655f2be38db..c6cfd77970e1 100644 --- a/ui/src/pages/chat/chat-composer-queue.test.ts +++ b/ui/src/pages/chat/chat-composer-queue.test.ts @@ -91,14 +91,14 @@ describe("chat composer queue reordering", () => { const rows = container.querySelectorAll(".chat-queue__item"); expect(rows).toHaveLength(2); - expect([...rows].map((row) => row.getAttribute("draggable"))).toEqual(["true", "true"]); const grips = [...container.querySelectorAll(".chat-queue__grip")]; expect(grips).toHaveLength(2); expect(grips[0]?.tagName).toBe("BUTTON"); expect(grips[0]?.getAttribute("aria-label")).toBe(t("chat.queue.reorderQueuedMessage")); expect(grips[0]?.getAttribute("aria-keyshortcuts")).toBe("ArrowUp ArrowDown"); - // The row carries no overflow menu: the handle is the whole reorder surface. - expect(container.querySelector("wa-dropdown")).toBeNull(); + expect(grips.map((grip) => grip.getAttribute("draggable"))).toEqual(["true", "true"]); + expect([...rows].every((row) => !row.hasAttribute("draggable"))).toBe(true); + expect(container.querySelectorAll("wa-dropdown.chat-queue__overflow")).toHaveLength(2); }); it.each([ @@ -144,7 +144,7 @@ describe("chat composer queue reordering", () => { }); expect(container.querySelector(".chat-queue__grip")).toBeNull(); - expect(container.querySelector(".chat-queue__item")?.getAttribute("draggable")).toBe("false"); + expect(container.querySelector(".chat-queue__item")?.hasAttribute("draggable")).toBe(false); }); it("reserves the handle column on every row so the pills never shift", () => { @@ -211,11 +211,6 @@ describe("chat composer queue reordering", () => { }); const rows = [...container.querySelectorAll(".chat-queue__item")]; - expect(rows.map((row) => row.querySelector(".chat-queue__edit") !== null)).toEqual([ - true, - false, - true, - ]); expect(rows[1]?.querySelector(".chat-queue__edit-input")).not.toBeNull(); expect(rows[1]?.querySelector(".chat-queue__edit-submit")).not.toBeNull(); expect(rows[1]?.querySelector(".chat-queue__edit-cancel")).not.toBeNull(); @@ -225,13 +220,11 @@ describe("chat composer queue reordering", () => { true, ]); - const disabled = (selector: string) => - rows.map((row) => row.querySelector(selector)?.hasAttribute("disabled") ?? false); - expect(disabled(".chat-queue__edit")).toEqual([true, false, true]); - expect(disabled(".chat-queue__remove")).toEqual([false, false, false]); - - rows[0]?.querySelector(".chat-queue__edit")?.click(); - expect(onQueueEdit).not.toHaveBeenCalled(); + const editItem = rows[0]?.querySelector("wa-dropdown-item[value='edit']"); + expect(editItem?.hasAttribute("disabled")).toBe(true); + expect(rows[2]?.querySelector("wa-dropdown-item[value='edit']")?.hasAttribute("disabled")).toBe( + true, + ); rows[2]?.querySelector(".chat-queue__remove")?.click(); expect(onQueueRemove).toHaveBeenCalledWith("c"); @@ -275,7 +268,9 @@ describe("chat composer queue reordering", () => { }); const rows = [...container.querySelectorAll(".chat-queue__item")]; - expect(rows.map((row) => row.getAttribute("draggable"))).toEqual(["false", "true", "true"]); + expect( + rows.map((row) => row.querySelector(".chat-queue__grip")?.getAttribute("draggable")), + ).toEqual(["false", "true", "true"]); }); it("offers no move to a row alone between locked rows, and refuses a drop from across one", () => { @@ -293,12 +288,9 @@ describe("chat composer queue reordering", () => { const rows = [...container.querySelectorAll(".chat-queue__item")]; // "a" is a segment of one, so it has nothing to move against. - expect(rows.map((row) => row.getAttribute("draggable"))).toEqual([ - "false", - "false", - "true", - "true", - ]); + expect( + rows.map((row) => row.querySelector(".chat-queue__grip")?.getAttribute("draggable")), + ).toEqual(["false", "false", "true", "true"]); const dataTransfer = { types: ["application/x-openclaw-queued-message"], diff --git a/ui/src/pages/chat/components/chat-composer-queue.ts b/ui/src/pages/chat/components/chat-composer-queue.ts index 3ab883c65d6f..47a7aff2e845 100644 --- a/ui/src/pages/chat/components/chat-composer-queue.ts +++ b/ui/src/pages/chat/components/chat-composer-queue.ts @@ -101,7 +101,7 @@ function renderChatQueueItem( ) { const stateLabel = sendStateLabel(item); const failed = item.sendState === "failed" || item.sendState === "unconfirmed"; - const steerMode = item.queueMode === "steer"; + const steerMode = item.queueMode === "steer" && !failed; const reconnecting = item.sendState === "waiting-reconnect"; const busy = item.sendState === "executing-command"; const editing = props.editingId === item.id; @@ -111,7 +111,7 @@ function renderChatQueueItem( const moveIndex = segment.indexOf(item.id); const move = props.onQueueMove; // Queue-level: once any row can move, every row reserves the handle column so - // the pill and text stay on one x whatever state a row is in. Row-level: only + // the icon and text stay on one x whatever state a row is in. Row-level: only // a row that may actually move gets a live handle. const showsHandle = Boolean(move) && reorder.offered; const canMove = showsHandle && moveIndex >= 0 && segment.length > 1; @@ -125,23 +125,16 @@ function renderChatQueueItem( (item.attachments?.length ? t("chat.queue.imageCount", { count: String(item.attachments.length) }) : ""); - const itemClass = `chat-queue__item${failed ? " chat-queue__item--failed" : ""}${ - reconnecting ? " chat-queue__item--reconnect" : "" - }${editing ? " chat-queue__item--editing" : ""}`; + const itemClass = `chat-queue__item${steerMode ? " chat-queue__item--steered" : ""}${ + failed ? " chat-queue__item--failed" : "" + }${reconnecting ? " chat-queue__item--reconnect" : ""}${ + editing ? " chat-queue__item--editing" : "" + }`; // Row order keeps the actions on the first flex line; the error wraps below // them via flex-basis so failed rows grow by one line instead of a card. return html`
{ - event.dataTransfer?.setData(DRAG_MIME, item.id); - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = "move"; - } - } - : undefined} @dragover=${canMove ? (event: DragEvent) => { if (!event.dataTransfer?.types.includes(DRAG_MIME)) { @@ -172,11 +165,20 @@ function renderChatQueueItem( ? html` - - ` - : nothing} + : nothing} ${busy || editing ? nothing : html` @@ -312,10 +305,37 @@ function renderChatQueueItem( }} @dblclick=${(event: MouseEvent) => event.stopPropagation()} > - ${icons.x} + ${icons.trash} `} + ${editing + ? nothing + : html` + ) => { + if (event.detail.item.value === "edit" && canEdit) { + props.onQueueEdit?.(item.id); + } + }} + > + + + + ${t("chat.queue.editQueuedMessage")} + + + `} ${ // Reconnect rows auto-retry, so the raw transport error is noise there; diff --git a/ui/src/pages/chat/components/chat-composer-view.ts b/ui/src/pages/chat/components/chat-composer-view.ts index e7ec909ae0de..4b91f04f1d4e 100644 --- a/ui/src/pages/chat/components/chat-composer-view.ts +++ b/ui/src/pages/chat/components/chat-composer-view.ts @@ -167,23 +167,24 @@ export function renderChatComposerView(context: ChatComposerViewContext) { }) : nothing; + const queue = renderChatQueue({ + queue: props.queue, + canAbort: showAbortableUi, + onQueueRetry: props.connected && canCompose ? props.onQueueRetry : undefined, + onQueueSteer: props.connected && canCompose ? props.onQueueSteer : undefined, + // Reordering is local bookkeeping, so it stays available while offline — + // exactly when a queue is long enough to need it. + onQueueMove: props.onQueueMove, + onQueueEdit: props.queuedEdit?.onEdit, + onQueueEditChange: props.queuedEdit?.onEditChange, + onQueueEditSubmit: props.queuedEdit?.onEditSubmit, + onQueueEditCancel: props.queuedEdit?.onCancel, + editingId: props.queuedEdit?.editingId ?? null, + editingText: props.queuedEdit?.editingText, + onQueueRemove: props.onQueueRemove, + }); + return html` - ${renderChatQueue({ - queue: props.queue, - canAbort: showAbortableUi, - onQueueRetry: props.connected && canCompose ? props.onQueueRetry : undefined, - onQueueSteer: props.connected && canCompose ? props.onQueueSteer : undefined, - // Reordering is local bookkeeping, so it stays available while offline — - // exactly when a queue is long enough to need it. - onQueueMove: props.onQueueMove, - onQueueEdit: props.queuedEdit?.onEdit, - onQueueEditChange: props.queuedEdit?.onEditChange, - onQueueEditSubmit: props.queuedEdit?.onEditSubmit, - onQueueEditCancel: props.queuedEdit?.onCancel, - editingId: props.queuedEdit?.editingId ?? null, - editingText: props.queuedEdit?.editingText, - onQueueRemove: props.onQueueRemove, - })} ${props.runError ? html` ` : nothing} - ${disabledBanner} ${voiceError} + ${disabledBanner} ${voiceError} ${queue} ${showComposerInput ? html`