Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/browser/components/ChatInput/SendModeDropdown.tsx
Original file line number Diff line number Diff line change
@@ -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<SendModeDropdownProps> = (props) => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="relative">
<Button
type="button"
size="xs"
variant="ghost"
aria-label="Send mode options"
aria-expanded={isOpen}
disabled={props.disabled}
onClick={() => setIsOpen((prev) => !prev)}
className={cn(
// Button applies a default `[&_svg]:size-4`; override locally so this caret
// stays slightly smaller than the send icon while remaining clearly legible.
"text-muted hover:text-foreground hover:bg-hover inline-flex items-center justify-center rounded-sm px-0.5 py-0.5 font-medium transition-colors duration-200 [&_svg]:!size-3.5 [&_svg]:translate-y-px",
props.triggerClassName
)}
>
<ChevronDown strokeWidth={2.5} />
</Button>

{isOpen && (
<div className="bg-separator border-border-light absolute right-0 bottom-full mb-1 min-w-[12.5rem] rounded-md border p-1.5 shadow-md">
{SEND_DISPATCH_MODES.map((entry) => (
<button
key={entry.mode}
type="button"
className="hover:bg-hover focus-visible:bg-hover text-foreground flex w-full items-center justify-between gap-2 rounded-sm px-2.5 py-1 text-left text-xs"
onClick={() => handleSelect(entry.mode)}
>
<span className="whitespace-nowrap">{entry.label}</span>
<kbd className="bg-background-secondary text-foreground border-border-medium rounded border px-1.5 py-px font-mono text-[10px] whitespace-nowrap">
{formatKeybind(entry.keybind)}
</kbd>
</button>
))}
</div>
)}
</div>
);
};
105 changes: 70 additions & 35 deletions src/browser/components/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -185,6 +186,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (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" &&
Expand Down Expand Up @@ -878,6 +880,9 @@ const ChatInputInner: React.FC<ChatInputProps> = (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".
Expand Down Expand Up @@ -1548,7 +1553,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const executeParsedCommand = async (
parsed: ParsedCommand | null,
restoreInput: string,
options?: { skipConfirmation?: boolean }
options?: { skipConfirmation?: boolean; queueDispatchMode?: QueueDispatchMode }
): Promise<boolean> => {
if (!parsed) {
return false;
Expand Down Expand Up @@ -1578,6 +1583,12 @@ const ChatInputInner: React.FC<ChatInputProps> = (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 = {
Expand All @@ -1586,7 +1597,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
workspaceId: commandWorkspaceId,
projectPath: commandProjectPath,
openSettings: open,
sendMessageOptions,
sendMessageOptions: commandSendMessageOptions,
setInput,
setAttachments,
setSendingState: (increment: boolean) => setSendingCount((c) => c + (increment ? 1 : -1)),
Expand Down Expand Up @@ -1618,7 +1629,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
if (reviewIdsForCheck.length > 0) {
props.onCheckReviews?.(reviewIdsForCheck);
}
props.onMessageSent?.();
props.onMessageSent?.(dispatchMode);
}
}

Expand Down Expand Up @@ -1733,7 +1744,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
[setInput]
);

const handleSend = async () => {
const handleSend = async (overrides?: { queueDispatchMode?: QueueDispatchMode }) => {
if (!canSend) {
return;
}
Expand Down Expand Up @@ -1823,7 +1834,11 @@ const ChatInputInner: React.FC<ChatInputProps> = (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;
}
Expand Down Expand Up @@ -2004,6 +2019,9 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
...(modelOverride ? { model: modelOverride } : {}),
...(thinkingOverride ? { thinkingLevel: thinkingOverride } : {}),
...(modelOneShot ? { skipAiSettingsPersistence: true } : {}),
...(overrides?.queueDispatchMode
? { queueDispatchMode: overrides.queueDispatchMode }
: {}),
additionalSystemInstructions,
editMessageId: editingMessage?.id,
fileParts: sendFileParts,
Expand Down Expand Up @@ -2054,7 +2072,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
if (editingMessage && props.onCancelEdit) {
props.onCancelEdit();
}
props.onMessageSent?.();
props.onMessageSent?.(overrides?.queueDispatchMode ?? "tool-end");
}
} catch (error) {
// Handle unexpected errors
Expand Down Expand Up @@ -2173,6 +2191,12 @@ const ChatInputInner: React.FC<ChatInputProps> = (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) {
Expand Down Expand Up @@ -2492,34 +2516,45 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
/>
</div>

<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
onClick={() => void handleSend()}
disabled={!canSend}
aria-label="Send message"
size="xs"
variant="ghost"
className={cn(
"text-muted hover:text-foreground hover:bg-hover inline-flex items-center justify-center rounded-sm px-1.5 py-0.5 font-medium transition-colors duration-200 disabled:opacity-50",
// Touch: wider tap target, keep icon centered.
"[@media(hover:none)_and_(pointer:coarse)]:h-9 [@media(hover:none)_and_(pointer:coarse)]:w-11 [@media(hover:none)_and_(pointer:coarse)]:px-0 [@media(hover:none)_and_(pointer:coarse)]:py-0 [@media(hover:none)_and_(pointer:coarse)]:text-sm"
)}
>
<SendHorizontal
className="h-3.5 w-3.5 [@media(hover:none)_and_(pointer:coarse)]:h-4 [@media(hover:none)_and_(pointer:coarse)]:w-4"
strokeWidth={2.5}
/>
</Button>
</TooltipTrigger>
<TooltipContent align="center">
Send message{" "}
<span className="mobile-hide-shortcut-hints">
({formatKeybind(KEYBINDS.SEND_MESSAGE)})
</span>
</TooltipContent>
</Tooltip>
<div className="inline-flex items-center gap-0">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
onClick={() => void handleSend()}
disabled={!canSend}
aria-label="Send message"
size="xs"
variant="ghost"
className={cn(
"text-muted hover:text-foreground hover:bg-hover inline-flex items-center justify-center rounded-sm px-1.5 py-0.5 font-medium transition-colors duration-200 disabled:opacity-50",
// Touch: wider tap target, keep icon centered.
"[@media(hover:none)_and_(pointer:coarse)]:h-9 [@media(hover:none)_and_(pointer:coarse)]:w-11 [@media(hover:none)_and_(pointer:coarse)]:px-0 [@media(hover:none)_and_(pointer:coarse)]:py-0 [@media(hover:none)_and_(pointer:coarse)]:text-sm"
)}
>
<SendHorizontal
className="h-3.5 w-3.5 [@media(hover:none)_and_(pointer:coarse)]:h-4 [@media(hover:none)_and_(pointer:coarse)]:w-4"
strokeWidth={2.5}
/>
</Button>
</TooltipTrigger>
<TooltipContent align="center">
<span>Send message ({formatKeybind(KEYBINDS.SEND_MESSAGE)})</span>
</TooltipContent>
</Tooltip>

{variant === "workspace" && (
<SendModeDropdown
disabled={!canChooseDispatchMode}
triggerClassName="-ml-1 px-0"
onSelect={(mode) => {
void handleSend(
mode === "tool-end" ? undefined : { queueDispatchMode: mode }
);
}}
/>
)}
</div>
</div>
</div>
</div>
Expand Down
21 changes: 21 additions & 0 deletions src/browser/components/ChatInput/sendDispatchModes.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 4 additions & 1 deletion src/browser/components/ChatInput/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SendMessageOptions["queueDispatchMode"]>;

export interface ChatInputAPI {
focus: () => void;
Expand All @@ -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<void>;
onModelChange?: (model: string) => void;
isCompacting?: boolean;
Expand Down
23 changes: 15 additions & 8 deletions src/browser/components/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -548,14 +549,20 @@ export const ChatPane: React.FC<ChatPaneProps> = (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) => {
Expand Down Expand Up @@ -990,7 +997,7 @@ interface ChatInputPaneProps {
onContextSwitchCompact: () => void;
onContextSwitchDismiss: () => void;
onModelChange?: (model: string) => void;
onMessageSent: () => void;
onMessageSent: (dispatchMode: QueueDispatchMode) => void;
onTruncateHistory: (percentage?: number) => Promise<void>;
editingMessage: EditingMessageState | undefined;
onCancelEdit: () => void;
Expand Down
Loading
Loading