From 3a50cf42e5fe8e8531c539a9c420fe3ad4fcc3fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 29 Mar 2026 22:13:39 +0100 Subject: [PATCH] fix(web): show both send and cancel buttons when agent is busy (#487) --- .agents/skills/e2e/SKILL.md | 2 +- apps/backend/cmd/mock-agent/handler.go | 16 +- apps/backend/cmd/mock-agent/main.go | 58 +++++- .../backend/cmd/mock-agent/mock_agent_test.go | 57 ++++++ .../components/task/chat/chat-input-body.tsx | 5 + .../task/chat/chat-input-toolbar.tsx | 179 +++++++----------- .../task/chat/queued-message-indicator.tsx | 9 +- .../task/chat/reset-context-button.tsx | 86 +++++++++ apps/web/e2e/tests/quick-chat-queue.spec.ts | 92 +++++++-- apps/web/e2e/tests/task-session-queue.spec.ts | 152 +++++++++++++++ 10 files changed, 507 insertions(+), 149 deletions(-) create mode 100644 apps/web/components/task/chat/reset-context-button.tsx create mode 100644 apps/web/e2e/tests/task-session-queue.spec.ts diff --git a/.agents/skills/e2e/SKILL.md b/.agents/skills/e2e/SKILL.md index e2fa76bef8..56da50e14f 100644 --- a/.agents/skills/e2e/SKILL.md +++ b/.agents/skills/e2e/SKILL.md @@ -69,7 +69,7 @@ make test-e2e-report # open HTML report from last run - **"Backend did not become healthy"** — run `make build-backend build-web`, check with `E2E_DEBUG=1` - **"Cannot find module"** — run `cd apps && pnpm install` - **Port conflicts** — backends use 18080+, frontends use 13000+ (per worker) -- **Flaky timeouts** — health checks 30s, tests 60s. Check `fixtures/backend.ts` or `playwright.config.ts` +- **Flaky timeouts** — **never increase locator timeouts to fix flaky tests.** If a locator times out, the root cause is almost always something else: a setup failure, missing navigation, race condition, or the element genuinely not rendering. Investigate why the element never appears instead of giving it more time. Note: infrastructure health timeouts (30s in `fixtures/backend.ts`) and overall test timeouts (60s in `playwright.config.ts`) are separate and should not be modified either. - Screenshots on failure, video on first retry (CI) ## TDD workflow diff --git a/apps/backend/cmd/mock-agent/handler.go b/apps/backend/cmd/mock-agent/handler.go index d12a671d7c..0b37e2510a 100644 --- a/apps/backend/cmd/mock-agent/handler.go +++ b/apps/backend/cmd/mock-agent/handler.go @@ -4,6 +4,7 @@ import ( "fmt" "math/rand" "os" + "regexp" "strings" "time" ) @@ -32,17 +33,16 @@ func fixedDelay(ms int) { time.Sleep(time.Duration(ms) * time.Millisecond) } -// stripKandevSystem removes ... blocks injected by -// injectKandevContext so that /e2e: routing works regardless of system prompt wrapping. +// stripKandevSystem removes all ... blocks from the +// prompt. Tags can be prepended (backend system context injection) or appended +// (frontend plan/document context), so we strip all occurrences. func stripKandevSystem(prompt string) string { - const endTag = "" - idx := strings.LastIndex(prompt, endTag) - if idx < 0 { - return prompt - } - return strings.TrimSpace(prompt[idx+len(endTag):]) + result := kandevSystemRegex.ReplaceAllString(prompt, "") + return strings.TrimSpace(result) } +var kandevSystemRegex = regexp.MustCompile(`[\s\S]*?`) + // handlePrompt routes a user prompt to the appropriate sequence generator. func handlePrompt(e *emitter, prompt, model string) { prompt = strings.TrimSpace(prompt) diff --git a/apps/backend/cmd/mock-agent/main.go b/apps/backend/cmd/mock-agent/main.go index f69d274864..f4312f95a4 100644 --- a/apps/backend/cmd/mock-agent/main.go +++ b/apps/backend/cmd/mock-agent/main.go @@ -30,10 +30,11 @@ var mcpServers map[string]mcpServerDef // mockAgent implements the acp.Agent interface for the mock agent. type mockAgent struct { - conn *acp.AgentSideConnection - model string - sessions map[acp.SessionId]bool - mu sync.Mutex + conn *acp.AgentSideConnection + model string + sessions map[acp.SessionId]bool + commandsEmitted map[acp.SessionId]bool + mu sync.Mutex } func main() { @@ -51,8 +52,9 @@ func main() { defer closeMCPClients() ag := &mockAgent{ - model: model, - sessions: make(map[acp.SessionId]bool), + model: model, + sessions: make(map[acp.SessionId]bool), + commandsEmitted: make(map[acp.SessionId]bool), } asc := acp.NewAgentSideConnection(ag, os.Stdout, os.Stdin) ag.conn = asc @@ -97,6 +99,7 @@ func (a *mockAgent) LoadSession(_ context.Context, req acp.LoadSessionRequest) ( // Prompt processes a user message and streams responses via SessionUpdate. func (a *mockAgent) Prompt(ctx context.Context, req acp.PromptRequest) (acp.PromptResponse, error) { + a.emitAvailableCommandsOnce(ctx, req.SessionId) prompt := extractPromptText(req.Prompt) e := &emitter{ctx: ctx, conn: a.conn, sid: req.SessionId} handlePrompt(e, prompt, a.model) @@ -120,6 +123,49 @@ func (a *mockAgent) SetSessionConfigOption(_ context.Context, _ acp.SetSessionCo return acp.SetSessionConfigOptionResponse{}, nil } +// emitAvailableCommandsOnce sends the available commands list once per session, +// on the first prompt. Emitting during Prompt (not NewSession) avoids writing +// notifications to stdout before the JSON-RPC session response. +func (a *mockAgent) emitAvailableCommandsOnce(ctx context.Context, sid acp.SessionId) { + a.mu.Lock() + if a.commandsEmitted[sid] { + a.mu.Unlock() + return + } + a.commandsEmitted[sid] = true + a.mu.Unlock() + + _ = a.conn.SessionUpdate(ctx, acp.SessionNotification{ + SessionId: sid, + Update: acp.SessionUpdate{ + AvailableCommandsUpdate: &acp.SessionAvailableCommandsUpdate{ + AvailableCommands: mockAvailableCommands(), + }, + }, + }) +} + +// mockAvailableCommands returns the slash commands supported by the mock agent. +func mockAvailableCommands() []acp.AvailableCommand { + hint := func(h string) *acp.AvailableCommandInput { + return &acp.AvailableCommandInput{Unstructured: &acp.UnstructuredCommandInput{Hint: h}} + } + return []acp.AvailableCommand{ + {Name: "slow", Description: "Run a slow response (default 5s)", Input: hint("duration (e.g. 10s)")}, + {Name: "error", Description: "Simulate an error"}, + {Name: "thinking", Description: "Emit thinking/reasoning blocks"}, + {Name: "crash", Description: "Simulate agent crash"}, + {Name: "all", Description: "Demonstrate all message types"}, + {Name: "todo", Description: "Emit a todo list"}, + {Name: "mermaid", Description: "Emit a mermaid diagram"}, + {Name: "subagent", Description: "Emit a subagent sequence"}, + {Name: "tool:read", Description: "Emit a read file tool call"}, + {Name: "tool:edit", Description: "Emit an edit file tool call"}, + {Name: "tool:exec", Description: "Emit a shell exec tool call"}, + {Name: "tool:search", Description: "Emit a search tool call"}, + } +} + // extractPromptText concatenates text content blocks from the prompt. func extractPromptText(blocks []acp.ContentBlock) string { var parts []string diff --git a/apps/backend/cmd/mock-agent/mock_agent_test.go b/apps/backend/cmd/mock-agent/mock_agent_test.go index 5783d7ed50..cb84c0f153 100644 --- a/apps/backend/cmd/mock-agent/mock_agent_test.go +++ b/apps/backend/cmd/mock-agent/mock_agent_test.go @@ -53,6 +53,63 @@ func TestParseModelFromArgs(t *testing.T) { } } +func TestStripKandevSystem(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "no tags", + input: "/slow 10s", + want: "/slow 10s", + }, + { + name: "tags appended after user text (plan mode)", + input: "/slow 10s\n\n\nACTIVE DOCUMENT: editing plan\n", + want: "/slow 10s", + }, + { + name: "multiple tag blocks appended", + input: "/slow 5s\n\n\nDOC context\n\n\n\nFILE context\n", + want: "/slow 5s", + }, + { + name: "tags prepended before user text (backend system context)", + input: "\nKANDEV CONTEXT\n\n\ne2e:delay(3000)\ne2e:message(\"hello\")", + want: "e2e:delay(3000)\ne2e:message(\"hello\")", + }, + { + name: "tags both prepended and appended", + input: "\nSYS\n\n\n/slow 5s\n\n\nPLAN\n", + want: "/slow 5s", + }, + { + name: "only tags, no user text", + input: "\nsome context\n", + want: "", + }, + { + name: "empty string", + input: "", + want: "", + }, + { + name: "whitespace before tags", + input: " hello world \n\nctx", + want: "hello world", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripKandevSystem(tt.input) + if got != tt.want { + t.Errorf("stripKandevSystem(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + func TestDelayRange(t *testing.T) { tests := []struct { model string diff --git a/apps/web/components/task/chat/chat-input-body.tsx b/apps/web/components/task/chat/chat-input-body.tsx index 1b0d3848a5..e087a8e28e 100644 --- a/apps/web/components/task/chat/chat-input-body.tsx +++ b/apps/web/components/task/chat/chat-input-body.tsx @@ -121,6 +121,10 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { const { isSending, onCancel, contextCount, contextPopoverOpen, setContextPopoverOpen } = p; const { contextFiles, onImplementPlan, onEnhancePrompt, isEnhancingPrompt } = p; const { isUtilityConfigured, hideSessionsDropdown, minimalToolbar, hidePlanMode } = p; + // Exclude auto-added plan context from the count — it's always present in plan mode + // and shouldn't by itself enable the send button. + const userContextCount = planContextEnabled ? Math.max(0, contextCount - 1) : contextCount; + const hasContent = value.trim().length > 0 || userContextCount > 0; // Block submit while enhancing prompt, but keep editor editable for programmatic updates const wrappedSubmit = isEnhancingPrompt ? () => {} : handleSubmitWithReset; const handleAttachFiles = useCallback(() => fileInputRef.current?.click(), [fileInputRef]); @@ -162,6 +166,7 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { taskTitle={taskTitle} taskDescription={taskDescription} isAgentBusy={isAgentBusy} + hasContent={hasContent} isDisabled={isDisabled} isSending={isSending} onCancel={onCancel} diff --git a/apps/web/components/task/chat/chat-input-toolbar.tsx b/apps/web/components/task/chat/chat-input-toolbar.tsx index d2389d2234..a5b29251f0 100644 --- a/apps/web/components/task/chat/chat-input-toolbar.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useRef, useState, useCallback, type ReactNode } from "react"; +import { memo, useRef, useState, type ReactNode } from "react"; import { IconArrowUp, IconChevronsLeft, @@ -12,25 +12,13 @@ import { IconPlugConnectedX, IconPaperclip, IconRocket, - IconRotateClockwise2, IconSparkles, } from "@tabler/icons-react"; import { GridSpinner } from "@/components/grid-spinner"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@kandev/ui/alert-dialog"; import { cn } from "@/lib/utils"; import { useToolbarCollapsed } from "@/hooks/use-toolbar-collapsed"; -import { getWebSocketClient } from "@/lib/ws/connection"; import { SHORTCUTS } from "@/lib/keyboard/constants"; import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip"; import { TokenUsageDisplay } from "@/components/task/chat/token-usage-display"; @@ -38,6 +26,7 @@ import { SessionsDropdown } from "@/components/task/sessions-dropdown"; import { ModelSelector } from "@/components/task/model-selector"; import { ModeSelector } from "@/components/task/mode-selector"; import { ContextPopover } from "./context-popover"; +import { ResetContextButton } from "./reset-context-button"; import type { ContextFile } from "@/lib/state/context-files-store"; export type ChatInputToolbarProps = { @@ -50,6 +39,8 @@ export type ChatInputToolbarProps = { taskTitle?: string; taskDescription: string; isAgentBusy: boolean; + /** Whether the input has content to send (text, comments, or context) */ + hasContent?: boolean; isDisabled: boolean; isSending: boolean; onCancel: () => void; @@ -81,6 +72,7 @@ export type ChatInputToolbarProps = { type SubmitButtonProps = { isAgentBusy: boolean; + hasContent: boolean; isDisabled: boolean; isSending: boolean; planModeEnabled: boolean; @@ -89,8 +81,15 @@ type SubmitButtonProps = { submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS]; }; +function submitTooltipDescription(isAgentBusy: boolean, planModeEnabled: boolean) { + if (isAgentBusy) return "Queue message"; + if (planModeEnabled) return "Request plan changes"; + return undefined; +} + function SubmitButton({ isAgentBusy, + hasContent, isDisabled, isSending, planModeEnabled, @@ -98,40 +97,53 @@ function SubmitButton({ onSubmit, submitShortcut, }: SubmitButtonProps) { + // When agent is busy and there's nothing to send, show only the cancel button. + // When there's content to queue, show both cancel and send buttons side-by-side. + const showSendButton = !isAgentBusy || hasContent; return ( - - {isAgentBusy ? ( - - ) : ( - +
+ {isAgentBusy && ( + + + + + Cancel agent + )} - + {showSendButton && ( + + + + )} +
); } @@ -251,74 +263,6 @@ function McpIndicator({ mcpServers }: { mcpServers: string[] }) { ); } -function ResetContextButton({ sessionId }: { sessionId: string }) { - const [confirmOpen, setConfirmOpen] = useState(false); - const [isResetting, setIsResetting] = useState(false); - - const handleReset = useCallback(async () => { - setIsResetting(true); - try { - const client = getWebSocketClient(); - if (!client) return; - await client.request("session.reset_context", { session_id: sessionId }, 30000); - } catch (error) { - console.error("Failed to reset agent context:", error); - } finally { - setIsResetting(false); - setConfirmOpen(false); - } - }, [sessionId]); - - return ( - <> - - - - - - Reset agent context — clears conversation history, preserves workspace - - - - - - Reset agent context? - - This will clear the agent's conversation history and start a fresh context. Your - workspace, files, and git state will be preserved. - - - - Cancel - - {isResetting ? "Resetting..." : "Reset Context"} - - - - - - ); -} - type ToolbarItemConfig = { id: string; collapsible: boolean; @@ -360,6 +304,7 @@ function ToolbarRightSection({ sessionId, planModeEnabled, isAgentBusy, + hasContent, onImplementPlan, isDisabled, isSending, @@ -372,6 +317,7 @@ function ToolbarRightSection({ sessionId: string | null; planModeEnabled: boolean; isAgentBusy: boolean; + hasContent: boolean; onImplementPlan?: () => void; isDisabled: boolean; isSending: boolean; @@ -389,6 +335,7 @@ function ToolbarRightSection({
void }) { function MinimalToolbar({ isAgentBusy, + hasContent, isDisabled, isSending, onCancel, @@ -510,13 +458,14 @@ function MinimalToolbar({ submitKey = "cmd_enter", }: Pick< ChatInputToolbarProps, - "isAgentBusy" | "isDisabled" | "isSending" | "onCancel" | "onSubmit" | "submitKey" + "isAgentBusy" | "hasContent" | "isDisabled" | "isSending" | "onCancel" | "onSubmit" | "submitKey" >) { const submitShortcut = submitKey === "enter" ? SHORTCUTS.SUBMIT_ENTER : SHORTCUTS.SUBMIT; return (
... blocks from display text. */ +function stripSystemTags(text: string): string { + return text.replace(/[\s\S]*?<\/kandev-system>/g, "").trim(); +} + export type QueuedMessageIndicatorHandle = { startEdit: () => void; }; @@ -192,7 +197,9 @@ export const QueuedMessageIndicator = forwardRef< if (!isVisible) return null; - const displayContent = content.length > 80 ? content.substring(0, 80) + "..." : content; + const visibleContent = stripSystemTags(content); + const displayContent = + visibleContent.length > 80 ? visibleContent.substring(0, 80) + "..." : visibleContent; return (
{ + setIsResetting(true); + try { + const client = getWebSocketClient(); + if (!client) return; + await client.request("session.reset_context", { session_id: sessionId }, 30000); + } catch (error) { + console.error("Failed to reset agent context:", error); + } finally { + setIsResetting(false); + setConfirmOpen(false); + } + }, [sessionId]); + + return ( + <> + + + + + + Reset agent context — clears conversation history, preserves workspace + + + + + + Reset agent context? + + This will clear the agent's conversation history and start a fresh context. Your + workspace, files, and git state will be preserved. + + + + Cancel + + {isResetting ? "Resetting..." : "Reset Context"} + + + + + + ); +} diff --git a/apps/web/e2e/tests/quick-chat-queue.spec.ts b/apps/web/e2e/tests/quick-chat-queue.spec.ts index 1fe64e7b3a..8bfa59e346 100644 --- a/apps/web/e2e/tests/quick-chat-queue.spec.ts +++ b/apps/web/e2e/tests/quick-chat-queue.spec.ts @@ -2,9 +2,9 @@ import { type Locator, type Page } from "@playwright/test"; import { test, expect } from "../fixtures/test-base"; /** - * Quick Chat queued message E2E test. + * Quick Chat queued message E2E tests. * - * Isolated into its own file because the agent stays busy for 10s during the test, + * Isolated into their own file because the agent stays busy for 10s during the test, * which can interfere with other quick chat tests sharing the same backend worker. */ @@ -35,6 +35,31 @@ async function openQuickChatWithAgent(page: Page): Promise { return dialog; } +/** + * Type text into the TipTap editor while the agent is busy. + * fill() silently fails on TipTap when the busy placeholder is shown, + * so we retry clicking and typing until text appears in the editor. + */ +async function typeWhileBusy(page: Page, editor: Locator, text: string): Promise { + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await editor.scrollIntoViewIfNeeded(); + for (let attempt = 0; attempt < 3; attempt++) { + const box = await editor.boundingBox(); + if (!box) throw new Error("Editor bounding box not found"); + await page.mouse.click(box.x + 20, box.y + box.height / 2); + await page.waitForTimeout(200); + await page.keyboard.type(text); + await page.waitForTimeout(100); + const content = await editor.textContent(); + if (content?.includes(text)) return; + // Text wasn't entered; select all and clear for retry + await page.keyboard.press(`${modifier}+a`); + await page.keyboard.press("Backspace"); + await page.waitForTimeout(200); + } + throw new Error(`Failed to type "${text}" into editor after 3 attempts`); +} + // 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 }); @@ -59,23 +84,7 @@ test("quick chat queued message indicator appears and message executes after age }); await testPage.waitForTimeout(500); - // Type a queued message: fill() silently fails on TipTap when the busy placeholder - // is shown. Retry clicking and typing until text appears in the editor. - await editor.scrollIntoViewIfNeeded(); - for (let attempt = 0; attempt < 3; attempt++) { - const box = await editor.boundingBox(); - if (!box) throw new Error("Editor bounding box not found"); - await testPage.mouse.click(box.x + 20, box.y + box.height / 2); - await testPage.waitForTimeout(200); - await testPage.keyboard.type("hello world"); - await testPage.waitForTimeout(100); - const text = await editor.textContent(); - if (text?.includes("hello world")) break; - // Text wasn't entered; select all and clear for retry - await testPage.keyboard.press(`${modifier}+a`); - await testPage.keyboard.press("Backspace"); - await testPage.waitForTimeout(200); - } + await typeWhileBusy(testPage, editor, "hello world"); await testPage.keyboard.press(`${modifier}+Enter`); // Verify the queued message indicator with cancel button appears. @@ -93,3 +102,48 @@ test("quick chat queued message indicator appears and message executes after age timeout: 30_000, }); }); + +test("quick chat 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 editor.click(); + await editor.fill("/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 testPage.waitForTimeout(500); + + // 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 queued message indicator with cancel button appears. + const cancelBtn = dialog.getByTitle("Cancel queued message"); + await expect(cancelBtn).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. + // Allow extra time: 10s slow + agent turn overhead + queue execution. + await expect(dialog.locator('[data-placeholder="Continue working on the task..."]')).toBeVisible({ + timeout: 60_000, + }); +}); diff --git a/apps/web/e2e/tests/task-session-queue.spec.ts b/apps/web/e2e/tests/task-session-queue.spec.ts new file mode 100644 index 0000000000..4a540263e0 --- /dev/null +++ b/apps/web/e2e/tests/task-session-queue.spec.ts @@ -0,0 +1,152 @@ +import { 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 { SessionPage } from "../pages/session-page"; + +/** + * Task session detail page — queued message tests. + * + * Verifies that users can queue messages via button click while the agent is + * busy, including with plan mode enabled. Uses /slow to keep the agent running. + */ + +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 expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + + return session; +} + +/** + * Type text into the TipTap editor while the agent is busy. + * fill() silently fails on TipTap when the busy placeholder is shown, + * so we retry clicking and typing until text appears in the editor. + */ +async function typeWhileBusy(page: Page, editor: import("@playwright/test").Locator, text: string) { + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await editor.scrollIntoViewIfNeeded(); + for (let attempt = 0; attempt < 3; attempt++) { + const box = await editor.boundingBox(); + if (!box) throw new Error("Editor bounding box not found"); + await page.mouse.click(box.x + 20, box.y + box.height / 2); + await page.waitForTimeout(200); + await page.keyboard.type(text); + await page.waitForTimeout(100); + const content = await editor.textContent(); + if (content?.includes(text)) return; + await page.keyboard.press(`${modifier}+a`); + await page.keyboard.press("Backspace"); + await page.waitForTimeout(200); + } + throw new Error(`Failed to type "${text}" into editor after 3 attempts`); +} + +test.describe("Task session queue", () => { + test.describe.configure({ retries: 1 }); + + 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 testPage.waitForTimeout(500); + + // 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(); + + // Verify the queued message indicator appears. + await expect(testPage.getByTitle("Cancel queued message")).toBeVisible({ timeout: 10_000 }); + + // Wait for the queued message to auto-execute and agent to become idle. + await expect(session.idleInput()).toBeVisible({ timeout: 30_000 }); + }); + + 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 testPage.waitForTimeout(500); + + // 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(); + + // Verify the queued message indicator shows clean text (no system tags). + const queueIndicator = testPage.getByTitle("Cancel queued message").locator(".."); + 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 }); + }); +});