diff --git a/apps/web/components/task/chat/chat-input-area.test.tsx b/apps/web/components/task/chat/chat-input-area.test.tsx index 2cb29cc9fc..b8db6f6aef 100644 --- a/apps/web/components/task/chat/chat-input-area.test.tsx +++ b/apps/web/components/task/chat/chat-input-area.test.tsx @@ -70,7 +70,7 @@ vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: () => ({ send: vi.fn() }), })); -import { useSubmitHandler } from "./chat-input-area"; +import { resolveInputPlaceholder, useSubmitHandler } from "./chat-input-area"; beforeEach(() => { handleSendMessageMock.mockReset(); @@ -83,31 +83,40 @@ afterEach(() => { vi.clearAllMocks(); }); -describe("useSubmitHandler", () => { - function panelState(overrides = {}) { - return { - resolvedSessionId: "session-1", - taskId: "task-1", - sessionModel: null, - activeModel: null, - isAgentBusy: false, - activeDocument: null, - planComments: [], - pendingPRFeedback: [], - walkthroughComments: [], - contextFiles: [], - prompts: [], - markCommentsSent: vi.fn(), - clearSessionPlanComments: vi.fn(), - handleClearPRFeedback: vi.fn(), - handleClearWalkthroughComments: vi.fn(), - clearEphemeral: vi.fn(), - addContextFile: vi.fn(), - planModeEnabled: false, - ...overrides, - } as never; - } +function panelState(overrides = {}) { + return { + resolvedSessionId: "session-1", + taskId: "task-1", + sessionModel: null, + activeModel: null, + isAgentBusy: false, + activeDocument: null, + planComments: [], + pendingPRFeedback: [], + walkthroughComments: [], + messageComments: [], + contextFiles: [], + prompts: [], + markCommentsSent: vi.fn(), + clearSessionPlanComments: vi.fn(), + handleClearPRFeedback: vi.fn(), + handleClearWalkthroughComments: vi.fn(), + clearEphemeral: vi.fn(), + addContextFile: vi.fn(), + planModeEnabled: false, + ...overrides, + } as never; +} +describe("resolveInputPlaceholder", () => { + it("invites queueing while a clarification remains pending", () => { + expect(resolveInputPlaceholder(false, undefined, false, true, false)).toBe( + "Queue instructions while the question is pending...", + ); + }); +}); + +describe("useSubmitHandler", () => { it("shows a toast when sending fails", async () => { vi.spyOn(console, "error").mockImplementation(() => undefined); handleSendMessageMock.mockRejectedValueOnce(new Error("WebSocket request timed out")); @@ -125,6 +134,32 @@ describe("useSubmitHandler", () => { }); }); + it("queues through the shared handler instead of preview onSend while clarification is pending", async () => { + const onSend = vi.fn(); + const { result } = renderHook(() => + useSubmitHandler(panelState({ pendingClarification: { id: "clarification-1" } }), onSend), + ); + + await act(async () => { + await result.current.handleSubmit({ message: "queued instruction" }); + }); + + expect(onSend).not.toHaveBeenCalled(); + expect(handleSendMessageMock).toHaveBeenCalledWith({ message: "queued instruction" }); + }); + + it("uses preview onSend when no clarification is pending", async () => { + const onSend = vi.fn(); + const { result } = renderHook(() => useSubmitHandler(panelState(), onSend)); + + await act(async () => { + await result.current.handleSubmit({ message: "direct preview message" }); + }); + + expect(onSend).toHaveBeenCalledWith({ message: "direct preview message" }); + expect(handleSendMessageMock).not.toHaveBeenCalled(); + }); + it("reports deterministic preflight failures as not sent", async () => { vi.spyOn(console, "error").mockImplementation(() => undefined); const error = Object.assign(new Error("Connection unavailable. Reconnect and try again."), { diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index 1370d8631e..7e11805a8b 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -78,7 +78,7 @@ export function buildSubmitMessage({ return finalMessage; } -function resolveInputPlaceholder( +export function resolveInputPlaceholder( isAgentBusy: boolean, activeDocumentType: string | undefined, planModeEnabled: boolean, @@ -86,7 +86,7 @@ function resolveInputPlaceholder( needsRecovery: boolean, ): string { if (needsRecovery) return "Choose a recovery option above to continue..."; - if (hasClarification) return "Answer the question above to continue..."; + if (hasClarification) return "Queue instructions while the question is pending..."; if (isAgentBusy) return "Queue instructions to the agent..."; if (activeDocumentType === "file") return "Continue working on the file..."; if (planModeEnabled) return "Continue working on the plan..."; @@ -141,6 +141,7 @@ function usePanelMessageHandler(panelState: ReturnType sessionModel, activeModel, isAgentBusy, + pendingClarification, activeDocument, planComments, contextFiles, @@ -153,6 +154,7 @@ function usePanelMessageHandler(panelState: ReturnType activeModel, planModeEnabled: panelState.planModeEnabled, isAgentBusy, + hasPendingClarification: !!pendingClarification, activeDocument, planComments, contextFiles, @@ -180,6 +182,7 @@ export function useSubmitHandler( clearEphemeral, addContextFile, planModeEnabled, + pendingClarification, } = panelState; const { handleSendMessage } = usePanelMessageHandler(panelState); @@ -197,7 +200,7 @@ export function useSubmitHandler( messageComments, }); const outbound = { ...payload, message: finalMessage }; - if (onSend) { + if (onSend && !pendingClarification) { // Expand task mentions because onSend bypasses useMessageHandler.buildFinalMessage. const taskCtx = payload.inlineTaskMentions?.length ? buildTaskMentionsContext(payload.inlineTaskMentions, storeApi.getState()) @@ -229,6 +232,7 @@ export function useSubmitHandler( [ isSending, onSend, + pendingClarification, storeApi, handleSendMessage, markCommentsSent, @@ -431,6 +435,10 @@ function useChatInputDerived( return { planActions, executor, placeholder }; } +function canManuallyDrainQueue(pendingClarification: unknown, sessionState: string | null) { + return !pendingClarification && (sessionState === "WAITING_FOR_INPUT" || sessionState === "IDLE"); +} + export function ChatInputArea({ chatInputRef, clarificationKey, @@ -450,7 +458,7 @@ export function ChatInputArea({ const { resolvedSessionId, taskId, isAgentBusy, needsRecovery, planModeEnabled } = panelState; const composerWorkspaceId = useComposerWorkspaceId(resolvedSessionId, taskId); const sessionState = panelState.session?.state ?? null; - const canDrainQueue = sessionState === "WAITING_FOR_INPUT" || sessionState === "IDLE"; + const canDrainQueue = canManuallyDrainQueue(panelState.pendingClarification, sessionState); const { planActions, executor, placeholder } = useChatInputDerived( panelState, chatInputRef, diff --git a/apps/web/components/task/chat/chat-input-body.test.tsx b/apps/web/components/task/chat/chat-input-body.test.tsx index 0599395490..359db5d85b 100644 --- a/apps/web/components/task/chat/chat-input-body.test.tsx +++ b/apps/web/components/task/chat/chat-input-body.test.tsx @@ -3,6 +3,7 @@ import { cleanup, render, screen } from "@testing-library/react"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { afterEach, describe, expect, it, vi } from "vitest"; import { ChatInputBody, type ChatInputBodyProps } from "./chat-input-body"; +import { shouldShowCancelAgent } from "./chat-input-container"; const tipTapPropsMock = vi.hoisted(() => vi.fn()); @@ -53,7 +54,6 @@ function props(overrides: Partial = {}): ChatInputBodyProps inputPlaceholder: "Ask to make changes", isDisabled: false, submitDisabled: false, - hasClarification: false, planModeEnabled: false, planModeAvailable: true, mcpServers: [], @@ -80,6 +80,20 @@ function props(overrides: Partial = {}): ChatInputBodyProps } describe("ChatInputBody", () => { + it("keeps the regular editor enabled while a structured clarification is pending", () => { + render( + + + , + ); + + expect(tipTapPropsMock).toHaveBeenCalledWith(expect.objectContaining({ disabled: false })); + }); + it("keeps entity references explicitly disabled unless the chat surface enables them", () => { render( @@ -115,3 +129,32 @@ describe("ChatInputBody", () => { expect(screen.getByTestId("mock-tiptap-input").parentElement?.className).not.toContain("pr-28"); }); }); + +describe("shouldShowCancelAgent", () => { + const detachedClarification = { metadata: { agent_disconnected: true } } as never; + const attachedClarification = {} as never; + + it.each([ + [ + "suppresses cancel for detached clarification while session is RUNNING", + true, + detachedClarification, + false, + ], + [ + "suppresses cancel for detached clarification while session is WAITING", + false, + detachedClarification, + false, + ], + [ + "keeps cancel for clarification without detached metadata", + false, + attachedClarification, + true, + ], + ["keeps cancel for a running session without clarification", true, null, true], + ])("%s", (_name, isAgentBusy, pendingClarification, expected) => { + expect(shouldShowCancelAgent(isAgentBusy, pendingClarification)).toBe(expected); + }); +}); diff --git a/apps/web/components/task/chat/chat-input-body.tsx b/apps/web/components/task/chat/chat-input-body.tsx index 4546cb7b47..9f38f6a0f0 100644 --- a/apps/web/components/task/chat/chat-input-body.tsx +++ b/apps/web/components/task/chat/chat-input-body.tsx @@ -20,7 +20,6 @@ export type ChatInputEditorAreaProps = { isDisabled: boolean; submitDisabled: boolean; submitDisabledReason?: string; - hasClarification: boolean; planModeEnabled: boolean; planModeAvailable: boolean; mcpServers: string[]; @@ -41,6 +40,7 @@ export type ChatInputEditorAreaProps = { hideAgentControls?: boolean; hidePlanMode?: boolean; isAgentBusy: boolean; + canCancelAgent?: boolean; onPlanModeChange: (enabled: boolean) => void; taskTitle?: string; taskDescription: string; @@ -125,7 +125,7 @@ function FileInput({ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { const { inputRef, value, handleChange, handleSubmitWithReset, inputPlaceholder } = p; - const { isDisabled, hasClarification, planModeEnabled, planModeAvailable, mcpServers } = p; + const { isDisabled, planModeEnabled, planModeAvailable, mcpServers } = p; const { submitKey, setIsInputFocused, sessionId, taskId, planContextEnabled } = p; const { onAddContextFile, onToggleContextFile, addFiles, fileInputRef } = p; const { showRequestChangesTooltip, isAgentBusy, onPlanModeChange, taskTitle, taskDescription } = @@ -155,7 +155,7 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { onChange={handleChange} onSubmit={wrappedSubmit} placeholder={inputPlaceholder} - disabled={isDisabled || hasClarification} + disabled={isDisabled} planModeEnabled={planModeEnabled} submitKey={submitKey} onFocus={() => setIsInputFocused(true)} @@ -182,6 +182,7 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) { taskTitle={taskTitle} taskDescription={taskDescription} isAgentBusy={isAgentBusy} + canCancelAgent={p.canCancelAgent} hasContent={hasContent} isDisabled={p.submitDisabled} submitDisabledReason={p.submitDisabledReason} diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx index fec49c368b..bb2d6f2f2f 100644 --- a/apps/web/components/task/chat/chat-input-container.tsx +++ b/apps/web/components/task/chat/chat-input-container.tsx @@ -7,7 +7,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { NewSessionDialog } from "@/components/task/new-session-dialog"; import { useAppStore } from "@/components/state-provider"; import type { ContextFile } from "@/lib/state/context-files-store"; -import type { Message } from "@/lib/types/http"; +import type { ClarificationRequestMetadata, Message } from "@/lib/types/http"; import type { DiffComment } from "@/lib/diff/types"; import type { TaskMentionData } from "@/hooks/use-inline-mention"; import type { EntityReference } from "@/lib/types/entity-reference"; @@ -269,6 +269,15 @@ type EnhancePromptExtras = { onVoiceAutoSend?: () => void; }; +export function shouldShowCancelAgent( + isAgentBusy: boolean, + pendingClarification: Message | null | undefined, +): boolean { + if (!pendingClarification) return isAgentBusy; + return !(pendingClarification.metadata as ClarificationRequestMetadata | undefined) + ?.agent_disconnected; +} + function buildEditorAreaProps( s: ContainerState, p: NormalizedChatInputProps, @@ -283,7 +292,6 @@ function buildEditorAreaProps( isDisabled: s.isDisabled, submitDisabled: s.submitDisabled, submitDisabledReason: s.submitDisabledReason, - hasClarification: s.hasClarification, planModeEnabled: p.planModeEnabled, planModeAvailable: p.planModeAvailable ?? true, mcpServers: p.mcpServers ?? [], @@ -299,7 +307,8 @@ function buildEditorAreaProps( addFiles: s.addFiles, fileInputRef: s.fileInputRef, showRequestChangesTooltip: p.showRequestChangesTooltip, - isAgentBusy: p.isAgentBusy, + isAgentBusy: p.isAgentBusy || !!(p.pendingClarification && p.onClarificationResolved), + canCancelAgent: shouldShowCancelAgent(p.isAgentBusy, p.pendingClarification), onPlanModeChange: p.onPlanModeChange, taskTitle: p.taskTitle, taskDescription: p.taskDescription, diff --git a/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx b/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx index fc529829f5..6a8006d9c0 100644 --- a/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx @@ -141,6 +141,7 @@ function DesktopRightSection(props: { hideAgentControls: boolean; planModeEnabled: boolean; isAgentBusy: boolean; + canCancelAgent?: boolean; hasContent: boolean; onImplementPlan?: (fresh: boolean) => void; isDisabled: boolean; @@ -178,6 +179,7 @@ function DesktopRightSection(props: { )} void; isAgentBusy: boolean; + canCancelAgent?: boolean; hasContent: boolean; onImplementPlan?: (fresh: boolean) => void; onEnhancePrompt?: () => void; @@ -181,6 +182,7 @@ export function MobileChatInputToolbar(props: MobileToolbarProps) { )} - {isAgentBusy && ( + {canCancelAgent && (