import { type Locator, type Page } from "@playwright/test"; import { test, expect } from "../../fixtures/test-base"; import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; import { typeWhileBusy, waitForComposerQueueMode } from "../../helpers/type-while-busy"; import { SessionPage } from "../../pages/session-page"; import { seedRunningGeneratingSession } from "../../helpers/generating-session"; import { expectFullQueueScrolls, seedFullQueueTask } from "./message-queue-scroll-helpers"; import { waitForQuickChatComposerReady } from "./quick-chat-helpers"; import { expectSendNowWorkflowRunning } from "./message-queue-workflow-helpers"; import { registerSeparateQueueRows, requestMessageQueueSettings, } from "../../helpers/message-queue-settings"; registerSeparateQueueRows(test); test("Send Now keeps a workflow transition running", async ({ testPage, apiClient, seedData }) => { await expectSendNowWorkflowRunning(testPage, apiClient, seedData, false); }); // --------------------------------------------------------------------------- // Quick Chat queue tests // --------------------------------------------------------------------------- /** * Open the collapsed queue panel by clicking the floating chip. The chip * appears above the chat input once at least one message is queued; the * panel only mounts after a click (collapsed by default). */ async function openQueuePanel(scope: Locator | Page): Promise { const chip = scope.getByTestId("queue-chip"); await expect(chip).toBeVisible({ timeout: 10_000 }); await chip.click(); await expect(scope.getByTestId("queued-ghost-list")).toBeVisible({ timeout: 5_000 }); } async function openQuickChatWithAgent(page: Page): Promise { await page.goto("/"); await page.waitForLoadState("networkidle"); const modifier = process.platform === "darwin" ? "Meta" : "Control"; await page.keyboard.press(`${modifier}+Shift+q`); const dialog = page.getByRole("dialog", { name: "Quick Chat" }); await expect(dialog).toBeVisible({ timeout: 10_000 }); const setup = dialog.getByTestId("quick-chat-setup"); if (!(await setup.isVisible({ timeout: 1_000 }).catch(() => false))) { await dialog.getByTestId("quick-chat-add-menu-trigger").click(); await page.getByTestId("quick-chat-new-agent").click(); } await expect(setup).toBeVisible({ timeout: 5_000 }); const agentSelector = dialog.getByTestId("agent-profile-selector"); if ( await agentSelector .getByText("Select agent", { exact: false }) .isVisible() .catch(() => false) ) { await agentSelector.click(); await page.getByRole("option").first().click(); } await dialog.getByTestId("quick-chat-start").click(); await waitForQuickChatComposerReady(dialog); return dialog; } test.describe("Quick chat queue", () => { // Allow 1 retry: the test can be flaky when a previous test cycle's agent process hasn't // fully shut down, causing the new session to conflict with a stale execution. test.describe.configure({ retries: 1 }); test("queued message indicator appears and message executes after agent turn", async ({ testPage, }) => { test.setTimeout(60_000); const dialog = await openQuickChatWithAgent(testPage); // Send a slow command so the agent stays busy for 10 seconds. const editor = dialog.locator(".tiptap.ProseMirror"); await typeWhileBusy(testPage, editor, "/slow 10s"); const modifier = process.platform === "darwin" ? "Meta" : "Control"; await editor.press(`${modifier}+Enter`); // Wait for agent to become busy. await expect(testPage.getByRole("status", { name: /Agent is (starting|running)/ })).toBeVisible( { timeout: 15_000, }, ); await waitForComposerQueueMode(dialog); await typeWhileBusy(testPage, editor, "hello world"); await testPage.keyboard.press(`${modifier}+Enter`); // Collapsed-by-default chip is the new queued-message indicator. const chip = dialog.getByTestId("queue-chip"); await expect(chip).toBeVisible({ timeout: 10_000 }); // The chip is only rendered while the queue panel is collapsed, so its // mere presence implies the closed state — no data-open assertion needed. // Wait for the first (slow) response to complete. await expect(dialog.getByText("Slow response complete", { exact: false })).toBeVisible({ timeout: 30_000, }); // The queued message should auto-execute — wait for the agent turn to finish. await expect( dialog.locator('[data-placeholder="Continue working on the task..."]'), ).toBeVisible({ timeout: 30_000, }); }); test("queue message via submit button click", async ({ testPage }) => { test.setTimeout(90_000); const dialog = await openQuickChatWithAgent(testPage); // Send a slow command so the agent stays busy for 10 seconds. const editor = dialog.locator(".tiptap.ProseMirror"); await typeWhileBusy(testPage, editor, "/slow 10s"); const modifier = process.platform === "darwin" ? "Meta" : "Control"; await editor.press(`${modifier}+Enter`); // Wait for agent to become busy. await expect(testPage.getByRole("status", { name: /Agent is (starting|running)/ })).toBeVisible( { timeout: 15_000, }, ); await waitForComposerQueueMode(dialog); // Before typing, only the cancel button should be visible (no send button). const submitBtn = dialog.getByTestId("submit-message-button"); await expect(submitBtn).not.toBeVisible(); await expect(dialog.getByTestId("cancel-agent-button")).toBeVisible(); // Type a queued message — the submit button should appear. await typeWhileBusy(testPage, editor, "queued via button"); await expect(submitBtn).toBeVisible({ timeout: 5_000 }); // Click the submit button (not keyboard shortcut) to queue the message. await submitBtn.click(); // Verify the collapsed chip appears as the queued-message indicator. await expect(dialog.getByTestId("queue-chip")).toBeVisible({ timeout: 10_000 }); // Verify the cancel-agent button is also visible alongside submit. const cancelAgentBtn = dialog.getByTestId("cancel-agent-button"); await expect(cancelAgentBtn).toBeVisible(); // Wait for the first (slow) response to complete and queued message to auto-execute. await expect( dialog.locator('[data-placeholder="Continue working on the task..."]'), ).toBeVisible({ timeout: 60_000, }); }); }); // --------------------------------------------------------------------------- // Task session queue tests // --------------------------------------------------------------------------- async function seedTaskAndWaitForIdle( testPage: Page, apiClient: ApiClient, seedData: SeedData, title: string, description = "/e2e:simple-message", ): Promise { const task = await apiClient.createTaskWithAgent( seedData.workspaceId, title, seedData.agentProfileId, { description, workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], }, ); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); await session.waitForChatIdle({ timeout: 30_000 }); return session; } async function queueMessages( apiClient: ApiClient, taskId: string, sessionId: string, messages: string[], ): Promise { const identity = await apiClient.getQueueSessionIdentity(taskId, sessionId); for (const message of messages) { await apiClient.queueMessage(identity, message); } } function scriptedQueueMessage(marker: string, delayMs = 250): string { return `e2e:delay(${delayMs})\ne2e:message("${marker}")`; } async function expectSeparateTurnsInOrder(scope: Locator, markers: string[]): Promise { const agentBodies = scope.locator("[data-agent-message-body][data-message-id]"); for (const marker of markers) { await expect(agentBodies.filter({ hasText: marker })).toHaveCount(1, { timeout: 45_000 }); } const agentTexts = await agentBodies.allTextContents(); const agentIndexes = markers.map((marker) => agentTexts.findIndex((text) => text.includes(marker)), ); expect(agentIndexes.every((index) => index >= 0)).toBe(true); expect(agentIndexes).toEqual([...agentIndexes].sort((a, b) => a - b)); const userTexts = await scope.getByTestId("user-message-bubble").allTextContents(); const userIndexes = markers.map((marker) => userTexts.findIndex((text) => text.includes(marker))); expect(userIndexes.every((index) => index >= 0)).toBe(true); expect(new Set(userIndexes).size).toBe(markers.length); expect(userIndexes).toEqual([...userIndexes].sort((a, b) => a - b)); } test.describe("Task session queue", () => { test.describe.configure({ retries: 1 }); test("full queue scrolls internally without hiding the composer", async ({ testPage, apiClient, seedData, }) => { const { session } = await seedFullQueueTask( testPage, apiClient, seedData, "Desktop full queue scrolling", ); await expectFullQueueScrolls(session); }); test("automatic merge compacts compatible user rows and disabled mode keeps them separate", async ({ testPage, apiClient, seedData, }) => { const task = await apiClient.createTask(seedData.workspaceId, "Automatic queue merge", { workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], }); const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, { state: "RUNNING", agentProfileId: seedData.agentProfileId, }); const queueIdentity = await apiClient.getQueueSessionIdentity(task.id, sessionId); await requestMessageQueueSettings(apiClient, "PATCH", { auto_merge_enabled: true }); await apiClient.queueMessage(queueIdentity, "automatic first"); await apiClient.queueMessage(queueIdentity, "automatic second"); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); await openQueuePanel(session.activeChat()); const panel = session.activeChat().getByTestId("queued-ghost-list"); const entries = panel.getByTestId("queue-entry-text"); await expect(entries).toHaveCount(1); const autoMerge = panel.getByTestId("queue-auto-merge"); await expect(autoMerge).toHaveAttribute("data-state", "checked"); await autoMerge.click(); await expect(autoMerge).toHaveAttribute("data-state", "unchecked"); await expect(entries.first()).toContainText("automatic first"); await expect(entries.first()).toContainText("automatic second"); await requestMessageQueueSettings(apiClient, "PATCH", { auto_merge_enabled: false }); await requestMessageQueueSettings(apiClient, "PATCH", { auto_merge_enabled: true }); await expect(autoMerge).toHaveAttribute("data-state", "unchecked"); await apiClient.queueMessage(queueIdentity, "separate third"); await apiClient.queueMessage(queueIdentity, "separate fourth"); await expect(entries).toHaveCount(3, { timeout: 10_000 }); await expect(entries.nth(1)).toHaveText("separate third"); await expect(entries.nth(2)).toHaveText("separate fourth"); }); test("automatic merge accepts a compatible message into a full queue", async ({ testPage, apiClient, seedData, }) => { const { taskId, sessionId, session } = await seedFullQueueTask( testPage, apiClient, seedData, "Full queue auto merge", ); // The queue is at capacity (10 separate rows, auto-merge off per spec // isolation). Enabling automatic merge must fold the next compatible // message into the tail instead of rejecting it as "queue full". await requestMessageQueueSettings(apiClient, "PATCH", { auto_merge_enabled: true }); const queueIdentity = await apiClient.getQueueSessionIdentity(taskId, sessionId); await apiClient.queueMessage(queueIdentity, "folded while full"); const chat = session.activeChat(); const chip = chat.getByTestId("queue-chip"); await expect(chip).toBeVisible({ timeout: 10_000 }); await chip.click(); const panel = chat.getByTestId("queued-ghost-list"); const entries = panel.getByTestId("queue-entry-text"); await expect(entries).toHaveCount(10); await expect(entries.last()).toContainText("Queued item 10"); await expect(entries.last()).toContainText("folded while full"); }); test("queue message via submit button on task session page", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(90_000); const session = await seedTaskAndWaitForIdle( testPage, apiClient, seedData, "Queue button test", ); // Send a slow command to keep the agent busy. await session.sendMessage("/slow 5s"); await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); await waitForComposerQueueMode(testPage); // Type a message while agent is busy. const editor = testPage.locator(".tiptap.ProseMirror").first(); await typeWhileBusy(testPage, editor, "queued via button"); // Both submit and cancel-agent buttons should be visible. const submitBtn = testPage.getByTestId("submit-message-button"); const cancelAgentBtn = testPage.getByTestId("cancel-agent-button"); await expect(submitBtn).toBeVisible({ timeout: 5_000 }); await expect(cancelAgentBtn).toBeVisible(); // Click the submit button to queue the message. await submitBtn.click(); // Collapsed chip is the queued-message indicator on the task session page. await expect(testPage.getByTestId("queue-chip")).toBeVisible({ timeout: 10_000 }); // Expand once so we can verify the per-entry Remove control is present. await openQueuePanel(testPage); await expect(testPage.getByTitle("Remove queued message")).toBeVisible({ timeout: 5_000 }); // Wait for the queued message to auto-execute and agent to become idle. await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); }); test("Send Now resumes Auto-run with the selected row first", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(120_000); const session = await seedTaskAndWaitForIdle( testPage, apiClient, seedData, "Queue Send Now row test", ); await session.sendMessage("/slow 30s"); await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); await expect(session.chat.getByText("Running slow response (30s total)...")).toBeVisible({ timeout: 15_000, }); const taskID = new URL(testPage.url()).pathname.split("/").pop(); if (!taskID) throw new Error("task URL did not contain a task ID"); const workflowStepBefore = (await apiClient.getTask(taskID)).workflow_step_id; const markerA = "targeted A response"; const markerB = "targeted B response"; const markerC = "targeted C response"; const task = await apiClient.getTask(taskID); const sessionID = task.primary_session_id; if (!sessionID) throw new Error("task did not have a primary session"); await queueMessages(apiClient, taskID, sessionID, [ scriptedQueueMessage(markerA), scriptedQueueMessage(markerB, 5_000), scriptedQueueMessage(markerC), ]); await openQueuePanel(testPage); const panel = testPage.getByTestId("queued-ghost-list"); await expect(panel.getByTestId("queue-entry-text")).toHaveCount(3); const autoRun = panel.getByTestId("queue-auto-run"); await expect(autoRun).toHaveAttribute("data-state", "checked"); await autoRun.click(); await expect(autoRun).toHaveAttribute("data-state", "unchecked"); const target = panel.getByTestId("queue-entry").filter({ hasText: markerB }); await expect(target).toBeVisible(); await target.hover(); const sendNow = target.getByTestId("queue-entry-send-now"); await expect(sendNow).toBeVisible({ timeout: 10_000 }); await expect(sendNow).toBeEnabled({ timeout: 10_000 }); await sendNow.click(); await expect(panel.getByTestId("queue-entry-text")).toHaveCount(2, { timeout: 10_000 }); await expect(panel.getByTestId("queue-entry-text").nth(0)).toContainText(markerA); await expect(panel.getByTestId("queue-entry-text").nth(1)).toContainText(markerC); await expect(autoRun).toHaveAttribute("data-state", "checked", { timeout: 10_000 }); // Send Now's internal interruption must not behave like an explicit user // cancellation. Check while the selected replacement turn is still active; // successful turn completion may legitimately advance the workflow later. await expect( session.chat.getByTestId("user-message-bubble").filter({ hasText: markerB }), ).toHaveCount(1, { timeout: 20_000 }); await expect(session.agentStatus()).toBeVisible({ timeout: 20_000 }); expect((await apiClient.getTask(taskID)).workflow_step_id).toBe(workflowStepBefore); await expectSeparateTurnsInOrder(session.chat, [markerB, markerA, markerC]); await session.waitForChatIdle({ timeout: 45_000 }); await expect(panel).not.toBeVisible({ timeout: 15_000 }); await expect(session.chat).not.toContainText("Turn cancelled by user"); }); test("Auto-run OFF finishes the current turn, survives reload, then resumes FIFO", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(120_000); const session = await seedTaskAndWaitForIdle( testPage, apiClient, seedData, "Queue Auto-run hold and resume", ); await session.sendMessage("/slow 8s"); await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); await waitForComposerQueueMode(testPage); const taskID = new URL(testPage.url()).pathname.split("/").pop(); if (!taskID) throw new Error("task URL did not contain a task ID"); const task = await apiClient.getTask(taskID); const sessionID = task.primary_session_id; if (!sessionID) throw new Error("task did not have a primary session"); const markers = ["auto-run A response", "auto-run B response", "auto-run C response"]; await queueMessages( apiClient, taskID, sessionID, markers.map((marker) => scriptedQueueMessage(marker)), ); await openQueuePanel(testPage); let panel = testPage.getByTestId("queued-ghost-list"); await expect(panel.getByTestId("queue-entry-text")).toHaveCount(3); let autoRun = panel.getByTestId("queue-auto-run"); await expect(autoRun).toHaveAttribute("data-state", "checked"); await autoRun.click(); await expect(autoRun).toHaveAttribute("data-state", "unchecked"); await expect(session.chat.getByText("Slow response complete", { exact: false })).toBeVisible({ timeout: 30_000, }); await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); await expect(panel.getByTestId("queue-entry-text")).toHaveCount(3); await expect(autoRun).toHaveAttribute("data-state", "unchecked"); await testPage.reload(); await session.waitForLoad(); await openQueuePanel(testPage); panel = testPage.getByTestId("queued-ghost-list"); autoRun = panel.getByTestId("queue-auto-run"); await expect(panel.getByTestId("queue-entry-text")).toHaveCount(3); await expect(autoRun).toHaveAttribute("data-state", "unchecked"); await autoRun.click(); await expectSeparateTurnsInOrder(session.chat, markers); await session.waitForChatIdle({ timeout: 45_000 }); await expect(testPage.getByTestId("queue-chip")).not.toBeVisible({ timeout: 15_000 }); }); test("queue editor textarea scrolls when content is long", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(90_000); const session = await seedTaskAndWaitForIdle( testPage, apiClient, seedData, "Queue editor scroll test", ); // Send a slow command to keep the agent busy. await session.sendMessage("/slow 10s"); await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); await waitForComposerQueueMode(testPage); // Type a short message while agent is busy and queue it. const editor = testPage.locator(".tiptap.ProseMirror").first(); await typeWhileBusy(testPage, editor, "short queued msg"); const submitBtn = testPage.getByTestId("submit-message-button"); await expect(submitBtn).toBeVisible({ timeout: 5_000 }); await submitBtn.click(); // Expand the collapsed queue panel to reveal the per-entry Edit affordance. await openQueuePanel(testPage); const editBtn = testPage.getByTitle("Edit queued message"); await expect(editBtn).toBeVisible({ timeout: 10_000 }); await editBtn.click(); // The edit textarea should now be visible. const textarea = testPage.getByTestId("queue-edit-textarea"); await expect(textarea).toBeVisible({ timeout: 5_000 }); // Fill via native setter + React event so the controlled component updates. const longText = Array.from( { length: 30 }, (_, i) => `Line ${i + 1} of scroll test content`, ).join("\n"); await textarea.evaluate((el: HTMLTextAreaElement, text: string) => { const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!; setter.call(el, text); el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); }, longText); // Verify the textarea has a constrained max-height and is scrollable. // The auto-grow effect re-measures after React commits the new value, so // poll the metrics rather than sampling them once behind a fixed settle. await expect .poll( async () => textarea.evaluate((el: HTMLTextAreaElement) => ({ scrollable: el.scrollHeight > el.clientHeight, maxHeight: getComputedStyle(el).maxHeight, overflowY: getComputedStyle(el).overflowY, })), { timeout: 5_000 }, ) .toEqual({ scrollable: true, maxHeight: "200px", overflowY: "auto" }); }); test("merges a queued message into the message above it", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(90_000); const session = await seedTaskAndWaitForIdle(testPage, apiClient, seedData, "Queue merge test"); // Send a slow command to keep the agent busy while we queue two messages. await session.sendMessage("/slow 10s"); await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); await waitForComposerQueueMode(testPage); const editor = testPage.locator(".tiptap.ProseMirror").first(); await typeWhileBusy(testPage, editor, "first message"); const submitBtn = testPage.getByTestId("submit-message-button"); await expect(submitBtn).toBeVisible({ timeout: 5_000 }); await submitBtn.click(); await typeWhileBusy(testPage, editor, "second message"); await expect(submitBtn).toBeVisible({ timeout: 5_000 }); await submitBtn.click(); // Expand the queue panel; the second row offers the merge control. await openQueuePanel(testPage); const mergeButtons = testPage.getByTestId("queue-entry-merge"); await expect(mergeButtons).toHaveCount(1, { timeout: 5_000 }); await mergeButtons.click(); // After the merge the queue holds a single entry whose text contains both // messages, and the panel header reflects one queued message. const entries = testPage.getByTestId("queue-entry-text"); await expect(entries).toHaveCount(1, { timeout: 5_000 }); await expect(entries.first()).toContainText("first message"); await expect(entries.first()).toContainText("second message"); await expect(testPage.getByTestId("queued-ghost-list")).toContainText(/1 of 10/); }); test("queue message with plan mode enabled via submit button", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(90_000); const session = await seedTaskAndWaitForIdle( testPage, apiClient, seedData, "Queue plan mode test", ); // Enable plan mode. await session.togglePlanMode(); // Send a slow command to keep the agent busy. await session.sendMessage("/slow 5s"); await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); await waitForComposerQueueMode(testPage); // In plan mode with no typed text, only the cancel button should be visible. // The auto-added plan context should NOT cause the send button to appear. const submitBtn = testPage.getByTestId("submit-message-button"); await expect(submitBtn).not.toBeVisible(); await expect(testPage.getByTestId("cancel-agent-button")).toBeVisible(); // Type a message while agent is busy — send button should appear. const editor = testPage.locator(".tiptap.ProseMirror").first(); await typeWhileBusy(testPage, editor, "plan queue test"); await expect(submitBtn).toBeVisible({ timeout: 5_000 }); // Click the submit button to queue the message. await submitBtn.click(); // Expand the chip first; the panel is collapsed by default. await openQueuePanel(testPage); // Verify the queued ghost list shows clean text (no system tags). const queueIndicator = testPage.getByTestId("queued-ghost-list"); await expect(queueIndicator).toBeVisible({ timeout: 10_000 }); await expect(queueIndicator).not.toContainText("kandev-system"); // Wait for agent to finish processing. await expect(session.planModeInput()).toBeVisible({ timeout: 30_000 }); }); }); // --------------------------------------------------------------------------- // Queue affordance — chip & panel behavior // --------------------------------------------------------------------------- test.describe("Queue affordance", () => { test.describe.configure({ retries: 1 }); test("queue chip stays collapsed by default and toggles via panel close button", async ({ testPage, }) => { test.setTimeout(90_000); const dialog = await openQuickChatWithAgent(testPage); // Send a slow command so the agent stays busy long enough to queue. const editor = dialog.locator(".tiptap.ProseMirror"); await editor.click(); await editor.fill("/slow 10s"); const modifier = process.platform === "darwin" ? "Meta" : "Control"; await editor.press(`${modifier}+Enter`); await expect(testPage.getByRole("status", { name: /Agent is (starting|running)/ })).toBeVisible( { timeout: 15_000 }, ); await waitForComposerQueueMode(dialog); await typeWhileBusy(testPage, editor, "first queued"); await testPage.keyboard.press(`${modifier}+Enter`); // Chip is present and collapsed by default. const chip = dialog.getByTestId("queue-chip"); await expect(chip).toBeVisible({ timeout: 10_000 }); await expect(chip).toContainText("queued"); await expect(dialog.getByTestId("queued-ghost-list")).not.toBeVisible(); // Clicking the chip expands the panel; the chip itself unmounts because the // panel header carries the same affordance. await chip.click(); await expect(dialog.getByTestId("queued-ghost-list")).toBeVisible({ timeout: 5_000 }); await expect(dialog.getByTestId("queue-chip")).not.toBeVisible(); // The panel header's X button collapses back to the chip. await dialog.getByTestId("queue-close").click(); await expect(dialog.getByTestId("queued-ghost-list")).not.toBeVisible(); await expect(dialog.getByTestId("queue-chip")).toBeVisible({ timeout: 5_000 }); }); test("Escape collapses an open queue panel", async ({ testPage }) => { test.setTimeout(90_000); const dialog = await openQuickChatWithAgent(testPage); const editor = dialog.locator(".tiptap.ProseMirror"); await editor.click(); await editor.fill("/slow 10s"); const modifier = process.platform === "darwin" ? "Meta" : "Control"; await editor.press(`${modifier}+Enter`); await expect(testPage.getByRole("status", { name: /Agent is (starting|running)/ })).toBeVisible( { timeout: 15_000 }, ); await waitForComposerQueueMode(dialog); await typeWhileBusy(testPage, editor, "queued for esc"); await testPage.keyboard.press(`${modifier}+Enter`); await openQueuePanel(dialog); // Move focus out of the editor so Escape isn't swallowed by the textarea guard. await dialog.getByTestId("queue-close").focus(); await testPage.keyboard.press("Escape"); await expect(dialog.getByTestId("queued-ghost-list")).not.toBeVisible({ timeout: 5_000 }); await expect(dialog.getByTestId("queue-chip")).toBeVisible({ timeout: 5_000 }); }); test("clear-all from the panel empties the queue and hides the chip", async ({ testPage, apiClient, seedData, }) => { test.setTimeout(90_000); const session = await seedTaskAndWaitForIdle( testPage, apiClient, seedData, "Queue clear-all test", ); await session.sendMessage("/slow 10s"); await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); await waitForComposerQueueMode(testPage); const editor = testPage.locator(".tiptap.ProseMirror").first(); await typeWhileBusy(testPage, editor, "to be cleared"); const submitBtn = testPage.getByTestId("submit-message-button"); await expect(submitBtn).toBeVisible({ timeout: 5_000 }); await submitBtn.click(); await openQueuePanel(testPage); await testPage.getByTestId("queue-clear-all").click(); // Panel and chip both disappear once the queue is empty. await expect(testPage.getByTestId("queued-ghost-list")).not.toBeVisible({ timeout: 5_000 }); await expect(testPage.getByTestId("queue-chip")).not.toBeVisible({ timeout: 5_000 }); }); }); async function readQueuePreviewMetricsWithoutDisclosure( preview: Locator, ): Promise<{ clientHeight: number; scrollHeight: number }> { return preview.evaluate((element) => { const disclosure = element .closest('[data-testid="queue-entry"]') ?.querySelector('[data-testid="queue-entry-expand"]'); const previousDisplay = disclosure?.style.display; try { if (disclosure) disclosure.style.display = "none"; return { clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, }; } finally { if (disclosure) disclosure.style.display = previousDisplay ?? ""; } }); } test.describe("Queued row controls", () => { test.describe.configure({ timeout: 120_000 }); // @covers AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.9 // @covers AC-UI-MESSAGE-QUEUE-MANAGEMENT-001.10 test("adapts queued row controls to rendered overflow", async ({ testPage, apiClient, seedData, }) => { const fixture = "adaptive-width-probe ".repeat(12).trim(); await testPage.setViewportSize({ width: 1280, height: 900 }); const { session, taskId, sessionId } = await seedRunningGeneratingSession( testPage, apiClient, seedData, "Adaptive queued row controls", { sleepSeconds: 60 }, ); await testPage.getByRole("button", { name: "Collapse sidebar" }).click(); await expect(testPage.getByTestId("app-sidebar")).toHaveAttribute("data-collapsed", "true"); const chatMaximize = testPage .locator( '.dv-groupview:has([data-testid^="session-tab-"]) .dv-tabs-and-actions-container [data-testid="dockview-maximize-btn"]', ) .first(); await expect(chatMaximize).toBeVisible(); await chatMaximize.click(); const identity = await apiClient.getQueueSessionIdentity(taskId, sessionId); const autoRunResponse = await apiClient.setQueueAutoRun(identity, false); expect(autoRunResponse).toMatchObject({ session_id: sessionId, auto_run: false }); await waitForComposerQueueMode(testPage); await apiClient.queueMessage(identity, fixture); await expect .poll(() => apiClient.getQueueStatus(identity)) .toMatchObject({ count: 1, auto_run: false }); const chat = session.activeChat(); await chat.getByTestId("queue-chip").click(); const panel = chat.getByTestId("queued-ghost-list"); const row = panel.getByTestId("queue-entry").filter({ hasText: fixture }); const preview = row.getByTestId("queue-entry-text"); const actions = row.getByTestId("queue-entry-actions"); const remove = row.getByTestId("queue-entry-remove"); await expect(row).toBeVisible(); await expect .poll(async () => { const metrics = await readQueuePreviewMetricsWithoutDisclosure(preview); return metrics.scrollHeight === metrics.clientHeight; }) .toBe(true); await expect(row.getByTestId("queue-entry-expand")).toHaveCount(0); await testPage.setViewportSize({ width: 800, height: 900 }); await expect .poll(async () => { const metrics = await readQueuePreviewMetricsWithoutDisclosure(preview); return metrics.scrollHeight > metrics.clientHeight; }) .toBe(true); const expand = row.getByTestId("queue-entry-expand"); await expect(expand).toBeVisible(); await testPage.mouse.move(0, 0); await expect(actions).toHaveCSS("opacity", "0"); await row.hover(); await expect(actions).toHaveCSS("opacity", "1"); expect(await testPage.evaluate(() => matchMedia("(pointer: fine)").matches)).toBe(true); await expect(remove).toHaveAttribute("title", "Remove queued message"); await expect( row.getByRole("button", { name: "Remove queued message", exact: true }), ).toHaveCount(1); await expect(remove.locator("svg")).toHaveClass(/tabler-icon-trash/); const order = await actions.evaluate((container) => { const direct = [...container.children] as HTMLElement[]; const disclosure = direct.find((element) => element.dataset.testid === "queue-entry-expand"); const terminal = direct.at(-1); return { adjacent: disclosure?.nextElementSibling === terminal, terminalTestId: (terminal as HTMLElement | undefined)?.dataset.testid, }; }); expect(order).toEqual({ adjacent: true, terminalTestId: "queue-entry-remove" }); const colors = await remove.evaluate((button) => { const muted = document.createElement("span"); muted.className = "text-muted-foreground"; const destructive = document.createElement("span"); destructive.className = "text-destructive"; button.parentElement?.append(muted, destructive); const values = { muted: getComputedStyle(muted).color, destructive: getComputedStyle(destructive).color, }; muted.remove(); destructive.remove(); return values; }); await expect(remove).toHaveCSS("color", colors.muted); await remove.hover(); await expect(remove).toHaveCSS("color", colors.destructive); await testPage.setViewportSize({ width: 1280, height: 900 }); await expect .poll(async () => { const metrics = await readQueuePreviewMetricsWithoutDisclosure(preview); return metrics.scrollHeight === metrics.clientHeight; }) .toBe(true); await expect(row.getByTestId("queue-entry-expand")).toHaveCount(0); await remove.click(); await expect(row).toHaveCount(0); }); });