diff --git a/src/browser/components/ChatInput/SendModeDropdown.tsx b/src/browser/components/ChatInput/SendModeDropdown.tsx new file mode 100644 index 0000000000..f0f4e0b1ba --- /dev/null +++ b/src/browser/components/ChatInput/SendModeDropdown.tsx @@ -0,0 +1,105 @@ +import React, { useEffect, useRef, useState } from "react"; +import { ChevronDown } from "lucide-react"; +import { Button } from "../ui/button"; +import { formatKeybind } from "@/browser/utils/ui/keybinds"; +import { cn } from "@/common/lib/utils"; +import type { QueueDispatchMode } from "./types"; +import { SEND_DISPATCH_MODES } from "./sendDispatchModes"; + +interface SendModeDropdownProps { + onSelect: (mode: QueueDispatchMode) => void; + triggerClassName?: string; + disabled?: boolean; +} + +export const SendModeDropdown: React.FC = (props) => { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!isOpen) { + return; + } + + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current?.contains(event.target as Node)) { + return; + } + setIsOpen(false); + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen]); + + useEffect(() => { + if (!isOpen) { + return; + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") { + return; + } + + event.preventDefault(); + setIsOpen(false); + }; + + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [isOpen]); + + useEffect(() => { + if (!props.disabled) { + return; + } + + setIsOpen(false); + }, [props.disabled]); + + const handleSelect = (mode: QueueDispatchMode) => { + props.onSelect(mode); + setIsOpen(false); + }; + + return ( +
+ + + {isOpen && ( +
+ {SEND_DISPATCH_MODES.map((entry) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index ffdd5ab78c..8bf8b5a2b7 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -114,8 +114,9 @@ import type { FilePart, SendMessageOptions } from "@/common/orpc/types"; import { CreationCenterContent } from "./CreationCenterContent"; import { cn } from "@/common/lib/utils"; -import type { ChatInputProps, ChatInputAPI } from "./types"; +import type { ChatInputProps, ChatInputAPI, QueueDispatchMode } from "./types"; import { CreationControls } from "./CreationControls"; +import { SendModeDropdown } from "./SendModeDropdown"; import { CodexOauthWarningBanner } from "./CodexOauthWarningBanner"; import { useCreationWorkspace } from "./useCreationWorkspace"; import { useCoderWorkspace } from "@/browser/hooks/useCoderWorkspace"; @@ -185,6 +186,7 @@ const ChatInputInner: React.FC = (props) => { const editingMessage = variant === "workspace" ? props.editingMessage : undefined; const isStreamStarting = variant === "workspace" ? (props.isStreamStarting ?? false) : false; const isCompacting = variant === "workspace" ? (props.isCompacting ?? false) : false; + const canInterrupt = variant === "workspace" ? (props.canInterrupt ?? false) : false; const [isMobileTouch, setIsMobileTouch] = useState( () => typeof window !== "undefined" && @@ -878,6 +880,9 @@ const ChatInputInner: React.FC = (props) => { !sendInFlightBlocksInput && !coderPresetsLoading && !policyBlocksCreateSend; + // Dispatch-mode choice (send-after-step vs send-after-turn) should track actual + // sendability — not just typed text. canSend already covers text, attachments, and reviews. + const canChooseDispatchMode = canInterrupt && canSend; // User request: this sync effect runs on mount and when defaults/config change. // Only treat *real* agent changes as explicit (origin "agent"); everything else is "sync". @@ -1548,7 +1553,7 @@ const ChatInputInner: React.FC = (props) => { const executeParsedCommand = async ( parsed: ParsedCommand | null, restoreInput: string, - options?: { skipConfirmation?: boolean } + options?: { skipConfirmation?: boolean; queueDispatchMode?: QueueDispatchMode } ): Promise => { if (!parsed) { return false; @@ -1578,6 +1583,12 @@ const ChatInputInner: React.FC = (props) => { } const reviewsData = reviewData; + const dispatchMode = options?.queueDispatchMode ?? "tool-end"; + // Thread dispatch mode into send options so queued command sends stay in sync with normal sends. + const commandSendMessageOptions: SendMessageOptions = { + ...sendMessageOptions, + ...(dispatchMode === "tool-end" ? {} : { queueDispatchMode: dispatchMode }), + }; // Prepare file parts for commands that need to send messages with attachments const commandFileParts = chatAttachmentsToFileParts(attachments, { validate: true }); const commandContext: SlashCommandContext = { @@ -1586,7 +1597,7 @@ const ChatInputInner: React.FC = (props) => { workspaceId: commandWorkspaceId, projectPath: commandProjectPath, openSettings: open, - sendMessageOptions, + sendMessageOptions: commandSendMessageOptions, setInput, setAttachments, setSendingState: (increment: boolean) => setSendingCount((c) => c + (increment ? 1 : -1)), @@ -1618,7 +1629,7 @@ const ChatInputInner: React.FC = (props) => { if (reviewIdsForCheck.length > 0) { props.onCheckReviews?.(reviewIdsForCheck); } - props.onMessageSent?.(); + props.onMessageSent?.(dispatchMode); } } @@ -1733,7 +1744,7 @@ const ChatInputInner: React.FC = (props) => { [setInput] ); - const handleSend = async () => { + const handleSend = async (overrides?: { queueDispatchMode?: QueueDispatchMode }) => { if (!canSend) { return; } @@ -1823,7 +1834,11 @@ const ChatInputInner: React.FC = (props) => { try { const modelOneShot = parsed?.type === "model-oneshot" ? parsed : null; - const commandHandled = modelOneShot ? false : await executeParsedCommand(parsed, input); + const commandHandled = modelOneShot + ? false + : await executeParsedCommand(parsed, input, { + queueDispatchMode: overrides?.queueDispatchMode, + }); if (commandHandled) { return; } @@ -2004,6 +2019,9 @@ const ChatInputInner: React.FC = (props) => { ...(modelOverride ? { model: modelOverride } : {}), ...(thinkingOverride ? { thinkingLevel: thinkingOverride } : {}), ...(modelOneShot ? { skipAiSettingsPersistence: true } : {}), + ...(overrides?.queueDispatchMode + ? { queueDispatchMode: overrides.queueDispatchMode } + : {}), additionalSystemInstructions, editMessageId: editingMessage?.id, fileParts: sendFileParts, @@ -2054,7 +2072,7 @@ const ChatInputInner: React.FC = (props) => { if (editingMessage && props.onCancelEdit) { props.onCancelEdit(); } - props.onMessageSent?.(); + props.onMessageSent?.(overrides?.queueDispatchMode ?? "tool-end"); } } catch (error) { // Handle unexpected errors @@ -2173,6 +2191,12 @@ const ChatInputInner: React.FC = (props) => { } // Handle send message (Shift+Enter for newline is default behavior) + if (matchesKeybind(e, KEYBINDS.SEND_MESSAGE_AFTER_TURN)) { + e.preventDefault(); + void handleSend({ queueDispatchMode: "turn-end" }); + return; + } + if (matchesKeybind(e, KEYBINDS.SEND_MESSAGE)) { // Mobile keyboards should keep Enter for newlines; sending remains button-driven. if (isMobileTouch) { @@ -2492,34 +2516,45 @@ const ChatInputInner: React.FC = (props) => { /> - - - - - - Send message{" "} - - ({formatKeybind(KEYBINDS.SEND_MESSAGE)}) - - - +
+ + + + + + Send message ({formatKeybind(KEYBINDS.SEND_MESSAGE)}) + + + + {variant === "workspace" && ( + { + void handleSend( + mode === "tool-end" ? undefined : { queueDispatchMode: mode } + ); + }} + /> + )} +
diff --git a/src/browser/components/ChatInput/sendDispatchModes.ts b/src/browser/components/ChatInput/sendDispatchModes.ts new file mode 100644 index 0000000000..0df675cd40 --- /dev/null +++ b/src/browser/components/ChatInput/sendDispatchModes.ts @@ -0,0 +1,21 @@ +import { KEYBINDS, type Keybind } from "@/browser/utils/ui/keybinds"; +import type { QueueDispatchMode } from "./types"; + +export interface SendDispatchModeEntry { + mode: QueueDispatchMode; + label: string; + keybind: Keybind; +} + +export const SEND_DISPATCH_MODES: readonly SendDispatchModeEntry[] = [ + { + mode: "tool-end", + label: "Send after step", + keybind: KEYBINDS.SEND_MESSAGE, + }, + { + mode: "turn-end", + label: "Send after turn", + keybind: KEYBINDS.SEND_MESSAGE_AFTER_TURN, + }, +] as const; diff --git a/src/browser/components/ChatInput/types.ts b/src/browser/components/ChatInput/types.ts index 29cda0ce05..5667bfb9b7 100644 --- a/src/browser/components/ChatInput/types.ts +++ b/src/browser/components/ChatInput/types.ts @@ -2,6 +2,9 @@ import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { TelemetryRuntimeType } from "@/common/telemetry/payload"; import type { Review } from "@/common/types/review"; import type { EditingMessageState, PendingUserMessage } from "@/browser/utils/chatEditing"; +import type { SendMessageOptions } from "@/common/orpc/types"; + +export type QueueDispatchMode = NonNullable; export interface ChatInputAPI { focus: () => void; @@ -23,7 +26,7 @@ export interface ChatInputWorkspaceVariant { workspaceId: string; /** Runtime type for the workspace (for telemetry) - no sensitive details like SSH host */ runtimeType?: TelemetryRuntimeType; - onMessageSent?: () => void; + onMessageSent?: (dispatchMode: QueueDispatchMode) => void; onTruncateHistory: (percentage?: number) => Promise; onModelChange?: (model: string) => void; isCompacting?: boolean; diff --git a/src/browser/components/ChatPane.tsx b/src/browser/components/ChatPane.tsx index fc41468833..190fe86f72 100644 --- a/src/browser/components/ChatPane.tsx +++ b/src/browser/components/ChatPane.tsx @@ -26,6 +26,7 @@ import { RetryBarrier } from "./Messages/ChatBarrier/RetryBarrier"; import { PinnedTodoList } from "./PinnedTodoList"; import { VIM_ENABLED_KEY } from "@/common/constants/storage"; import { ChatInput, type ChatInputAPI } from "./ChatInput/index"; +import type { QueueDispatchMode } from "./ChatInput/types"; import { shouldShowInterruptedBarrier, mergeConsecutiveStreamErrors, @@ -548,14 +549,20 @@ export const ChatPane: React.FC = (props) => { setEditingMessage(undefined); }, [setEditingMessage]); - const handleMessageSent = useCallback(() => { - // Auto-background any running foreground bash when user sends a new message - // This prevents the user from waiting for the bash to complete before their message is processed - autoBackgroundOnSend(); + const handleMessageSent = useCallback( + (dispatchMode: QueueDispatchMode = "tool-end") => { + // Only background foreground bashes for "tool-end" sends (Enter). + // "turn-end" sends (Ctrl/Cmd+Enter) let the stream finish naturally — + // backgrounding would disrupt a foreground bash the user wants to complete. + if (dispatchMode === "tool-end") { + autoBackgroundOnSend(); + } - // Enable auto-scroll when user sends a message - setAutoScroll(true); - }, [setAutoScroll, autoBackgroundOnSend]); + // Enable auto-scroll when user sends a message + setAutoScroll(true); + }, + [setAutoScroll, autoBackgroundOnSend] + ); const handleClearHistory = useCallback( async (percentage = 1.0) => { @@ -990,7 +997,7 @@ interface ChatInputPaneProps { onContextSwitchCompact: () => void; onContextSwitchDismiss: () => void; onModelChange?: (model: string) => void; - onMessageSent: () => void; + onMessageSent: (dispatchMode: QueueDispatchMode) => void; onTruncateHistory: (percentage?: number) => Promise; editingMessage: EditingMessageState | undefined; onCancelEdit: () => void; diff --git a/src/browser/components/Settings/sections/KeybindsSection.tsx b/src/browser/components/Settings/sections/KeybindsSection.tsx index 836017f0b0..dadd87756f 100644 --- a/src/browser/components/Settings/sections/KeybindsSection.tsx +++ b/src/browser/components/Settings/sections/KeybindsSection.tsx @@ -8,6 +8,7 @@ const KEYBIND_LABELS: Record = { TOGGLE_AGENT: "Open agent picker", CYCLE_AGENT: "Cycle agent", SEND_MESSAGE: "Send message", + SEND_MESSAGE_AFTER_TURN: "Send after turn", NEW_LINE: "Insert newline", CANCEL: "Cancel / Close modal", CANCEL_EDIT: "Cancel editing message", @@ -100,6 +101,7 @@ const KEYBIND_GROUPS: Array<{ label: string; keys: Array label: "Chat", keys: [ "SEND_MESSAGE", + "SEND_MESSAGE_AFTER_TURN", "NEW_LINE", "FOCUS_CHAT", "FOCUS_INPUT_I", diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 97c89f6d4c..e59838f7ee 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -35,6 +35,7 @@ import { import { applyCompactionOverrides } from "@/browser/utils/messages/compactionOptions"; import { resolveCompactionModel } from "@/browser/utils/messages/compactionModelPreference"; import { normalizeModelInput } from "@/browser/utils/models/normalizeModelInput"; +import type { QueueDispatchMode } from "@/browser/components/ChatInput/types"; import type { ChatAttachment } from "../components/ChatAttachments"; import { dispatchWorkspaceSwitch } from "./workspaceEvents"; import { getRuntimeKey, copyWorkspaceStorage } from "@/common/constants/storage"; @@ -138,7 +139,7 @@ export interface SlashCommandContext extends Omit Promise; resetInputHeight: () => void; /** Callback to trigger message-sent side effects (auto-scroll, auto-background) */ - onMessageSent?: () => void; + onMessageSent?: (dispatchMode: QueueDispatchMode) => void; /** Callback to mark review IDs as checked after successful send */ onCheckReviews?: (reviewIds: string[]) => void; /** Review IDs that are attached (for marking as checked on success) */ diff --git a/src/browser/utils/ui/keybinds.test.ts b/src/browser/utils/ui/keybinds.test.ts index 791e564183..1d0f0b6aae 100644 --- a/src/browser/utils/ui/keybinds.test.ts +++ b/src/browser/utils/ui/keybinds.test.ts @@ -71,6 +71,32 @@ describe("CYCLE_MODEL keybind (Ctrl+/)", () => { }); }); +describe("SEND_MESSAGE_AFTER_TURN keybind (Ctrl/Cmd+Enter)", () => { + it("matches Ctrl+Enter", () => { + globalThis.window = { api: { platform: "linux" } } as unknown as Window & typeof globalThis; + const event = createEvent({ key: "Enter", ctrlKey: true, metaKey: false }); + expect(matchesKeybind(event, KEYBINDS.SEND_MESSAGE_AFTER_TURN)).toBe(true); + }); + + it("matches Cmd+Enter on macOS", () => { + globalThis.window = { api: { platform: "darwin" } } as unknown as Window & typeof globalThis; + const event = createEvent({ key: "Enter", metaKey: true, ctrlKey: false }); + expect(matchesKeybind(event, KEYBINDS.SEND_MESSAGE_AFTER_TURN)).toBe(true); + }); + + it("does not match plain Enter", () => { + globalThis.window = { api: { platform: "linux" } } as unknown as Window & typeof globalThis; + const event = createEvent({ key: "Enter" }); + expect(matchesKeybind(event, KEYBINDS.SEND_MESSAGE_AFTER_TURN)).toBe(false); + }); + + it("SEND_MESSAGE does not match Ctrl+Enter", () => { + globalThis.window = { api: { platform: "linux" } } as unknown as Window & typeof globalThis; + const event = createEvent({ key: "Enter", ctrlKey: true }); + expect(matchesKeybind(event, KEYBINDS.SEND_MESSAGE)).toBe(false); + }); +}); + describe("matchesKeybind", () => { describe("FOCUS_REVIEW_SEARCH_QUICK keybind (/)", () => { it("matches Shift+/ when event.key is /", () => { diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index 14ce64b9ae..b3a90c49d2 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -259,6 +259,9 @@ export const KEYBINDS = { /** Send message / Submit form */ SEND_MESSAGE: { key: "Enter" }, + /** Send message after current turn ends */ + SEND_MESSAGE_AFTER_TURN: { key: "Enter", ctrl: true }, + /** Insert newline in text input */ NEW_LINE: { key: "Enter", shift: true }, diff --git a/src/common/orpc/schemas/stream.ts b/src/common/orpc/schemas/stream.ts index de14742644..3d35ad2927 100644 --- a/src/common/orpc/schemas/stream.ts +++ b/src/common/orpc/schemas/stream.ts @@ -593,6 +593,7 @@ export const SendMessageOptionsSchema = z.object({ * iterating on agent files - a broken agent in the worktree won't affect message sending. */ disableWorkspaceAgents: z.boolean().optional(), + queueDispatchMode: z.enum(["tool-end", "turn-end"]).nullish(), }); // Re-export ChatUsageDisplaySchema for convenience diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index ba2651069c..cb4d8fd25b 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -2770,7 +2770,8 @@ export class AgentSession { system1Model: options?.system1Model, system1ThinkingLevel: options?.system1ThinkingLevel, disableWorkspaceAgents: options?.disableWorkspaceAgents, - hasQueuedMessage: () => !this.messageQueue.isEmpty(), + hasQueuedMessage: () => + !this.messageQueue.isEmpty() && this.messageQueue.getQueueDispatchMode() === "tool-end", openaiTruncationModeOverride, }); @@ -3853,8 +3854,10 @@ export class AgentSession { this.assertNotDisposed("queueMessage"); this.messageQueue.add(message, options, internal); this.emitQueuedMessageChanged(); - // Signal to bash_output that it should return early to process the queued message - this.backgroundProcessManager.setMessageQueued(this.workspaceId, true); + // Signal to bash_output that it should return early to process queued messages + // only for tool-end dispatches. + const shouldEarlyReturn = this.messageQueue.getQueueDispatchMode() === "tool-end"; + this.backgroundProcessManager.setMessageQueued(this.workspaceId, shouldEarlyReturn); } clearQueue(): void { diff --git a/src/node/services/messageQueue.test.ts b/src/node/services/messageQueue.test.ts index 597a3059f9..b240b480dd 100644 --- a/src/node/services/messageQueue.test.ts +++ b/src/node/services/messageQueue.test.ts @@ -166,6 +166,112 @@ describe("MessageQueue", () => { }); }); + describe("queue dispatch mode", () => { + it("should default to tool-end when queueing without explicit mode", () => { + queue.add("Follow up"); + + expect(queue.getQueueDispatchMode()).toBe("tool-end"); + }); + + it("should store explicit turn-end mode", () => { + queue.add("Follow up", { + model: "gpt-4", + agentId: "exec", + queueDispatchMode: "turn-end", + }); + + expect(queue.getQueueDispatchMode()).toBe("turn-end"); + }); + + it("should prioritize tool-end mode when mixed", () => { + queue.add("Wait until turn ends", { + model: "gpt-4", + agentId: "exec", + queueDispatchMode: "turn-end", + }); + queue.add("Interrupt at next tool step", { + model: "gpt-4", + agentId: "exec", + queueDispatchMode: "tool-end", + }); + + expect(queue.getQueueDispatchMode()).toBe("tool-end"); + }); + + it("should preserve turn-end mode when compaction enqueue is rejected", () => { + const validOptions: SendMessageOptions = { + model: "gpt-4", + agentId: "exec", + }; + + queue.add("wait for turn end", { + ...validOptions, + queueDispatchMode: "turn-end", + }); + expect(queue.getQueueDispatchMode()).toBe("turn-end"); + + const metadata: MuxMessageMetadata = { + type: "compaction-request", + rawCommand: "/compact", + parsed: {}, + }; + + expect(() => + queue.add("summarize", { + ...validOptions, + queueDispatchMode: "tool-end", + muxMetadata: metadata, + }) + ).toThrow(/Cannot queue compaction request/); + + expect(queue.getQueueDispatchMode()).toBe("turn-end"); + expect(queue.getMessages()).toHaveLength(1); + }); + + it("should preserve turn-end mode when agent-skill enqueue is rejected", () => { + const validOptions: SendMessageOptions = { + model: "gpt-4", + agentId: "exec", + }; + + queue.add("wait for turn end", { + ...validOptions, + queueDispatchMode: "turn-end", + }); + expect(queue.getQueueDispatchMode()).toBe("turn-end"); + + const metadata: MuxMessageMetadata = { + type: "agent-skill", + rawCommand: "/init", + skillName: "init", + scope: "built-in", + }; + + expect(() => + queue.add("run skill", { + ...validOptions, + queueDispatchMode: "tool-end", + muxMetadata: metadata, + }) + ).toThrow(/Cannot queue agent skill/); + + expect(queue.getQueueDispatchMode()).toBe("turn-end"); + expect(queue.getMessages()).toHaveLength(1); + }); + + it("should reset mode to tool-end when cleared", () => { + queue.add("Follow up", { + model: "gpt-4", + agentId: "exec", + queueDispatchMode: "turn-end", + }); + + queue.clear(); + + expect(queue.getQueueDispatchMode()).toBe("tool-end"); + }); + }); + describe("addOnce", () => { it("should dedupe repeated entries by key", () => { const image = { url: "data:image/png;base64,abc", mediaType: "image/png" }; diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index 7eaf5884fd..a9b1f22062 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -42,6 +42,8 @@ function hasReviews(meta: unknown): meta is MetadataWithReviews { return Array.isArray(obj.reviews); } +type QueueDispatchMode = "tool-end" | "turn-end"; + /** * Queue for messages sent during active streaming. * @@ -71,6 +73,7 @@ export class MessageQueue { private latestOptions?: SendMessageOptions; private accumulatedFileParts: FilePart[] = []; private dedupeKeys: Set = new Set(); + private queueDispatchMode: QueueDispatchMode = "tool-end"; private queuedEntryCount = 0; private queuedSyntheticCount = 0; @@ -81,6 +84,10 @@ export class MessageQueue { return isCompactionMetadata(this.firstMuxMetadata); } + getQueueDispatchMode(): QueueDispatchMode { + return this.queueDispatchMode; + } + /** * Add a message to the queue. * Preserves muxMetadata from first message, updates other options. @@ -133,6 +140,13 @@ export class MessageQueue { const incomingIsCompaction = isCompactionMetadata(options?.muxMetadata); const incomingIsAgentSkill = isAgentSkillMetadata(options?.muxMetadata); const queueHasMessages = !this.isEmpty(); + const incomingMode = options?.queueDispatchMode ?? "tool-end"; + const nextQueueDispatchMode = !queueHasMessages + ? incomingMode + : incomingMode === "tool-end" + ? "tool-end" + : this.queueDispatchMode; + const queueHasAgentSkill = isAgentSkillMetadata(this.firstMuxMetadata); // Avoid leaking agent-skill metadata to later queued messages. @@ -161,6 +175,9 @@ export class MessageQueue { ); } + // Commit dispatch mode only after validation checks pass + this.queueDispatchMode = nextQueueDispatchMode; + // Add text message if non-empty if (trimmedMessage.length > 0) { this.messages.push(trimmedMessage); @@ -248,11 +265,15 @@ export class MessageQueue { ? this.firstMuxMetadata : (this.latestOptions?.muxMetadata as unknown); const options = this.latestOptions - ? { - ...this.latestOptions, - muxMetadata, - fileParts: this.accumulatedFileParts.length > 0 ? this.accumulatedFileParts : undefined, - } + ? (() => { + const restOptions: SendMessageOptions = { ...this.latestOptions }; + delete restOptions.queueDispatchMode; + return { + ...restOptions, + muxMetadata, + fileParts: this.accumulatedFileParts.length > 0 ? this.accumulatedFileParts : undefined, + }; + })() : undefined; const allQueuedEntriesAreSynthetic = @@ -271,6 +292,7 @@ export class MessageQueue { this.latestOptions = undefined; this.accumulatedFileParts = []; this.dedupeKeys.clear(); + this.queueDispatchMode = "tool-end"; this.queuedEntryCount = 0; this.queuedSyntheticCount = 0; } diff --git a/tests/ui/chat/sendModeDropdown.test.ts b/tests/ui/chat/sendModeDropdown.test.ts new file mode 100644 index 0000000000..53754b31bc --- /dev/null +++ b/tests/ui/chat/sendModeDropdown.test.ts @@ -0,0 +1,382 @@ +import "../dom"; +import { act, fireEvent, waitFor } from "@testing-library/react"; + +import { preloadTestModules, type TestEnvironment } from "../../ipc/setup"; + +import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { workspaceStore } from "@/browser/stores/WorkspaceStore"; +import { getReviewsKey } from "@/common/constants/storage"; +import type { ReviewsState } from "@/common/types/review"; +import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; + +import { createAppHarness, type AppHarness } from "../harness"; + +interface ServiceContainerPrivates { + backgroundProcessManager: BackgroundProcessManager; +} + +function getBackgroundProcessManager(env: TestEnvironment): BackgroundProcessManager { + return (env.services as unknown as ServiceContainerPrivates).backgroundProcessManager; +} + +async function waitForForegroundToolCallId( + env: TestEnvironment, + workspaceId: string, + toolCallId: string +): Promise { + const controller = new AbortController(); + let iterator: AsyncIterator<{ foregroundToolCallIds: string[] }> | null = null; + + try { + const subscribedIterator = await env.orpc.workspace.backgroundBashes.subscribe( + { workspaceId }, + { signal: controller.signal } + ); + + iterator = subscribedIterator; + + for await (const state of subscribedIterator) { + if (state.foregroundToolCallIds.includes(toolCallId)) { + return; + } + } + + throw new Error("backgroundBashes.subscribe ended before foreground bash was observed"); + } finally { + controller.abort(); + void iterator?.return?.(); + } +} + +function getSendModeButton(container: HTMLElement): HTMLButtonElement | null { + const buttons = Array.from( + container.querySelectorAll('button[aria-label="Send mode options"]') + ) as HTMLButtonElement[]; + + // Multiple ChatInput instances can be mounted; the active workspace input is the last one. + return buttons.at(-1) ?? null; +} + +function getSendModeTrigger(container: HTMLElement): HTMLButtonElement | null { + const button = getSendModeButton(container); + if (!button || button.disabled) { + return null; + } + + return button; +} + +async function waitForSendModeTrigger(container: HTMLElement): Promise { + return waitFor( + () => { + const trigger = getSendModeTrigger(container); + if (!trigger) { + throw new Error("Send mode trigger is not visible"); + } + return trigger; + }, + { timeout: 30_000 } + ); +} + +async function openSendModeMenu(container: HTMLElement): Promise { + let lastError: Error | null = null; + + for (let attempt = 0; attempt < 3; attempt += 1) { + const trigger = await waitForSendModeTrigger(container); + act(() => { + fireEvent.click(trigger); + }); + + try { + await waitFor( + () => { + const expandedTrigger = container.querySelector( + 'button[aria-label="Send mode options"][aria-expanded="true"]' + ); + if (!expandedTrigger) { + throw new Error("Send mode menu did not open"); + } + }, + { timeout: 2_000 } + ); + return; + } catch (error) { + if (error instanceof Error) { + lastError = error; + } else { + lastError = new Error("Send mode menu did not open"); + } + } + } + + throw lastError ?? new Error("Send mode menu did not open"); +} + +async function waitForCanInterrupt(workspaceId: string, expected: boolean): Promise { + await waitFor( + () => { + const state = workspaceStore.getWorkspaceSidebarState(workspaceId); + if (state.canInterrupt !== expected) { + throw new Error(`Expected canInterrupt=${expected}, got ${state.canInterrupt}`); + } + }, + { timeout: 30_000 } + ); +} + +async function getActiveTextarea(container: HTMLElement): Promise { + return waitFor( + () => { + const textareas = Array.from( + container.querySelectorAll('textarea[aria-label="Message Claude"]') + ) as HTMLTextAreaElement[]; + if (textareas.length === 0) { + throw new Error("Chat textarea not found"); + } + + const enabled = [...textareas].reverse().find((textarea) => !textarea.disabled); + if (!enabled) { + throw new Error("Chat textarea is disabled"); + } + + return enabled; + }, + { timeout: 10_000 } + ); +} + +async function startStreamingTurn(app: AppHarness, label: string): Promise { + // Use a long mock echo payload so canInterrupt stays true long enough for dropdown interactions. + const longStreamingTail = " keep-streaming".repeat(600); + await app.chat.send(`[mock:wait-start] ${label}${longStreamingTail}`); + app.env.services.aiService.releaseMockStreamStartGate(app.workspaceId); + await waitForCanInterrupt(app.workspaceId, true); +} + +describe("SendModeDropdown (mock AI router)", () => { + beforeAll(async () => { + await preloadTestModules(); + }); + + test("dropdown trigger is visible but disabled when not streaming", async () => { + const app = await createAppHarness({ branchPrefix: "send-mode-dropdown" }); + + try { + const trigger = getSendModeButton(app.view.container); + expect(trigger).not.toBeNull(); + expect(trigger?.disabled).toBe(true); + } finally { + await app.dispose(); + } + }, 60_000); + + test("dropdown trigger visible while streaming", async () => { + const app = await createAppHarness({ branchPrefix: "send-mode-dropdown" }); + + try { + await startStreamingTurn(app, "show send mode trigger while streaming"); + + const disabledTrigger = await waitFor( + () => { + const trigger = getSendModeButton(app.view.container); + if (!trigger) { + throw new Error("Send mode trigger is not visible"); + } + return trigger; + }, + { timeout: 30_000 } + ); + expect(disabledTrigger.disabled).toBe(true); + + await app.chat.typeWithoutSending("enable send mode dropdown"); + await waitForSendModeTrigger(app.view.container); + + await app.chat.expectStreamComplete(60_000); + + await waitFor( + () => { + const trigger = getSendModeButton(app.view.container); + expect(trigger).not.toBeNull(); + expect(trigger?.disabled).toBe(true); + }, + { timeout: 30_000 } + ); + } finally { + await app.dispose(); + } + }, 60_000); + + test("dropdown menu shows labels and keybind chips", async () => { + const app = await createAppHarness({ branchPrefix: "send-mode-dropdown" }); + + try { + await startStreamingTurn(app, "open send mode dropdown menu"); + await app.chat.typeWithoutSending("open send mode menu"); + + await openSendModeMenu(app.view.container); + + const stepRow = await waitFor( + () => { + const rows = Array.from(app.view.container.querySelectorAll("button")); + const row = rows.find((button) => button.textContent?.includes("Send after step")); + if (!row) { + throw new Error("Send after step row not found"); + } + return row; + }, + { timeout: 30_000 } + ); + + const turnRow = await waitFor( + () => { + const rows = Array.from(app.view.container.querySelectorAll("button")); + const row = rows.find((button) => button.textContent?.includes("Send after turn")); + if (!row) { + throw new Error("Send after turn row not found"); + } + return row; + }, + { timeout: 30_000 } + ); + + expect(stepRow.querySelector("kbd")).not.toBeNull(); + expect(turnRow.querySelector("kbd")).not.toBeNull(); + + const keybindChips = app.view.container.querySelectorAll("kbd"); + expect(keybindChips.length).toBeGreaterThanOrEqual(2); + + await app.chat.expectStreamComplete(60_000); + } finally { + await app.dispose(); + } + }, 60_000); + + test("send-after-turn does NOT auto-background foreground bash", async () => { + const app = await createAppHarness({ branchPrefix: "send-mode-dropdown" }); + + let unregister: (() => void) | undefined; + + try { + const manager = getBackgroundProcessManager(app.env); + const toolCallId = "bash-foreground-send-after-turn"; + let backgrounded = false; + + const registration = manager.registerForegroundProcess( + app.workspaceId, + toolCallId, + "echo foreground bash for send-after-turn", + "foreground bash for send-after-turn", + () => { + backgrounded = true; + unregister?.(); + } + ); + + unregister = registration.unregister; + + await waitForForegroundToolCallId(app.env, app.workspaceId, toolCallId); + + const turnEndMessage = "turn-end test"; + await app.chat.typeWithoutSending(turnEndMessage); + const textarea = await getActiveTextarea(app.view.container); + fireEvent.keyDown(textarea, { key: "Enter", ctrlKey: true }); + + await app.chat.expectTranscriptContains(`Mock response: ${turnEndMessage}`); + await app.chat.expectStreamComplete(); + + expect(backgrounded).toBe(false); + } finally { + unregister?.(); + await app.dispose(); + } + }, 60_000); + + test("send-after-step still auto-backgrounds foreground bash", async () => { + const app = await createAppHarness({ branchPrefix: "send-mode-dropdown" }); + + let unregister: (() => void) | undefined; + + try { + const manager = getBackgroundProcessManager(app.env); + const toolCallId = "bash-foreground-send-after-step"; + let backgrounded = false; + + const registration = manager.registerForegroundProcess( + app.workspaceId, + toolCallId, + "echo foreground bash for send-after-step", + "foreground bash for send-after-step", + () => { + backgrounded = true; + unregister?.(); + } + ); + + unregister = registration.unregister; + + await waitForForegroundToolCallId(app.env, app.workspaceId, toolCallId); + + const toolEndMessage = "tool-end test"; + await app.chat.typeWithoutSending(toolEndMessage); + const textarea = await getActiveTextarea(app.view.container); + fireEvent.keyDown(textarea, { key: "Enter" }); + + await app.chat.expectTranscriptContains(`Mock response: ${toolEndMessage}`); + + await waitFor( + () => { + expect(backgrounded).toBe(true); + }, + { timeout: 60_000 } + ); + + await app.chat.expectStreamComplete(); + } finally { + unregister?.(); + await app.dispose(); + } + }, 60_000); + + test("dropdown enabled with review-only draft during streaming (no typed text)", async () => { + const app = await createAppHarness({ branchPrefix: "send-mode-dropdown" }); + + try { + await startStreamingTurn(app, "review-only dropdown enablement"); + + // Verify dropdown is disabled with no content + const disabledTrigger = getSendModeButton(app.view.container); + expect(disabledTrigger).not.toBeNull(); + expect(disabledTrigger?.disabled).toBe(true); + + // Seed an attached review via persisted state (useReviews listens cross-component) + act(() => { + updatePersistedState(getReviewsKey(app.workspaceId), { + workspaceId: app.workspaceId, + reviews: { + "review-1": { + id: "review-1", + status: "attached", + createdAt: Date.now(), + data: { + filePath: "src/example.ts", + lineRange: "+1", + selectedCode: "const x = 1;", + userNote: "Check this", + }, + }, + }, + lastUpdated: Date.now(), + }); + }); + + // Dropdown should become enabled — canSend is true via review, canInterrupt is true via stream + const enabledTrigger = await waitForSendModeTrigger(app.view.container); + expect(enabledTrigger.disabled).toBe(false); + + await app.chat.expectStreamComplete(60_000); + } finally { + await app.dispose(); + } + }, 60_000); +}); diff --git a/tests/ui/compaction/compaction.test.ts b/tests/ui/compaction/compaction.test.ts index 557286e3a1..8cbf14c90c 100644 --- a/tests/ui/compaction/compaction.test.ts +++ b/tests/ui/compaction/compaction.test.ts @@ -54,6 +54,27 @@ async function waitForForegroundToolCallId( } } +async function getActiveTextarea(container: HTMLElement): Promise { + return waitFor( + () => { + const textareas = Array.from( + container.querySelectorAll('textarea[aria-label="Message Claude"]') + ) as HTMLTextAreaElement[]; + if (textareas.length === 0) { + throw new Error("Chat textarea not found"); + } + + const enabled = [...textareas].reverse().find((textarea) => !textarea.disabled); + if (!enabled) { + throw new Error("Chat textarea is disabled"); + } + + return enabled; + }, + { timeout: 10_000 } + ); +} + async function setDeterministicForceCompactionThreshold( env: TestEnvironment, workspaceId: string @@ -218,6 +239,56 @@ describe("Compaction UI (mock AI router)", () => { } }, 60_000); + test("/compact with Ctrl+Enter (turn-end) does NOT auto-background foreground bash", async () => { + const app = await createAppHarness({ branchPrefix: "compaction-ui" }); + + let unregister: (() => void) | undefined; + + try { + const manager = getBackgroundProcessManager(app.env); + + const toolCallId = "bash-foreground-compact-turn-end"; + let backgrounded = false; + + const registration = manager.registerForegroundProcess( + app.workspaceId, + toolCallId, + "echo foreground bash for compact turn-end", + "foreground bash for compact turn-end", + () => { + backgrounded = true; + unregister?.(); + } + ); + + unregister = registration.unregister; + + // Ensure the UI's subscription has observed the foreground bash before sending /compact. + await waitForForegroundToolCallId(app.env, app.workspaceId, toolCallId); + + const seedMessage = "Seed conversation for /compact turn-end test"; + + const seedResult = await app.env.orpc.workspace.sendMessage({ + workspaceId: app.workspaceId, + message: seedMessage, + options: { model: WORKSPACE_DEFAULTS.model, agentId: WORKSPACE_DEFAULTS.agentId }, + }); + expect(seedResult.success).toBe(true); + await app.chat.expectTranscriptContains(`Mock response: ${seedMessage}`); + + await app.chat.typeWithoutSending("/compact -t 500"); + const textarea = await getActiveTextarea(app.view.container); + fireEvent.keyDown(textarea, { key: "Enter", ctrlKey: true }); + + await app.chat.expectTranscriptContains("Mock compaction summary:", 60_000); + + expect(backgrounded).toBe(false); + } finally { + unregister?.(); + await app.dispose(); + } + }, 60_000); + test("force compaction sends any foreground bash to background", async () => { const app = await createAppHarness({ branchPrefix: "compaction-ui" });