fix(web): show both send and cancel buttons when agent is busy (#487)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'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'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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user