fix(web): show both send and cancel buttons when agent is busy (#487)

This commit is contained in:
Carlos Florêncio
2026-03-29 22:13:39 +01:00
committed by GitHub
parent acd2b9770d
commit 3a50cf42e5
10 changed files with 507 additions and 149 deletions
+1 -1
View File
@@ -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
+8 -8
View File
@@ -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 <kandev-system>...</kandev-system> blocks injected by
// injectKandevContext so that /e2e: routing works regardless of system prompt wrapping.
// stripKandevSystem removes all <kandev-system>...</kandev-system> 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 = "</kandev-system>"
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(`<kandev-system>[\s\S]*?</kandev-system>`)
// handlePrompt routes a user prompt to the appropriate sequence generator.
func handlePrompt(e *emitter, prompt, model string) {
prompt = strings.TrimSpace(prompt)
+52 -6
View File
@@ -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
@@ -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<kandev-system>\nACTIVE DOCUMENT: editing plan\n</kandev-system>",
want: "/slow 10s",
},
{
name: "multiple tag blocks appended",
input: "/slow 5s\n\n<kandev-system>\nDOC context\n</kandev-system>\n\n<kandev-system>\nFILE context\n</kandev-system>",
want: "/slow 5s",
},
{
name: "tags prepended before user text (backend system context)",
input: "<kandev-system>\nKANDEV CONTEXT\n</kandev-system>\n\ne2e:delay(3000)\ne2e:message(\"hello\")",
want: "e2e:delay(3000)\ne2e:message(\"hello\")",
},
{
name: "tags both prepended and appended",
input: "<kandev-system>\nSYS\n</kandev-system>\n\n/slow 5s\n\n<kandev-system>\nPLAN\n</kandev-system>",
want: "/slow 5s",
},
{
name: "only tags, no user text",
input: "<kandev-system>\nsome context\n</kandev-system>",
want: "",
},
{
name: "empty string",
input: "",
want: "",
},
{
name: "whitespace before tags",
input: " hello world \n\n<kandev-system>ctx</kandev-system>",
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
@@ -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}
@@ -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 (
<KeyboardShortcutTooltip
shortcut={submitShortcut}
description={planModeEnabled ? "Request plan changes" : undefined}
enabled={!isAgentBusy && !isDisabled}
>
{isAgentBusy ? (
<Button
type="button"
variant="secondary"
size="icon"
className="h-7 w-7 rounded-full cursor-pointer bg-destructive/10 text-destructive hover:bg-destructive/20"
onClick={onCancel}
>
<IconPlayerPauseFilled className="h-3.5 w-3.5" />
</Button>
) : (
<Button
type="button"
variant="default"
size="icon"
className={cn(
"h-7 w-7 rounded-full cursor-pointer",
planModeEnabled && "bg-violet-600 hover:bg-violet-500",
)}
disabled={isDisabled}
onClick={onSubmit}
>
{isSending && <GridSpinner className="text-primary-foreground" />}
{!isSending && planModeEnabled && <IconFileTextSpark className="h-4 w-4" />}
{!isSending && !planModeEnabled && <IconArrowUp className="h-4 w-4" />}
</Button>
<div className="flex items-center gap-1">
{isAgentBusy && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="secondary"
size="icon"
className="h-7 w-7 rounded-full cursor-pointer bg-destructive/10 text-destructive hover:bg-destructive/20"
onClick={onCancel}
data-testid="cancel-agent-button"
>
<IconPlayerPauseFilled className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Cancel agent</TooltipContent>
</Tooltip>
)}
</KeyboardShortcutTooltip>
{showSendButton && (
<KeyboardShortcutTooltip
shortcut={submitShortcut}
description={submitTooltipDescription(isAgentBusy, planModeEnabled)}
enabled={!isDisabled}
>
<Button
type="button"
variant="default"
size="icon"
className={cn(
"h-7 w-7 rounded-full cursor-pointer",
planModeEnabled && "bg-violet-600 hover:bg-violet-500",
)}
disabled={isDisabled}
onClick={onSubmit}
data-testid="submit-message-button"
>
{isSending && <GridSpinner className="text-primary-foreground" />}
{!isSending && planModeEnabled && <IconFileTextSpark className="h-4 w-4" />}
{!isSending && !planModeEnabled && <IconArrowUp className="h-4 w-4" />}
</Button>
</KeyboardShortcutTooltip>
)}
</div>
);
}
@@ -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 (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 cursor-pointer hover:bg-muted/40 text-muted-foreground"
onClick={() => setConfirmOpen(true)}
disabled={isResetting}
data-testid="reset-context-button"
>
{isResetting ? (
<GridSpinner className="h-4 w-4" />
) : (
<IconRotateClockwise2 className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
Reset agent context — clears conversation history, preserves workspace
</TooltipContent>
</Tooltip>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset agent context?</AlertDialogTitle>
<AlertDialogDescription>
This will clear the agent&apos;s conversation history and start a fresh context. Your
workspace, files, and git state will be preserved.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleReset}
disabled={isResetting}
className="cursor-pointer"
data-testid="reset-context-confirm"
>
{isResetting ? "Resetting..." : "Reset Context"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
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({
<div className="ml-1">
<SubmitButton
isAgentBusy={isAgentBusy}
hasContent={hasContent}
isDisabled={isDisabled}
isSending={isSending}
planModeEnabled={planModeEnabled}
@@ -503,6 +450,7 @@ function AttachFilesButton({ onClick }: { onClick: () => 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 (
<div className="flex items-center justify-end gap-1 px-1 pt-0 pb-0.5 border-t border-border">
<SubmitButton
isAgentBusy={isAgentBusy}
hasContent={hasContent ?? false}
isDisabled={isDisabled}
isSending={isSending}
planModeEnabled={false}
@@ -553,6 +502,7 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp
return (
<MinimalToolbar
isAgentBusy={props.isAgentBusy}
hasContent={props.hasContent}
isDisabled={props.isDisabled}
isSending={props.isSending}
onCancel={props.onCancel}
@@ -621,6 +571,7 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp
sessionId={props.sessionId}
planModeEnabled={props.planModeEnabled}
isAgentBusy={props.isAgentBusy}
hasContent={props.hasContent ?? false}
onImplementPlan={props.onImplementPlan}
isDisabled={props.isDisabled}
isSending={props.isSending}
@@ -7,6 +7,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@/lib/utils";
import { Textarea } from "@kandev/ui/textarea";
/** Strip internal <kandev-system>...</kandev-system> blocks from display text. */
function stripSystemTags(text: string): string {
return text.replace(/<kandev-system>[\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 (
<div
@@ -0,0 +1,86 @@
"use client";
import { useState, useCallback } from "react";
import { IconRotateClockwise2 } 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 { getWebSocketClient } from "@/lib/ws/connection";
export 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 (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 cursor-pointer hover:bg-muted/40 text-muted-foreground"
onClick={() => setConfirmOpen(true)}
disabled={isResetting}
data-testid="reset-context-button"
>
{isResetting ? (
<GridSpinner className="h-4 w-4" />
) : (
<IconRotateClockwise2 className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
Reset agent context — clears conversation history, preserves workspace
</TooltipContent>
</Tooltip>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset agent context?</AlertDialogTitle>
<AlertDialogDescription>
This will clear the agent&apos;s conversation history and start a fresh context. Your
workspace, files, and git state will be preserved.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleReset}
disabled={isResetting}
className="cursor-pointer"
data-testid="reset-context-confirm"
>
{isResetting ? "Resetting..." : "Reset Context"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+73 -19
View File
@@ -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<Locator> {
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<void> {
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,
});
});
@@ -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<SessionPage> {
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 });
});
});